Compare commits
36 Commits
dependabot
...
feat/ocr-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d348e762f | ||
|
|
7bf3252cdb | ||
|
|
feabd12876 | ||
|
|
2b2295b7ed | ||
|
|
069edcb076 | ||
|
|
d5349057b0 | ||
|
|
678ee862ea | ||
|
|
c391b98af8 | ||
|
|
6ca7f2927a | ||
|
|
4d94aa5040 | ||
|
|
36c5cfc6be | ||
|
|
a54fdcb24c | ||
|
|
926300dee5 | ||
|
|
73700f983a | ||
|
|
2aa1688a17 | ||
|
|
3ae2a827c4 | ||
|
|
fec0ac8d0a | ||
|
|
be9febffb1 | ||
|
|
e5793031f8 | ||
|
|
716a8bf2ef | ||
|
|
b10706cf69 | ||
|
|
1f587867c6 | ||
|
|
0b253c73bb | ||
|
|
1648edeba7 | ||
|
|
4090e064cb | ||
|
|
3fac04b00e | ||
|
|
2f7b0194a5 | ||
|
|
9a4f9f0699 | ||
|
|
3d2b4bb203 | ||
|
|
4ebe7c9683 | ||
|
|
b92bff56a7 | ||
|
|
ab649c55da | ||
|
|
99c44442e7 | ||
|
|
9b5e5b5710 | ||
|
|
f4f35866f5 | ||
|
|
4ba074020c |
37
README.md
37
README.md
@@ -1,3 +1,36 @@
|
||||
## Local Agents Marketplace
|
||||
# Local Agents Marketplace
|
||||
|
||||
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready.
|
||||
Bonded-escrow coordination for AI agent work on **Vector**, the Apex Fusion eUTXO L2.
|
||||
|
||||
Buyers commission work. Suppliers claim it. Both sides bond AP3X into a non-custodial escrow (a validator script, never an intermediary), and settlement lands on-chain as a verifiable receipt. Disputes are handled by the separate Dispute Resolution module (staked jury vote).
|
||||
|
||||
**Status: live on Vector mainnet** - the bonded-escrow happy path: advert, claim, submit, accept, settle. The contracts have been through the internal audit pipeline (methodology published in [vector-ai-agents](https://github.com/Apex-Fusion/vector-ai-agents)); they have not yet undergone independent third-party audit.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Advert** - a buyer posts a job with escrow bonded
|
||||
2. **Claim** - a supplier claims it, bonding their side
|
||||
3. **Submit** - work is delivered
|
||||
4. **Accept** - the buyer accepts the work
|
||||
5. **Settle** - the escrow releases; the settlement is a signed on-chain receipt
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `contracts/advert`, `contracts/escrow`, `contracts/marketplace` | Aiken validators |
|
||||
| `docs/ARCHITECTURE.md` | Architecture spec |
|
||||
| `docs/buyer/`, `docs/supplier/` | Role guides |
|
||||
| `deploy/` | Deployment and simulation tooling |
|
||||
|
||||
Monorepo managed with pnpm workspaces.
|
||||
|
||||
## Getting started
|
||||
|
||||
Developer docs: [Vector AI documentation](https://apex-fusion.github.io/vector-ai-documentation/). For the contract-level picture, start with [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [mcp-server](https://github.com/Apex-Fusion/mcp-server) - hosted MCP server for Vector (testnet and mainnet)
|
||||
- [agent-sdk-py](https://github.com/Apex-Fusion/agent-sdk-py) / [agent-sdk-ts](https://github.com/Apex-Fusion/agent-sdk-ts) - Python and TypeScript SDKs
|
||||
- [vector-agent-modules](https://github.com/Apex-Fusion/vector-agent-modules) - dispute resolution, reputation staking, self-improvement
|
||||
|
||||
@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"express": "^4.21.2",
|
||||
"gpt-tokenizer": "^2.8.1",
|
||||
"multer": "^2.2.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -42,10 +42,10 @@
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"happy-dom": "^15.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.4.3"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,48 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-kimi-2@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.kimi-2",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-kimi-3@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.kimi-3",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-kimi-4@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.kimi-4",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-deepseek-flash-1@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-1",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-deepseek-flash-2@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-2",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-deepseek-flash-3@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-3",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
* Defaults to "./data/archive" relative to cwd.
|
||||
* When unset, archive is disabled (responses are
|
||||
* not persisted; /v1/responses* return 503).
|
||||
* GATEWAY_PUBLIC_URL — Public base URL of the OpenAI-compatible gateway
|
||||
* (e.g. https://api.marketplace.vector.apexfusion.org).
|
||||
* Injected into the SPA boot block so the "Generate
|
||||
* API key" page knows where to POST /signup. When
|
||||
* unset, the SPA falls back to deriving "api." +
|
||||
* current host.
|
||||
*/
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -56,6 +62,7 @@ export interface BuyerConfig {
|
||||
password: string;
|
||||
sessionSecret: string;
|
||||
cookieSecure: boolean;
|
||||
gatewayPublicUrl: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -141,6 +148,11 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
cookieSecure = cookieSecureStr === "1";
|
||||
}
|
||||
|
||||
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
|
||||
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||
// derives the URL from its own host ("api." + location.host).
|
||||
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
indexerUrl,
|
||||
@@ -155,5 +167,6 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
password,
|
||||
sessionSecret,
|
||||
cookieSecure,
|
||||
gatewayPublicUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
password: config.password,
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||
jobStore,
|
||||
pdfCaps,
|
||||
});
|
||||
|
||||
@@ -73,8 +73,10 @@ import {
|
||||
buildPostEscrowTx,
|
||||
buildPostTtsEscrowTx,
|
||||
buildPostChatEscrowTx,
|
||||
buildPostOcrEscrowTx,
|
||||
ttsPromptHash,
|
||||
chatSessionPromptHash,
|
||||
ocrPromptHash,
|
||||
buildAcceptTx,
|
||||
buildReclaimTx,
|
||||
TxConstructionError,
|
||||
@@ -88,8 +90,11 @@ import type {
|
||||
SubmitPromptResult,
|
||||
SubmitTtsOptions,
|
||||
SubmitTtsResult,
|
||||
SubmitOcrOptions,
|
||||
SubmitOcrResult,
|
||||
StartChatOptions,
|
||||
StartChatResult,
|
||||
ChatSettleMode,
|
||||
EndChatOptions,
|
||||
EndChatResult,
|
||||
AcceptResultOptions,
|
||||
@@ -806,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
|
||||
|
||||
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
|
||||
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
|
||||
const request = { image_b64, mime, output_format };
|
||||
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
|
||||
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
|
||||
supplier_pkh: advertDatum?.supplier_pkh ?? "",
|
||||
capability_id: advertDatum?.capability_id ?? "",
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "failed",
|
||||
failure_reason: reason,
|
||||
});
|
||||
};
|
||||
|
||||
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
|
||||
let escrowResult;
|
||||
try {
|
||||
const utxo = await this.chain.queryUtxo(advertRef);
|
||||
if (utxo && utxo.datumHex) {
|
||||
try {
|
||||
advertDatum = decodeAdvertDatum(utxo.datumHex);
|
||||
} catch { /* builder will re-throw structurally */ }
|
||||
}
|
||||
escrowResult = await buildPostOcrEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
advertRef,
|
||||
request,
|
||||
payment_lovelace,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TxConstructionError) {
|
||||
recordFailure(err.reason);
|
||||
throw err;
|
||||
}
|
||||
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
escrowOutputRef = escrowResult.escrowOutputRef;
|
||||
escrowRefStr = refToString(escrowOutputRef);
|
||||
|
||||
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
|
||||
try {
|
||||
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||
} catch { /* mock providers / tests */ }
|
||||
|
||||
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
|
||||
try {
|
||||
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
}
|
||||
} catch { /* metadata best-effort */ }
|
||||
|
||||
if (!advertDatum) {
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
|
||||
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
const ocrBody = { image_b64, mime, output_format };
|
||||
|
||||
let ocrResult;
|
||||
try {
|
||||
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 202 + poll mirror of submitTts's async handling. OCR inference for a
|
||||
// page runs seconds; the long tail is the Submit tx confirmation.
|
||||
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
|
||||
const jobId = (ocrResult.body as { job_id?: string }).job_id;
|
||||
if (typeof jobId !== "string" || jobId.length === 0) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier 202 response missing job_id",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let polled = ocrResult;
|
||||
let firstIter = true;
|
||||
while (Date.now() < pollDeadline) {
|
||||
if (!firstIter) {
|
||||
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
firstIter = false;
|
||||
try {
|
||||
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (polled.status === 200) break;
|
||||
if (polled.status === 202) continue;
|
||||
const failBody = (polled.body && typeof polled.body === "object")
|
||||
? polled.body as { reason?: string; message?: string }
|
||||
: {};
|
||||
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
|
||||
status: polled.status,
|
||||
message: failBody.message ?? `supplier returned ${polled.status}`,
|
||||
});
|
||||
recordFailure(sErr.reason);
|
||||
throw sErr;
|
||||
}
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
}
|
||||
ocrResult = polled;
|
||||
}
|
||||
|
||||
if (!ocrResult.ok) {
|
||||
const bodyReason =
|
||||
ocrResult.body && typeof ocrResult.body === "object"
|
||||
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
|
||||
: "supplier_http_error";
|
||||
const sErr = new SupplierError(bodyReason, {
|
||||
status: ocrResult.status,
|
||||
message: `supplier returned ${ocrResult.status}`,
|
||||
});
|
||||
recordFailure(bodyReason);
|
||||
throw sErr;
|
||||
}
|
||||
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier body is not valid JSON",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
|
||||
const responseBody = ocrResult.body as {
|
||||
output_format?: string;
|
||||
content?: string;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
||||
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||
receipt_signature?: string;
|
||||
};
|
||||
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier response is missing content / receipt / receipt_signature",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const receipt = responseBody.receipt;
|
||||
const receiptSignature = responseBody.receipt_signature;
|
||||
|
||||
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 5. Receipt verification ───────────────────────────────────────
|
||||
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
|
||||
recordFailure("wrong_supplier");
|
||||
throw new ReceiptVerificationError("wrong_supplier");
|
||||
}
|
||||
if (receipt.escrow_ref !== escrowRefStr) {
|
||||
recordFailure("wrong_escrow_ref");
|
||||
throw new ReceiptVerificationError("wrong_escrow_ref");
|
||||
}
|
||||
// The OCR prompt commitment uses the SAME canonical hash the supplier
|
||||
// validated against the escrow datum. Recompute from the envelope we
|
||||
// sent; a mismatch means the supplier signed a different request.
|
||||
const expectedPromptHash = ocrPromptHash(request);
|
||||
if (receipt.prompt_hash !== expectedPromptHash) {
|
||||
recordFailure("prompt_hash_mismatch");
|
||||
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||
}
|
||||
if (receipt.model !== advertDatum.model) {
|
||||
recordFailure("request_spec_hash_mismatch");
|
||||
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||
}
|
||||
// The receipt's response_hash must commit the content we actually
|
||||
// received in the requested output shape.
|
||||
const expectedResponseHash = nodeCrypto.createHash("sha256")
|
||||
.update(canonicalize({
|
||||
content: responseBody.content,
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
}), "utf8")
|
||||
.digest("hex");
|
||||
if (receipt.response_hash !== expectedResponseHash) {
|
||||
recordFailure("response_hash_mismatch");
|
||||
throw new ReceiptVerificationError("response_hash_mismatch");
|
||||
}
|
||||
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (receiptSignature === ZERO_SIGNATURE) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
|
||||
recordFailure("malformed_receipt");
|
||||
throw new ReceiptVerificationError("malformed_receipt");
|
||||
}
|
||||
|
||||
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
||||
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr,
|
||||
supplier_pkh: advertDatum.supplier_pkh,
|
||||
capability_id: advertDatum.capability_id,
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "completed",
|
||||
response: responseBody.content.length <= 200
|
||||
? responseBody.content
|
||||
: responseBody.content.slice(0, 200),
|
||||
receipt,
|
||||
receipt_signature: receiptSignature,
|
||||
});
|
||||
|
||||
return {
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
content: responseBody.content,
|
||||
usage: {
|
||||
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
|
||||
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
|
||||
total_tokens: responseBody.usage?.total_tokens ?? 0,
|
||||
},
|
||||
receipt,
|
||||
receiptSignature,
|
||||
escrowRef: escrowOutputRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
|
||||
|
||||
/**
|
||||
@@ -899,7 +1176,13 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
|
||||
this.emitProgress({ type: "chat_started", escrow_ref: escrowRefStr });
|
||||
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url };
|
||||
// Ticket-mode suppliers answer {status:"ticket"} (no Claim); anything else
|
||||
// — "claimed" or an older supplier without the field — is full settlement.
|
||||
const startStatus = startRes.body && typeof startRes.body === "object"
|
||||
? (startRes.body as { status?: string }).status
|
||||
: undefined;
|
||||
const settleMode: ChatSettleMode = startStatus === "ticket" ? "ticket" : "full";
|
||||
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url, settleMode };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -937,10 +1220,20 @@ export class Marketplace extends EventEmitterBase {
|
||||
});
|
||||
}
|
||||
const endBody = endRes.body as {
|
||||
status?: string;
|
||||
settle_mode?: string;
|
||||
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||
receipt_signature?: string;
|
||||
submitted_ref?: string;
|
||||
};
|
||||
// Ticket sessions produce no receipt and nothing to Accept — the Open
|
||||
// escrow returns via reclaim after deliver_by. Trusting the supplier's
|
||||
// response is incentive-safe: lying in either direction only forfeits the
|
||||
// supplier's own payout (the buyer's sweeper reclaims unsettled escrows).
|
||||
if (endBody.settle_mode === "ticket") {
|
||||
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
|
||||
return { settleMode: "ticket", escrowRef };
|
||||
}
|
||||
if (!endBody.receipt || !endBody.receipt_signature) {
|
||||
throw new SupplierError("malformed_response", { message: "supplier end response missing receipt" });
|
||||
}
|
||||
@@ -1022,7 +1315,7 @@ export class Marketplace extends EventEmitterBase {
|
||||
this.emitProgress({ type: "accept_submitted", escrow_ref: refToString(acceptedRef) });
|
||||
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
|
||||
|
||||
return { receipt, receiptSignature, escrowRef, acceptedRef };
|
||||
return { settleMode: "full", receipt, receiptSignature, escrowRef, acceptedRef };
|
||||
}
|
||||
|
||||
// ─── acceptResult / reclaim ───────────────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,9 @@ export type {
|
||||
AcceptResultOptions,
|
||||
ReclaimOptions,
|
||||
GetTaskHistoryOptions,
|
||||
ChatSettleMode,
|
||||
StartChatResult,
|
||||
EndChatResult,
|
||||
} from "./types.js";
|
||||
export { ReceiptVerificationError, IndexerError, SupplierError } from "./types.js";
|
||||
export type { TaskHistoryStore } from "./history.js";
|
||||
|
||||
@@ -187,6 +187,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
|
||||
@@ -197,6 +226,11 @@ export interface StartChatOptions {
|
||||
advertRef: OutputReference;
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
/** Chain settlement mode a chat session runs under (reported by the supplier
|
||||
* at /v1/chat/start): "full" = Claim/Submit/Accept lifecycle; "ticket" = the
|
||||
* buyer's escrow post is the only chain op (reclaimed after deliver_by). */
|
||||
export type ChatSettleMode = "full" | "ticket";
|
||||
|
||||
export interface StartChatResult {
|
||||
escrowRef: OutputReference;
|
||||
/** Random nonce committed into the escrow's session-init prompt_hash. The
|
||||
@@ -204,6 +238,8 @@ export interface StartChatResult {
|
||||
sessionNonce: string;
|
||||
/** Supplier endpoint_url, cached by the buyer-app for message routing. */
|
||||
supplierBaseUrl: string;
|
||||
/** How the supplier settles this session ("full" when it Claimed). */
|
||||
settleMode: ChatSettleMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,13 +256,21 @@ export interface EndChatOptions {
|
||||
/** The browser's local transcript mirror, used to verify response_hash. */
|
||||
transcript?: import("@marketplace/shared/tx").ChatMessage[];
|
||||
}
|
||||
export interface EndChatResult {
|
||||
receipt: Receipt;
|
||||
receiptSignature: string;
|
||||
escrowRef: OutputReference;
|
||||
/** The Submitted escrow UTxO that was Accepted. */
|
||||
acceptedRef: OutputReference;
|
||||
}
|
||||
export type EndChatResult =
|
||||
| {
|
||||
settleMode: "full";
|
||||
receipt: Receipt;
|
||||
receiptSignature: string;
|
||||
escrowRef: OutputReference;
|
||||
/** The Submitted escrow UTxO that was Accepted. */
|
||||
acceptedRef: OutputReference;
|
||||
}
|
||||
| {
|
||||
/** Ticket session: nothing was Submitted/Accepted — no receipt exists.
|
||||
* The Open escrow returns to the buyer via reclaim after deliver_by. */
|
||||
settleMode: "ticket";
|
||||
escrowRef: OutputReference;
|
||||
};
|
||||
|
||||
/**
|
||||
* AcceptResultOptions — arguments to Marketplace.acceptResult().
|
||||
|
||||
@@ -109,6 +109,11 @@ export interface AppDeps {
|
||||
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
|
||||
* clock to drive expiry and rolling-renewal assertions deterministically. */
|
||||
nowMs?: () => number;
|
||||
/** Public base URL of the OpenAI-compatible gateway, injected into the SPA
|
||||
* boot block so the "Generate API key" page can POST cross-origin to
|
||||
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||
* SPA derives "api." + its own host. */
|
||||
gatewayPublicUrl?: string;
|
||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||
@@ -680,9 +685,11 @@ export function createApp(deps: AppDeps): Express {
|
||||
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
|
||||
|
||||
function isChatMessageArray(v: unknown): v is ChatMessage[] {
|
||||
// The free chat demo stays text-only: the `tool` role (added to
|
||||
// ChatMessage for the gateway demo endpoint) is not accepted here.
|
||||
return Array.isArray(v) && v.length > 0 && v.every(
|
||||
(m) => m && typeof m === "object"
|
||||
&& (["system", "user", "assistant"] as const).includes((m as ChatMessage).role)
|
||||
&& ["system", "user", "assistant"].includes((m as ChatMessage).role)
|
||||
&& typeof (m as ChatMessage).content === "string",
|
||||
);
|
||||
}
|
||||
@@ -927,6 +934,14 @@ export function createApp(deps: AppDeps): Express {
|
||||
supplierBaseUrl: state.supplierBaseUrl,
|
||||
});
|
||||
chatRouteState.delete(rawRef);
|
||||
if (result.settleMode === "ticket") {
|
||||
// Ticket session: no receipt/Accept — funds return via reclaim.
|
||||
return res.status(200).json({
|
||||
status: "ended",
|
||||
escrow_ref: rawRef,
|
||||
settle_mode: "ticket",
|
||||
});
|
||||
}
|
||||
return res.status(200).json({
|
||||
status: "accepted",
|
||||
escrow_ref: rawRef,
|
||||
@@ -1310,6 +1325,9 @@ export function createApp(deps: AppDeps): Express {
|
||||
pubKeyHash: resolved.walletKey.pubKeyHash,
|
||||
address: displayAddress,
|
||||
},
|
||||
// Public gateway base URL for the "Generate API key" page. Empty
|
||||
// string when unconfigured — the SPA then derives "api." + host.
|
||||
gatewayUrl: deps.gatewayPublicUrl ?? "",
|
||||
})};</script>`
|
||||
: "";
|
||||
const indexWithBoot = cachedTemplate.replace(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* /tasks → <TaskHistory />
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||
*/
|
||||
|
||||
import { Routes, Route, Link } from "react-router-dom";
|
||||
@@ -14,6 +15,7 @@ import TaskHistory from "./pages/TaskHistory.js";
|
||||
import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||
import ApiKeys from "./pages/ApiKeys.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
@@ -35,10 +37,13 @@ export default function App() {
|
||||
<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">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link>
|
||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
|
||||
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
|
||||
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
|
||||
<Link to="/api-keys" className="font-medium hover:underline">API Keys</Link>
|
||||
<SignOutButton />
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
@@ -48,6 +53,7 @@ export default function App() {
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
<Route path="/wallet" element={<Wallet />} />
|
||||
<Route path="/api-keys" element={<ApiKeys />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -267,7 +267,18 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
|
||||
} catch { /* keep fallback */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
setCharged(payment_lovelace !== undefined ? ap3x(payment_lovelace) : "0");
|
||||
let endBody: { settle_mode?: string } = {};
|
||||
try {
|
||||
endBody = (await resp.json()) as { settle_mode?: string };
|
||||
} catch { /* older buyer-app responses stay full-settle */ }
|
||||
// Ticket sessions never charge — the escrow returns via reclaim.
|
||||
setCharged(
|
||||
endBody.settle_mode === "ticket"
|
||||
? "0 (ticket session — escrow reclaimed)"
|
||||
: payment_lovelace !== undefined
|
||||
? ap3x(payment_lovelace)
|
||||
: "0",
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function JobEstimate({
|
||||
<dd className="text-right">{est.mapCalls}</dd>
|
||||
<dt>Reduce calls</dt>
|
||||
<dd className="text-right">{est.reduceCalls}</dd>
|
||||
<dt className="font-medium">Total paid calls</dt>
|
||||
<dt className="font-medium">Total escrowed calls</dt>
|
||||
<dd className="text-right font-medium">{est.totalCalls}</dd>
|
||||
<dt className="font-medium">Est. max cost</dt>
|
||||
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
||||
@@ -126,7 +126,7 @@ export default function JobEstimate({
|
||||
disabled={blocked || starting}
|
||||
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
|
||||
>
|
||||
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
|
||||
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Upload a text-based PDF book. The buyer will chunk it and pay marketplace
|
||||
Upload a text-based PDF book. The buyer will chunk it and commission marketplace
|
||||
suppliers to summarize each chunk, then synthesize a full-book summary.
|
||||
</p>
|
||||
<input
|
||||
|
||||
32
buyer/src/ui/gateway.ts
Normal file
32
buyer/src/ui/gateway.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* buyer/src/ui/gateway.ts — resolve the OpenAI-compatible gateway base URL the
|
||||
* SPA should call for the "Generate API key" page.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. window.__BUYER_BOOT__.gatewayUrl — injected by the buyer server from the
|
||||
* GATEWAY_PUBLIC_URL env (the configured value). Present on any page load
|
||||
* made while the buyer_session cookie is valid.
|
||||
* 2. Derived "api." + current host — the deploy convention is exactly
|
||||
* api.<buyer-host> (marketplace.… → api.marketplace.…). This covers the
|
||||
* gap right after a fresh login, when the SPA flips auth state WITHOUT a
|
||||
* page reload so the boot block (which is only injected into
|
||||
* server-rendered HTML) hasn't been re-fetched yet.
|
||||
*
|
||||
* The result never has a trailing slash.
|
||||
*/
|
||||
|
||||
interface BuyerBootMaybeGateway {
|
||||
gatewayUrl?: string;
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveGatewayUrl(): string {
|
||||
const boot = (window as unknown as { __BUYER_BOOT__?: BuyerBootMaybeGateway })
|
||||
.__BUYER_BOOT__;
|
||||
const configured = boot?.gatewayUrl?.trim();
|
||||
if (configured) return stripTrailingSlashes(configured);
|
||||
return `${window.location.protocol}//api.${window.location.host}`;
|
||||
}
|
||||
205
buyer/src/ui/pages/ApiKeys.tsx
Normal file
205
buyer/src/ui/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* buyer/src/ui/pages/ApiKeys.tsx — self-serve "Generate API key" page.
|
||||
*
|
||||
* Mints a custodial gateway API key by POSTing directly to the gateway's
|
||||
* public /signup endpoint (cross-origin; the gateway CORS-allows this origin).
|
||||
* The raw key is shown ONCE — it is never stored server-side in retrievable
|
||||
* form, so it cannot be listed or recovered later. The page also surfaces the
|
||||
* funding deposit address and a ready-to-paste usage snippet.
|
||||
*
|
||||
* Access is gated by the SPA's existing operator login (this page only renders
|
||||
* inside <RequireAuth>). The gateway /signup itself stays public + IP
|
||||
* rate-limited; per-user accounts are a later feature.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { resolveGatewayUrl } from "../gateway.js";
|
||||
|
||||
interface SignupResult {
|
||||
api_key: string;
|
||||
key_prefix: string;
|
||||
deposit_address: string;
|
||||
}
|
||||
|
||||
/** Pull a human-readable message out of whatever error shape the gateway
|
||||
* returns (OpenAI `{error:{message}}`, plain `{error|message}`, or neither). */
|
||||
function errorMessage(body: unknown, status: number, statusText: string): string {
|
||||
if (body && typeof body === "object") {
|
||||
const b = body as Record<string, unknown>;
|
||||
const err = b.error;
|
||||
if (err && typeof err === "object" && typeof (err as Record<string, unknown>).message === "string") {
|
||||
return (err as Record<string, string>).message;
|
||||
}
|
||||
if (typeof err === "string") return err;
|
||||
if (typeof b.message === "string") return b.message;
|
||||
}
|
||||
return `${status} ${statusText}`.trim() || "request failed";
|
||||
}
|
||||
|
||||
function CopyButton({ value, label }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
?.writeText(value)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
/* clipboard blocked (insecure context / permissions) — no-op */
|
||||
});
|
||||
}}
|
||||
className="rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{copied ? "Copied!" : (label ?? "Copy")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [label, setLabel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SignupResult | null>(null);
|
||||
|
||||
const gatewayUrl = resolveGatewayUrl();
|
||||
const baseUrl = `${gatewayUrl}/openai/v1`;
|
||||
|
||||
async function generate() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(label.trim() ? { label: label.trim() } : {}),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as unknown;
|
||||
if (!res.ok) {
|
||||
throw new Error(errorMessage(body, res.status, res.statusText));
|
||||
}
|
||||
const r = body as Partial<SignupResult> | null;
|
||||
if (!r || typeof r.api_key !== "string" || typeof r.deposit_address !== "string") {
|
||||
throw new Error("unexpected response from gateway");
|
||||
}
|
||||
setResult({
|
||||
api_key: r.api_key,
|
||||
key_prefix: r.key_prefix ?? r.api_key.slice(0, 12),
|
||||
deposit_address: r.deposit_address,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "could not reach the gateway");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const curlSnippet =
|
||||
result === null
|
||||
? ""
|
||||
: [
|
||||
`curl ${baseUrl}/chat/completions \\`,
|
||||
` -H "Authorization: Bearer ${result.api_key}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"model": "<model>", "messages": [{"role": "user", "content": "Hello"}]}'`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">API Keys</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
|
||||
gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
|
||||
<code className="font-mono text-xs">{baseUrl}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Label <span className="font-normal text-gray-400">(optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. my-laptop"
|
||||
maxLength={120}
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generate()}
|
||||
disabled={loading}
|
||||
className="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||
data-testid="generate-api-key"
|
||||
>
|
||||
{loading ? "Generating…" : "Generate API key"}
|
||||
</button>
|
||||
{error !== null && (
|
||||
<p className="text-sm text-red-600" data-testid="api-key-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result !== null && (
|
||||
<div className="space-y-4 rounded-lg border border-amber-300 bg-amber-50 p-4" data-testid="api-key-result">
|
||||
<div className="rounded border border-amber-400 bg-amber-100 px-3 py-2 text-sm text-amber-900">
|
||||
Copy your API key now — it is shown <strong>only once</strong> and cannot be recovered. If you
|
||||
lose it, generate a new one.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">API key</span>
|
||||
<CopyButton value={result.api_key} />
|
||||
</div>
|
||||
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||
{result.api_key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Deposit address (fund with AP3X)</span>
|
||||
<CopyButton value={result.deposit_address} />
|
||||
</div>
|
||||
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||
{result.deposit_address}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Base URL</span>
|
||||
<CopyButton value={baseUrl} />
|
||||
</div>
|
||||
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||
{baseUrl}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Quick start</span>
|
||||
<CopyButton value={curlSnippet} />
|
||||
</div>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100">
|
||||
{curlSnippet}
|
||||
</pre>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Replace <code className="font-mono"><model></code> with a model from{" "}
|
||||
<code className="font-mono">{baseUrl}/models</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default function BookSummarizer() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
|
||||
Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
|
||||
Jobs keep running if you navigate away; reopen them below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
|
||||
* for this buyer, with an "Accept & Pay" action per row.
|
||||
* for this buyer, with an "Accept & Settle" action per row.
|
||||
*
|
||||
* Data flow:
|
||||
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
|
||||
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
|
||||
<th className="px-2 py-2">Submitted</th>
|
||||
<th className="px-2 py-2">Supplier</th>
|
||||
<th className="px-2 py-2">Capability</th>
|
||||
<th className="px-2 py-2">Payment</th>
|
||||
<th className="px-2 py-2">Escrowed</th>
|
||||
<th className="px-2 py-2">Window left</th>
|
||||
<th className="px-2 py-2">Action</th>
|
||||
</tr>
|
||||
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
|
||||
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
||||
}
|
||||
>
|
||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
|
||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500">·</span>
|
||||
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
||||
<span>{fmtAda(lc.payment_lovelace)} escrowed</span>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
||||
<span className="text-gray-500">·</span>
|
||||
|
||||
@@ -29,6 +29,11 @@ services:
|
||||
# the response archive + PDF job store (past summaries) across container
|
||||
# recreation/redeploys.
|
||||
ARCHIVE_DIR: /repo/data/archive
|
||||
# Public base URL of the OpenAI gateway, surfaced to the SPA boot block so
|
||||
# the "API Keys" page can POST cross-origin to ${gatewayUrl}/signup. The
|
||||
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||
volumes:
|
||||
- buyer-mainnet-archive:/repo/data/archive
|
||||
|
||||
@@ -27,6 +27,24 @@ services:
|
||||
NETWORK_ID: "1"
|
||||
GATEWAY_PORT: "8080"
|
||||
GATEWAY_DB_DIR: /repo/data/gateway
|
||||
# Mainnet chain genesis (slot↔wallclock). MUST match buyer/supplier or
|
||||
# PostEscrow validity bounds land ~51 days off and Ogmios rejects (code 3118).
|
||||
VECTOR_ZERO_TIME_MS: "1756485600000"
|
||||
# On-chain reference-script UTxOs (escrow spending validator) used by
|
||||
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
|
||||
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
# Browser Origins allowed to call the gateway cross-origin. The buyer
|
||||
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
|
||||
# /signup here. Comma-separated; empty disables CORS entirely.
|
||||
GATEWAY_CORS_ORIGINS: https://marketplace.vector.apexfusion.org
|
||||
# Ticket mode: the escrow post at session open is the only chain op;
|
||||
# the sweeper reclaims Open escrows after deliver_by. Must match the
|
||||
# kimi-chat suppliers' CHAT_SETTLE_MODE.
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
# Demo janitor closes idle sessions BEFORE the suppliers' 15 min
|
||||
# CHAT_IDLE_TIMEOUT_MS backstop.
|
||||
DEMO_SESSION_IDLE_MS: "600000"
|
||||
volumes:
|
||||
- gateway-mainnet-data:/repo/data/gateway
|
||||
healthcheck:
|
||||
@@ -41,8 +59,18 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
|
||||
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
|
||||
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
|
||||
# answered by traefik before the redirect applies.
|
||||
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #1)
|
||||
#
|
||||
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||
# instance name, container, env file, Traefik host/router, and advert model.
|
||||
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-deepseek-flash-1.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.deepseek-flash-1 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-deepseek-flash-1
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-deepseek-flash-1
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.deepseek-flash-1
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-1.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #2)
|
||||
#
|
||||
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||
# instance name, container, env file, Traefik host/router, and advert model.
|
||||
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-deepseek-flash-2.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.deepseek-flash-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-deepseek-flash-2
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-deepseek-flash-2
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.deepseek-flash-2
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-2.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #3)
|
||||
#
|
||||
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||
# instance name, container, env file, Traefik host/router, and advert model.
|
||||
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-deepseek-flash-3.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.deepseek-flash-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-deepseek-flash-3
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-deepseek-flash-3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.deepseek-flash-3
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-3.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.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
|
||||
|
||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml — Supplier — MAINNET
|
||||
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 1)
|
||||
#
|
||||
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS A-record: mp-suppliers-ds-flash-chat-1.vector.apexfusion.org -> host IP
|
||||
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||
# 4. supplier/.env.ds-flash-chat-1 written on host (chmod 600) with at least:
|
||||
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||
|
||||
name: marketplace-mainnet-supplier-ds-flash-chat-1
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-ds-flash-chat-1
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.ds-flash-chat-1
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-1.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml — Supplier — MAINNET
|
||||
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
|
||||
#
|
||||
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS A-record: mp-suppliers-ds-flash-chat-2.vector.apexfusion.org -> host IP
|
||||
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||
# 4. supplier/.env.ds-flash-chat-2 written on host (chmod 600) with at least:
|
||||
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||
|
||||
name: marketplace-mainnet-supplier-ds-flash-chat-2
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-ds-flash-chat-2
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.ds-flash-chat-2
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-2.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml — Supplier — MAINNET
|
||||
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
|
||||
#
|
||||
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS A-record: mp-suppliers-ds-flash-chat-3.vector.apexfusion.org -> host IP
|
||||
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||
# 4. supplier/.env.ds-flash-chat-3 written on host (chmod 600) with at least:
|
||||
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||
|
||||
name: marketplace-mainnet-supplier-ds-flash-chat-3
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-ds-flash-chat-3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.ds-flash-chat-3
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-3.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -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
|
||||
|
||||
73
deploy/mainnet/docker-compose.supplier-kimi-2.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-2.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-2.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #2)
|
||||
#
|
||||
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||
# concurrency ceiling; supplier has no 429/backoff).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-kimi-2.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.kimi-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-2
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-2
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.kimi-2
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||
# upstream call isn't aborted before the advertised processing deadline.
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from supplier/.env.kimi-2 on the mainnet host (footgun avoidance).
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
73
deploy/mainnet/docker-compose.supplier-kimi-3.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-3.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-3.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #3)
|
||||
#
|
||||
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||
# concurrency ceiling; supplier has no 429/backoff).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-kimi-3.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.kimi-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-3
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.kimi-3
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||
# upstream call isn't aborted before the advertised processing deadline.
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from supplier/.env.kimi-3 on the mainnet host (footgun avoidance).
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
73
deploy/mainnet/docker-compose.supplier-kimi-4.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-4.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-4.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #4)
|
||||
#
|
||||
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||
# concurrency ceiling; supplier has no 429/backoff).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS (proxied): mp-suppliers-kimi-4.vector.apexfusion.org → host
|
||||
# 2. supplier/.env.kimi-4 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-4
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-4
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.kimi-4
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||
# upstream call isn't aborted before the advertised processing deadline.
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from supplier/.env.kimi-4 on the mainnet host (footgun avoidance).
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
84
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
84
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml — Supplier — MAINNET
|
||||
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
|
||||
#
|
||||
# Second llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||
# advert, env file and hostname.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS A-record: mp-suppliers-kimi-chat-2.vector.apexfusion.org → host IP
|
||||
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||
# 4. supplier/.env.kimi-chat-2 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-chat-2
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-chat-2
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.kimi-chat-2
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.kimi-chat-2 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
84
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
84
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml — Supplier — MAINNET
|
||||
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
|
||||
#
|
||||
# Third llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||
# advert, env file and hostname.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS A-record: mp-suppliers-kimi-chat-3.vector.apexfusion.org → host IP
|
||||
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||
# 4. supplier/.env.kimi-chat-3 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-chat-3
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-chat-3
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ../../supplier/.env.kimi-chat-3
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.kimi-chat-3 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -49,8 +49,12 @@ services:
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Auto-end an idle session after 5 min (supplier Submits the transcript).
|
||||
CHAT_IDLE_TIMEOUT_MS: "300000"
|
||||
# 2.5 min idle end (supplier Submits the transcript): single-slot
|
||||
# suppliers must free up quickly for the shared demo endpoint (the
|
||||
# gateway transparently reopens a session on the next turn).
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
@@ -70,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
|
||||
|
||||
@@ -48,8 +48,10 @@ services:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||
# Kimi K2.6 generations can be slow; mirror the DeepSeek/GPT composes.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||
# upstream call isn't aborted before the advertised processing deadline
|
||||
# (matches the kimi-2/3/4 replicas).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
# Without this the supplier silently falls back to ReadOnlyOgmiosProvider
|
||||
@@ -73,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 1. Goal and scope
|
||||
|
||||
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
||||
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
||||
|
||||
**What v1 is NOT.**
|
||||
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
||||
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
||||
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
||||
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
|
||||
- Not multi-chain. Vector only (testnet and mainnet).
|
||||
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
||||
- Not streaming. `stream: true` is rejected by suppliers.
|
||||
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
|
||||
| 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
|
||||
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
||||
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
||||
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
||||
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
||||
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
||||
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
||||
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
||||
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
|
||||
|
||||
## 9. Open follow-ups (not blocking M0 / M1)
|
||||
|
||||
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
||||
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
||||
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
||||
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
||||
- Masumi docs: https://docs.masumi.network/
|
||||
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
---
|
||||
marp: true
|
||||
paginate: true
|
||||
size: 16:9
|
||||
---
|
||||
|
||||
<!--
|
||||
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
||||
Build PDF + PPTX with: bash docs/build-deck.sh
|
||||
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
||||
* Every slide uses the same navy→indigo gradient as the title slide so
|
||||
* the deck reads as one continuous document. Title and closing slides
|
||||
* keep the centered hero treatment; content slides stay top-aligned but
|
||||
* share the same palette and accent rules. */
|
||||
:root {
|
||||
--ink: #f1f5f9; /* primary text on dark gradient */
|
||||
--muted: #94a3b8;
|
||||
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
||||
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
||||
--accent-soft: rgba(129,140,248,0.12);
|
||||
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
||||
--row-even: rgba(255,255,255,0.04);
|
||||
--row-odd: rgba(255,255,255,0.02);
|
||||
--border: rgba(255,255,255,0.10);
|
||||
}
|
||||
section {
|
||||
background: var(--gradient);
|
||||
color: var(--ink);
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 22px;
|
||||
padding: 40px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
section::after {
|
||||
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
border-bottom: 4px solid var(--accent);
|
||||
padding-bottom: 10px;
|
||||
margin: 0 0 18px 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
margin-right: 14px;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
}
|
||||
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
||||
p, li { margin: 6px 0; line-height: 1.45; }
|
||||
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
||||
strong { color: #ffffff; }
|
||||
a { color: #a5b4fc; }
|
||||
blockquote {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 10px 18px;
|
||||
color: #e0e7ff;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
code {
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: #e0e7ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
||||
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
||||
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
||||
* stays readable on the navy gradient. */
|
||||
img {
|
||||
display: block;
|
||||
margin: 8px auto;
|
||||
max-width: 92%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
/* Tile grid for the local-agent versatility slide. */
|
||||
.backends {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
margin: 14px 0 12px 0;
|
||||
}
|
||||
.tile {
|
||||
background: var(--accent-soft);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.tile small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.tile.dots {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
.pipeline {
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid var(--border);
|
||||
color: #f8fafc;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
margin: 6px auto 14px auto;
|
||||
max-width: 760px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pipeline span {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
/* Tables sit on a solid white card so cell text reads cleanly against
|
||||
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
||||
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
||||
* needed so the table actually consumes the full slide width — Marp's
|
||||
* default theme leaves tables at content-natural width, which left
|
||||
* blank gutters on either side. */
|
||||
table {
|
||||
display: table !important;
|
||||
width: 100% !important;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 0.82em;
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
||||
th {
|
||||
background: var(--accent-deep);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
tr:nth-child(even) td { background: #ffffff; }
|
||||
tr:nth-child(odd) td { background: #f8fafc; }
|
||||
/* code spans inside table cells need a light pill, not the dark one
|
||||
* defined globally — otherwise they'd disappear into the white row. */
|
||||
td code {
|
||||
background: #eef2ff;
|
||||
color: #1e293b;
|
||||
}
|
||||
/* Bold/strong inside cells must stay dark — the global rule paints
|
||||
* <strong> white for the dark-gradient body text, which would render
|
||||
* invisible against the white table card. */
|
||||
td strong, th strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
th strong {
|
||||
color: #ffffff;
|
||||
}
|
||||
/* Title slide — centered hero treatment on the same gradient. */
|
||||
section.title {
|
||||
text-align: center;
|
||||
padding: 100px 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
section.title h1 {
|
||||
display: inline-block;
|
||||
font-size: 64px;
|
||||
margin: 0 auto 20px auto;
|
||||
}
|
||||
section.title blockquote {
|
||||
margin: 28px auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
/* Closing slide — same centered treatment. */
|
||||
section.closing {
|
||||
text-align: center;
|
||||
padding: 100px 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
section.closing h2::before { display: none; }
|
||||
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
||||
</style>
|
||||
|
||||
<!-- _class: title -->
|
||||
|
||||
# Local Agents Marketplace
|
||||
|
||||
### A trust-minimised, on-chain marketplace for LLM inference
|
||||
|
||||
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
||||
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
||||
|
||||
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
||||
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
||||
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
||||
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
||||
|
||||
We need a **two-sided marketplace** where:
|
||||
1. Sellers monetise spare compute,
|
||||
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
||||
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
||||
|
||||
---
|
||||
|
||||
## How it works — three actors, one chain
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph CHAIN[Vector — UTxO]
|
||||
ADV[Advert UTxO<br/>capability, price, bonds]
|
||||
ESC[Escrow UTxO<br/>locked funds + state]
|
||||
end
|
||||
|
||||
SUP[🧠 Supplier<br/>local LLM node]
|
||||
BUY[💼 Buyer<br/>app or agent]
|
||||
IDX[🔭 Indexer<br/>read-only bulletin board]
|
||||
|
||||
SUP -->|publish advert| ADV
|
||||
BUY -->|discover| IDX
|
||||
IDX -.->|index events| CHAIN
|
||||
BUY -->|post escrow| ESC
|
||||
BUY -->|prompt + escrow_ref| SUP
|
||||
SUP -->|signed receipt| BUY
|
||||
SUP -->|claim & submit| ESC
|
||||
BUY -->|accept & pay| ESC
|
||||
```
|
||||
|
||||
* **Supplier** runs the inference and publishes a price.
|
||||
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
||||
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
||||
|
||||
---
|
||||
|
||||
## The bonded-escrow lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant B as Buyer
|
||||
participant Chain as Vector
|
||||
participant S as Supplier
|
||||
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
||||
|
||||
S->>Chain: PostAdvert (locks supplier_bond)
|
||||
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
||||
B->>S: POST /v1/chat/completions
|
||||
S->>Chain: ClaimEscrow → Claimed
|
||||
S->>LLM: run inference
|
||||
LLM-->>S: assistant message
|
||||
S->>Chain: SubmitEscrow → Submitted
|
||||
S-->>B: 200 OK + signed receipt
|
||||
B->>Chain: AcceptEscrow — supplier paid
|
||||
```
|
||||
|
||||
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
||||
|
||||
---
|
||||
|
||||
## What if someone misbehaves?
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
||||
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
||||
CLAIMED -->|deliver_by passed| RECLAIM
|
||||
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
||||
|
||||
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
||||
classDef good fill:#dcfce7,stroke:#16a34a
|
||||
classDef warn fill:#fef3c7,stroke:#d97706
|
||||
```
|
||||
|
||||
| Failure mode | Recovery | Loss bearer |
|
||||
|---|---|---|
|
||||
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
||||
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
||||
|
||||
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
||||
|
||||
---
|
||||
|
||||
## Local-agent versatility — any OpenAI-compatible engine
|
||||
|
||||
<div class="pipeline">
|
||||
<span>💼 Buyer</span> →
|
||||
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
||||
<span>backend ↓</span>
|
||||
</div>
|
||||
|
||||
<div class="backends">
|
||||
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
||||
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
||||
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
||||
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
||||
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
||||
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
||||
</div>
|
||||
|
||||
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
||||
|
||||
---
|
||||
|
||||
## Capability + model = task selection
|
||||
|
||||
| Capability ID | Example models | Use case |
|
||||
|---|---|---|
|
||||
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
||||
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
||||
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
||||
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
||||
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
||||
| `…` | … | … |
|
||||
|
||||
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
||||
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
||||
|
||||
---
|
||||
|
||||
## OpenClaw — agentic teams on your existing OpenAI seat
|
||||
|
||||
<div class="pipeline">
|
||||
<span>🧠 Supplier wrapper</span> →
|
||||
<span>🦾 OpenClaw</span> →
|
||||
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
||||
</div>
|
||||
|
||||
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
||||
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
||||
|
||||
---
|
||||
|
||||
## Proven on Vector testnet — 2026-05-05
|
||||
|
||||
| Step | Tx hash |
|
||||
|---|---|
|
||||
| Buyer posts escrow | `af762561…c2f7bc5` |
|
||||
| Supplier claims | (lifecycle tx) |
|
||||
| Supplier submits receipt | (lifecycle tx) |
|
||||
| Buyer accepts | `125e3cfe…7dc100d` |
|
||||
|
||||
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
||||
|
||||
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
||||
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
||||
|
||||
---
|
||||
|
||||
## Why UTxO + Vector
|
||||
|
||||
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
||||
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
||||
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
||||
* **No custom rollup, no bridge** — runs on a production chain today.
|
||||
|
||||
---
|
||||
|
||||
<!-- _class: closing -->
|
||||
|
||||
## The pitch in one sentence
|
||||
|
||||
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
||||
|
||||
### Try it
|
||||
|
||||
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
||||
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,11 @@
|
||||
# Vector Buyer App
|
||||
|
||||
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
|
||||
A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You type a **prompt** and set a **payment** amount in the web UI
|
||||
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
|
||||
1. You type a **prompt** and set a **price** in the web UI
|
||||
2. The app posts an **escrow transaction** on-chain, locking price + bond
|
||||
3. The app sends the task to a **supplier node** via HTTP
|
||||
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
||||
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
||||
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
|
||||
|
||||
- **Never share your .env file** - it contains your wallet seed phrase
|
||||
- **Never share your data/ folder** - it contains task history
|
||||
- The seed phrase controls your wallet funds. Back it up securely.
|
||||
- Escrow protects you: the supplier cannot take your funds without delivering a result
|
||||
- The seed phrase controls your wallet. Back it up securely.
|
||||
- Escrow protects you: the supplier cannot take your escrowed value without delivering a result
|
||||
- You have a 10-minute dispute window after receiving a result
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -24,8 +24,8 @@ The audit confirmed 39 holes in the first draft (10 distinct themes). This docum
|
||||
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
|
||||
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
|
||||
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
|
||||
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** |
|
||||
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick |
|
||||
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
|
||||
| Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
|
||||
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
|
||||
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
|
||||
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
|
||||
@@ -41,7 +41,7 @@ New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **o
|
||||
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
|
||||
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
|
||||
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
|
||||
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 ADA`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
|
||||
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
|
||||
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
|
||||
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
|
||||
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
|
||||
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
|
||||
## Request lifecycle — one-shot `/chat/completions`
|
||||
All on-chain steps run inside the per-key `mutex.run(...)`.
|
||||
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
|
||||
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-ADA UTxO ≥5 ADA exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
|
||||
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
|
||||
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
|
||||
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
|
||||
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
|
||||
@@ -113,9 +113,15 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
|
||||
- **close** → `endChat` (Submit + Accept, awaits) → settle + bond refund; record usage; wallet-health.
|
||||
- Sessions are reclaimable by the sweeper if abandoned before close.
|
||||
|
||||
### Settle modes (`CHAT_SETTLE_MODE`, gateway + supplier, default `full`)
|
||||
- **full** — the lifecycle above: supplier Claims at start, Submits a transcript receipt at end, gateway Accepts (buyer charged).
|
||||
- **ticket** — the buyer's escrow post at open is the ONLY chain op. The supplier verifies the Open escrow as an entry ticket (`/v1/chat/start` → `{status:"ticket"}`) but never Claims/Submits; `/v1/chat/end` → `{status:"ended", settle_mode:"ticket"}` with no receipt; the sweeper Reclaims the Open escrow after `deliver_by`, returning all locked funds (net cost ≈ tx fees). Usage rows record `cost_lovelace: 0`. Sessions may outlive `deliver_by` (no hard-cap) — a live session whose ticket got reclaimed keeps running; only the idle janitor/close ends it. Close behavior is driven per-session by the supplier's `/end` response, so mixed-mode rollout windows settle correctly.
|
||||
- Ticket caveats (accepted, demo-grade): a supplier restart forgets used tickets, so an unexpired un-reclaimed escrow ref could reopen a session (bounded by `deliver_by` + the sweep); `/account.locked_in_escrow_lovelace` over-reports because the indexer never updates spent rows.
|
||||
- Suppliers additionally take `MAX_CHAT_SESSIONS` (default 1) — concurrent session slots; `/status` reports `active_sessions`/`max_sessions` and stays `free` until all slots are taken.
|
||||
|
||||
## Sweeper & wallet-health
|
||||
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
|
||||
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy.
|
||||
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
|
||||
|
||||
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
|
||||
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
|
||||
@@ -139,7 +145,7 @@ Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-U
|
||||
## Verification (testnet, end-to-end)
|
||||
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
|
||||
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
|
||||
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm via `GET /account` (`collateral_ok:true`).
|
||||
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
|
||||
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
|
||||
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
|
||||
6. One-shot `stream:true` → chunks + final usage + `[DONE]`.
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
---
|
||||
marp: true
|
||||
paginate: true
|
||||
size: 16:9
|
||||
---
|
||||
|
||||
<!--
|
||||
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
||||
Build PDF + PPTX with: bash docs/build-deck.sh
|
||||
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
||||
* Every slide uses the same navy→indigo gradient as the title slide so
|
||||
* the deck reads as one continuous document. Title and closing slides
|
||||
* keep the centered hero treatment; content slides stay top-aligned but
|
||||
* share the same palette and accent rules. */
|
||||
:root {
|
||||
--ink: #f1f5f9; /* primary text on dark gradient */
|
||||
--muted: #94a3b8;
|
||||
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
||||
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
||||
--accent-soft: rgba(129,140,248,0.12);
|
||||
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
||||
--row-even: rgba(255,255,255,0.04);
|
||||
--row-odd: rgba(255,255,255,0.02);
|
||||
--border: rgba(255,255,255,0.10);
|
||||
}
|
||||
section {
|
||||
background: var(--gradient);
|
||||
color: var(--ink);
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 22px;
|
||||
padding: 40px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
section::after {
|
||||
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
border-bottom: 4px solid var(--accent);
|
||||
padding-bottom: 10px;
|
||||
margin: 0 0 18px 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
margin-right: 14px;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
}
|
||||
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
||||
p, li { margin: 6px 0; line-height: 1.45; }
|
||||
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
||||
strong { color: #ffffff; }
|
||||
a { color: #a5b4fc; }
|
||||
blockquote {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 10px 18px;
|
||||
color: #e0e7ff;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
code {
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: #e0e7ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
||||
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
||||
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
||||
* stays readable on the navy gradient. */
|
||||
img {
|
||||
display: block;
|
||||
margin: 8px auto;
|
||||
max-width: 92%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
/* Tile grid for the local-agent versatility slide. */
|
||||
.backends {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
margin: 14px 0 12px 0;
|
||||
}
|
||||
.tile {
|
||||
background: var(--accent-soft);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.tile small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.tile.dots {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
font-size: 28px;
|
||||
}
|
||||
.pipeline {
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid var(--border);
|
||||
color: #f8fafc;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
margin: 6px auto 14px auto;
|
||||
max-width: 760px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pipeline span {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
/* Tables sit on a solid white card so cell text reads cleanly against
|
||||
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
||||
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
||||
* needed so the table actually consumes the full slide width — Marp's
|
||||
* default theme leaves tables at content-natural width, which left
|
||||
* blank gutters on either side. */
|
||||
table {
|
||||
display: table !important;
|
||||
width: 100% !important;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 0.82em;
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
||||
th {
|
||||
background: var(--accent-deep);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
tr:nth-child(even) td { background: #ffffff; }
|
||||
tr:nth-child(odd) td { background: #f8fafc; }
|
||||
/* code spans inside table cells need a light pill, not the dark one
|
||||
* defined globally — otherwise they'd disappear into the white row. */
|
||||
td code {
|
||||
background: #eef2ff;
|
||||
color: #1e293b;
|
||||
}
|
||||
/* Bold/strong inside cells must stay dark — the global rule paints
|
||||
* <strong> white for the dark-gradient body text, which would render
|
||||
* invisible against the white table card. */
|
||||
td strong, th strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
th strong {
|
||||
color: #ffffff;
|
||||
}
|
||||
/* Title slide — centered hero treatment on the same gradient. */
|
||||
section.title {
|
||||
text-align: center;
|
||||
padding: 100px 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
section.title h1 {
|
||||
display: inline-block;
|
||||
font-size: 64px;
|
||||
margin: 0 auto 20px auto;
|
||||
}
|
||||
section.title blockquote {
|
||||
margin: 28px auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
/* Closing slide — same centered treatment. */
|
||||
section.closing {
|
||||
text-align: center;
|
||||
padding: 100px 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
section.closing h2::before { display: none; }
|
||||
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
||||
</style>
|
||||
|
||||
<!-- _class: title -->
|
||||
|
||||
# Local Agents Marketplace
|
||||
|
||||
### A trust-minimised, on-chain marketplace for LLM inference
|
||||
|
||||
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
||||
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
||||
|
||||
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
||||
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
||||
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
||||
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
||||
|
||||
We need a **two-sided marketplace** where:
|
||||
1. Sellers monetise spare compute,
|
||||
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
||||
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
||||
|
||||
---
|
||||
|
||||
## How it works — three actors, one chain
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph CHAIN[Vector — UTxO]
|
||||
ADV[Advert UTxO<br/>capability, price, bonds]
|
||||
ESC[Escrow UTxO<br/>locked funds + state]
|
||||
end
|
||||
|
||||
SUP[🧠 Supplier<br/>local LLM node]
|
||||
BUY[💼 Buyer<br/>app or agent]
|
||||
IDX[🔭 Indexer<br/>read-only bulletin board]
|
||||
|
||||
SUP -->|publish advert| ADV
|
||||
BUY -->|discover| IDX
|
||||
IDX -.->|index events| CHAIN
|
||||
BUY -->|post escrow| ESC
|
||||
BUY -->|prompt + escrow_ref| SUP
|
||||
SUP -->|signed receipt| BUY
|
||||
SUP -->|claim & submit| ESC
|
||||
BUY -->|accept & pay| ESC
|
||||
```
|
||||
|
||||
* **Supplier** runs the inference and publishes a price.
|
||||
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
||||
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
||||
|
||||
---
|
||||
|
||||
## The bonded-escrow lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant B as Buyer
|
||||
participant Chain as Vector
|
||||
participant S as Supplier
|
||||
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
||||
|
||||
S->>Chain: PostAdvert (locks supplier_bond)
|
||||
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
||||
B->>S: POST /v1/chat/completions
|
||||
S->>Chain: ClaimEscrow → Claimed
|
||||
S->>LLM: run inference
|
||||
LLM-->>S: assistant message
|
||||
S->>Chain: SubmitEscrow → Submitted
|
||||
S-->>B: 200 OK + signed receipt
|
||||
B->>Chain: AcceptEscrow — supplier paid
|
||||
```
|
||||
|
||||
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
||||
|
||||
---
|
||||
|
||||
## What if someone misbehaves?
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
||||
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
||||
CLAIMED -->|deliver_by passed| RECLAIM
|
||||
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
||||
|
||||
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
||||
classDef good fill:#dcfce7,stroke:#16a34a
|
||||
classDef warn fill:#fef3c7,stroke:#d97706
|
||||
```
|
||||
|
||||
| Failure mode | Recovery | Loss bearer |
|
||||
|---|---|---|
|
||||
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
||||
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
||||
|
||||
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
||||
|
||||
---
|
||||
|
||||
## Local-agent versatility — any OpenAI-compatible engine
|
||||
|
||||
<div class="pipeline">
|
||||
<span>💼 Buyer</span> →
|
||||
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
||||
<span>backend ↓</span>
|
||||
</div>
|
||||
|
||||
<div class="backends">
|
||||
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
||||
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
||||
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
||||
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
||||
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
||||
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
||||
</div>
|
||||
|
||||
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
||||
|
||||
---
|
||||
|
||||
## Capability + model = task selection
|
||||
|
||||
| Capability ID | Example models | Use case |
|
||||
|---|---|---|
|
||||
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
||||
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
||||
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
||||
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
||||
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
||||
| `…` | … | … |
|
||||
|
||||
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
||||
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
||||
|
||||
---
|
||||
|
||||
## OpenClaw — agentic teams on your existing OpenAI seat
|
||||
|
||||
<div class="pipeline">
|
||||
<span>🧠 Supplier wrapper</span> →
|
||||
<span>🦾 OpenClaw</span> →
|
||||
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
||||
</div>
|
||||
|
||||
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
||||
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
||||
|
||||
---
|
||||
|
||||
## Proven on Vector testnet — 2026-05-05
|
||||
|
||||
| Step | Tx hash |
|
||||
|---|---|
|
||||
| Buyer posts escrow | `af762561…c2f7bc5` |
|
||||
| Supplier claims | (lifecycle tx) |
|
||||
| Supplier submits receipt | (lifecycle tx) |
|
||||
| Buyer accepts | `125e3cfe…7dc100d` |
|
||||
|
||||
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
||||
|
||||
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
||||
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
||||
|
||||
---
|
||||
|
||||
## Why UTxO + Vector
|
||||
|
||||
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
||||
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
||||
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
||||
* **No custom rollup, no bridge** — runs on a production chain today.
|
||||
|
||||
---
|
||||
|
||||
<!-- _class: closing -->
|
||||
|
||||
## The pitch in one sentence
|
||||
|
||||
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
||||
|
||||
### Try it
|
||||
|
||||
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
||||
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
||||
Binary file not shown.
Binary file not shown.
@@ -164,8 +164,8 @@ If the buyer does NOT accept within the dispute window (10 minutes), you can col
|
||||
|
||||
- **Never share your .env file** - it contains your wallet seed phrase
|
||||
- **Never share your data/ folder** - it contains task history
|
||||
- The seed phrase controls your wallet funds. Back it up securely.
|
||||
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
|
||||
- The seed phrase controls your wallet. Back it up securely.
|
||||
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -30,5 +30,7 @@ GATEWAY_DB_DIR=./data/gateway
|
||||
# KEY_RATE_MAX=60 # requests per API key per window
|
||||
# KEY_RATE_WINDOW_MS=60000
|
||||
# SWEEPER_INTERVAL_MS=60000
|
||||
# DEMO_SESSION_IDLE_MS=180000 # close demo chat sessions idle this long (frees the supplier slot)
|
||||
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit/Accept) or "ticket" (escrow post only; sweeper reclaims) — must match suppliers
|
||||
# WALLET_HEALTH_INTERVAL_MS=600000
|
||||
# SDK_REGISTRY_MAX=500 # max cached per-key Marketplace instances
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts"
|
||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/buyer": "workspace:*",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { seal } from "../crypto/seal.js";
|
||||
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
|
||||
import { asyncHandler } from "../middleware/http.js";
|
||||
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
|
||||
import { badRequest } from "../openai/errors.js";
|
||||
import { badRequest, forbidden } from "../openai/errors.js";
|
||||
|
||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||
|
||||
@@ -89,7 +89,10 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
|
||||
res.status(200).json({
|
||||
key_prefix: keyRow.key_prefix,
|
||||
deposit_address: keyRow.deposit_address,
|
||||
// The shared demo key's wallet is operator-funded; hiding the deposit
|
||||
// address keeps strangers from confusing it with their own wallet.
|
||||
deposit_address: keyRow.demo ? null : keyRow.deposit_address,
|
||||
...(keyRow.demo ? { demo: true } : {}),
|
||||
balance: {
|
||||
available_lovelace: available.toString(),
|
||||
locked_in_escrow_lovelace: lockedInEscrow.toString(),
|
||||
@@ -118,6 +121,11 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
export function makeWithdrawHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
if (keyRow.demo) {
|
||||
// The demo key is shared publicly; without this, anyone holding it
|
||||
// could drain the operator-funded wallet to their own address.
|
||||
throw forbidden("demo_key_restricted", "withdrawals are disabled for the shared demo key");
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const toAddress = body.to_address;
|
||||
|
||||
76
gateway/src/cli/create-demo-key.ts
Normal file
76
gateway/src/cli/create-demo-key.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* gateway/src/cli/create-demo-key.ts — mint the shared demo API key.
|
||||
*
|
||||
* Same custodial machinery as POST /signup, but sets demo=1: withdraw is
|
||||
* blocked, /account hides the deposit address, chat completions run through
|
||||
* the session-backed demo executor, and per-IP rate limits apply. Public
|
||||
* signup can never mint a demo key — this CLI is the only path.
|
||||
*
|
||||
* Run inside the gateway container (hits the volume-mounted DB):
|
||||
* docker exec <gateway> pnpm --filter @marketplace/gateway create-demo-key
|
||||
* Or locally:
|
||||
* GATEWAY_MASTER_KEY=<64hex> GATEWAY_DB_DIR=./data/gateway NETWORK_ID=1 \
|
||||
* pnpm --filter @marketplace/gateway create-demo-key [--label shared-demo]
|
||||
*
|
||||
* The raw key is printed ONCE and never stored (only its sha256).
|
||||
*/
|
||||
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { GatewayStore } from "../db/store.js";
|
||||
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
|
||||
import { seal } from "../crypto/seal.js";
|
||||
import { hashApiKey } from "../middleware/apiKeyAuth.js";
|
||||
|
||||
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const masterKey = process.env.GATEWAY_MASTER_KEY;
|
||||
if (!masterKey || !HEX64.test(masterKey)) {
|
||||
throw new Error("create-demo-key: GATEWAY_MASTER_KEY must be 64 hex chars");
|
||||
}
|
||||
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
|
||||
const networkId: 0 | 1 = process.env.NETWORK_ID === "1" ? 1 : 0;
|
||||
const label = arg("label") ?? "shared-demo";
|
||||
|
||||
const privHex = genPrivKeyHex();
|
||||
const walletKey = deriveWalletKey(privHex, networkId);
|
||||
const sealed = seal(privHex, masterKey);
|
||||
|
||||
const rawKey = `vk_${networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
|
||||
|
||||
const store = new GatewayStore(dbDir);
|
||||
store.insertKey({
|
||||
id: randomUUID(),
|
||||
key_hash: hashApiKey(rawKey),
|
||||
key_prefix: rawKey.slice(0, 12),
|
||||
label,
|
||||
wallet_pkh: walletKey.pubKeyHash,
|
||||
deposit_address: walletKey.address,
|
||||
enc_priv_nonce: sealed.nonce,
|
||||
enc_priv_ct: sealed.ct,
|
||||
enc_priv_tag: sealed.tag,
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
demo: 1,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
[
|
||||
`[gateway] demo key created (label=${label}, network=${networkId === 1 ? "mainnet" : "testnet"})`,
|
||||
``,
|
||||
` api_key: ${rawKey}`,
|
||||
` deposit_address: ${walletKey.address}`,
|
||||
``,
|
||||
`Save the api_key now — it is shown only once. Fund the deposit address`,
|
||||
`with the demo float (withdrawals are disabled for this key).`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -20,6 +20,10 @@
|
||||
* KEY_RATE_MAX / KEY_RATE_WINDOW_MS — per-key request limiter.
|
||||
* SWEEPER_INTERVAL_MS / WALLET_HEALTH_INTERVAL_MS — background tick cadences.
|
||||
* SDK_REGISTRY_MAX — max cached per-key Marketplace instances (LRU).
|
||||
* GATEWAY_CORS_ORIGINS — comma-separated allowlist of browser Origins that may
|
||||
* call the gateway cross-origin (e.g. the marketplace
|
||||
* frontend's "Generate API key" page). Default empty =
|
||||
* CORS off (no Access-Control-* headers emitted).
|
||||
*/
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -35,9 +39,23 @@ export interface GatewayConfig {
|
||||
dbDir: string;
|
||||
signupRate: { max: number; windowMs: number };
|
||||
keyRate: { max: number; windowMs: number };
|
||||
demoIpRate: { max: number; windowMs: number };
|
||||
sweeperIntervalMs: number;
|
||||
/** Close demo chat sessions idle this long (frees the supplier slot).
|
||||
* Keep below the supplier's CHAT_IDLE_TIMEOUT_MS so the gateway closes
|
||||
* first and records usage. */
|
||||
demoSessionIdleMs: number;
|
||||
/** Chat-session settlement mode this deployment runs. "full" = suppliers
|
||||
* Claim/Submit and the gateway Accepts; "ticket" = the escrow post at open
|
||||
* is the only chain op and the sweeper Reclaims it after deliver_by.
|
||||
* Must agree with the suppliers' CHAT_SETTLE_MODE. */
|
||||
chatSettleMode: "full" | "ticket";
|
||||
walletHealthIntervalMs: number;
|
||||
sdkRegistryMax: number;
|
||||
corsOrigins: string[];
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -48,6 +66,14 @@ function requireField(env: Record<string, string | undefined>, name: string): st
|
||||
return v;
|
||||
}
|
||||
|
||||
function chatSettleMode(env: Record<string, string | undefined>): "full" | "ticket" {
|
||||
const v = env.CHAT_SETTLE_MODE ?? "full";
|
||||
if (v !== "full" && v !== "ticket") {
|
||||
throw new Error('loadConfig: CHAT_SETTLE_MODE must be "full" or "ticket"');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function posInt(
|
||||
env: Record<string, string | undefined>,
|
||||
name: string,
|
||||
@@ -106,8 +132,19 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
max: posInt(env, "KEY_RATE_MAX", 60),
|
||||
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
|
||||
},
|
||||
demoIpRate: {
|
||||
max: posInt(env, "DEMO_IP_RATE_MAX", 20),
|
||||
windowMs: posInt(env, "DEMO_IP_RATE_WINDOW_MS", 60 * 1000),
|
||||
},
|
||||
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
|
||||
demoSessionIdleMs: posInt(env, "DEMO_SESSION_IDLE_MS", 3 * 60 * 1000),
|
||||
chatSettleMode: chatSettleMode(env),
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
.filter((o) => o !== ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
||||
enc_priv_tag TEXT NOT NULL,
|
||||
master_key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
demo INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ApiKeyRow {
|
||||
master_key_version: number;
|
||||
created_at: number;
|
||||
disabled: number;
|
||||
/** 1 = shared demo key: withdraw blocked, session-backed completions, per-IP limits. */
|
||||
demo: number;
|
||||
}
|
||||
|
||||
export interface UsageRow {
|
||||
@@ -81,13 +83,20 @@ export class GatewayStore {
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
this.db.exec(CREATE_TABLES_SQL);
|
||||
|
||||
// Additive migration for DBs created before the demo flag existed
|
||||
// (CREATE TABLE IF NOT EXISTS won't alter an existing table).
|
||||
const keyCols = this.db.pragma("table_info(api_keys)") as Array<{ name: string }>;
|
||||
if (!keyCols.some((c) => c.name === "demo")) {
|
||||
this.db.exec("ALTER TABLE api_keys ADD COLUMN demo INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
this.sInsertKey = this.db.prepare(`
|
||||
INSERT INTO api_keys (
|
||||
id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
|
||||
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled
|
||||
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled, demo
|
||||
) VALUES (
|
||||
@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
|
||||
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0
|
||||
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0, @demo
|
||||
)
|
||||
`);
|
||||
this.sGetKeyByHash = this.db.prepare(`SELECT * FROM api_keys WHERE key_hash = @key_hash`);
|
||||
@@ -133,8 +142,8 @@ export class GatewayStore {
|
||||
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
|
||||
}
|
||||
|
||||
insertKey(row: Omit<ApiKeyRow, "disabled">): void {
|
||||
this.sInsertKey.run({ ...row, label: row.label ?? null });
|
||||
insertKey(row: Omit<ApiKeyRow, "disabled" | "demo"> & { demo?: number }): void {
|
||||
this.sInsertKey.run({ ...row, label: row.label ?? null, demo: row.demo ?? 0 });
|
||||
}
|
||||
|
||||
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
|
||||
@@ -193,4 +202,8 @@ export class GatewayStore {
|
||||
listOpenSessions(): SessionRow[] {
|
||||
return this.sListOpenSessions.all() as SessionRow[];
|
||||
}
|
||||
|
||||
listOpenSessionsByKey(keyId: string): SessionRow[] {
|
||||
return this.listOpenSessions().filter((s) => s.key_id === keyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,7 +60,28 @@ export function ipRateLimit(opts: { max: number; windowMs: number }) {
|
||||
return makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||
}
|
||||
|
||||
/** Per-API-key limiter (apply AFTER apiKeyAuth). */
|
||||
/** Per-API-key limiter (apply AFTER apiKeyAuth). Demo keys are exempt: the
|
||||
* shared demo key would let one abusive IP exhaust everyone's budget — the
|
||||
* per-IP demoIpRateLimit is the demo control instead. */
|
||||
export function keyRateLimit(opts: { max: number; windowMs: number }) {
|
||||
return makeLimiter(opts, (req) => requireKey(req).id);
|
||||
const limit = makeLimiter(opts, (req) => requireKey(req).id);
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (requireKey(req).demo) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
limit(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-IP limiter that applies ONLY to demo-key requests (after apiKeyAuth). */
|
||||
export function demoIpRateLimit(opts: { max: number; windowMs: number }) {
|
||||
const limit = makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (!requireKey(req).demo) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
limit(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
402
gateway/src/ocr/extract.ts
Normal file
402
gateway/src/ocr/extract.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
|
||||
*
|
||||
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
|
||||
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
|
||||
* path, all inside the per-key mutex: parse → select supplier → preflight →
|
||||
* per-candidate live busy pre-check → submitOcr (escrow → supplier call →
|
||||
* receipt verify) → resolve Submitted ref → Accept (awaited) → record usage
|
||||
* → wallet-health → respond.
|
||||
*
|
||||
* Response (200):
|
||||
* {
|
||||
* output_format, content,
|
||||
* usage: { prompt_tokens, completion_tokens, total_tokens },
|
||||
* x_vector: {
|
||||
* receipt, receipt_signature, escrow_ref,
|
||||
* receipt_labels: ["first-party-brokered"]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Venue attribution: the caller MAY send
|
||||
* X-Venue: venue slug (e.g. "apify"), ≤ 32 chars
|
||||
* X-Run-User-Hash: sha256 of the venue-side runner id, 64 hex
|
||||
* Both are logged on a structured line per settled job. The wedge-ledger
|
||||
* emitter consumes these lines; a usage-table venue
|
||||
* column is deliberately deferred to later work so this route stays
|
||||
* schema-additive. payer_class is derived at ledger time from the hash
|
||||
* (known first-party hashes vs the rest), never asserted here.
|
||||
*
|
||||
* Demo keys are rejected: the demo executor is chat-session-shaped and its
|
||||
* wallet holds one escrow per session; OCR needs a funded per-key wallet.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { TxConstructionError } from "@marketplace/shared/tx";
|
||||
import {
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { SupplierError, type ProgressEvent } from "@marketplace/buyer/sdk";
|
||||
import type { GatewayDeps } from "../deps.js";
|
||||
import type { GatewayStore } from "../db/store.js";
|
||||
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||
import { asyncHandler } from "../middleware/http.js";
|
||||
import { selectCandidates, type SupplierCandidate } from "../routing/selectSupplier.js";
|
||||
import { preflight } from "../onchain/preflight.js";
|
||||
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||
import { badRequest, forbidden, notFound, paymentRequired, toGatewayError } from "../openai/errors.js";
|
||||
|
||||
/** Operator-model v0: HAL8 operates both the custodial buyer and the
|
||||
* supplier fleet, so every receipt this route returns is first-party
|
||||
* brokered. Becomes per-supplier metadata when
|
||||
* control-independent supply exists. */
|
||||
const RECEIPT_LABELS_V0 = ["first-party-brokered"] as const;
|
||||
|
||||
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const VENUE_RE = /^[a-z0-9-]{1,32}$/;
|
||||
|
||||
/** Bound on the live busy pre-check below - cheap enough that a stalled
|
||||
* supplier never meaningfully delays the request. */
|
||||
const SUPPLIER_STATUS_CHECK_TIMEOUT_MS = 2_000;
|
||||
|
||||
interface ParsedOcrRequest {
|
||||
image_b64: string;
|
||||
mime: string;
|
||||
output_format: string;
|
||||
/** "" = any model under the capability (single-model ids). */
|
||||
model: string;
|
||||
}
|
||||
|
||||
function parseOcrRequest(body: unknown): ParsedOcrRequest {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||
}
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
const image = b.image_b64;
|
||||
if (typeof image !== "string" || image.length === 0) {
|
||||
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
|
||||
}
|
||||
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
|
||||
}
|
||||
if (!B64_RE.test(image)) {
|
||||
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
|
||||
const mime = b.mime;
|
||||
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
|
||||
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
|
||||
const rawFormat = b.output_format ?? "markdown";
|
||||
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
|
||||
throw badRequest(
|
||||
"invalid_output_format",
|
||||
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
let model = "";
|
||||
if (b.model !== undefined && b.model !== null) {
|
||||
if (typeof b.model !== "string" || b.model === "") {
|
||||
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
|
||||
}
|
||||
model = b.model;
|
||||
}
|
||||
|
||||
return { image_b64: image, mime, output_format: rawFormat, model };
|
||||
}
|
||||
|
||||
/** Sanitized venue-attribution headers. Invalid values drop to "".
|
||||
* Exported for direct unit testing (I3 review finding) — it is the only
|
||||
* gate on what lands in the ocr_settled/ocr_failed structured log lines the
|
||||
* wedge-ledger emitter reads, so it needs coverage independent of the HTTP
|
||||
* route. */
|
||||
export function venueAttribution(req: Request): { venue: string; runUserHash: string } {
|
||||
const venueRaw = req.header("X-Venue") ?? "";
|
||||
const hashRaw = req.header("X-Run-User-Hash") ?? "";
|
||||
return {
|
||||
venue: VENUE_RE.test(venueRaw) ? venueRaw : "",
|
||||
runUserHash: HEX64_RE.test(hashRaw) ? hashRaw.toLowerCase() : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live busy pre-check, run immediately before a candidate's escrow posts.
|
||||
* Mirrors sessions.ts's startChat pre-check: same signal ("working" on the
|
||||
* supplier's own /status), same best-effort posture. Posting an escrow only
|
||||
* to have the supplier reject as busy strands the escrow until reclaim and
|
||||
* burns the tx fee, so this is worth a bounded, cheap HTTP round trip.
|
||||
*
|
||||
* Never a gate: the indexer already filters candidates on advert status, so
|
||||
* a timeout, network error, non-2xx, or malformed body all resolve to "not
|
||||
* busy" (unknown, not blocking) - a briefly-unreachable /status must never
|
||||
* stop the candidate from being attempted.
|
||||
*/
|
||||
async function isSupplierBusy(
|
||||
endpointUrl: string,
|
||||
fetchFn: typeof globalThis.fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetchFn(`${endpointUrl.replace(/\/+$/, "")}/status`, {
|
||||
signal: AbortSignal.timeout(SUPPLIER_STATUS_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as unknown;
|
||||
const status = body && typeof body === "object" ? (body as { status?: unknown }).status : undefined;
|
||||
return status === "working";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function recordOcrUsage(
|
||||
store: GatewayStore,
|
||||
p: {
|
||||
keyId: string;
|
||||
capabilityId: string;
|
||||
model: string;
|
||||
supplierPkh: string | null;
|
||||
escrowRef: string | null;
|
||||
costLovelace: bigint | null;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
status: "completed" | "failed";
|
||||
failureReason?: string;
|
||||
},
|
||||
): void {
|
||||
store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: p.keyId,
|
||||
created_at: Date.now(),
|
||||
kind: "ocr",
|
||||
model: p.model,
|
||||
capability_id: p.capabilityId,
|
||||
supplier_pkh: p.supplierPkh,
|
||||
escrow_ref: p.escrowRef,
|
||||
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
|
||||
prompt_tokens: p.promptTokens,
|
||||
completion_tokens: p.completionTokens,
|
||||
status: p.status,
|
||||
failure_reason: p.failureReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeOcrExtractHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
if (keyRow.demo) {
|
||||
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
|
||||
}
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const parsed = parseOcrRequest(req.body);
|
||||
const attribution = venueAttribution(req);
|
||||
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
|
||||
});
|
||||
}
|
||||
|
||||
async function runOcrOneShot(
|
||||
deps: GatewayDeps,
|
||||
keyId: string,
|
||||
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
|
||||
parsed: ParsedOcrRequest,
|
||||
attribution: { venue: string; runUserHash: string },
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { config, store, chain, fetchFn } = deps;
|
||||
const capabilityId = config.ocrCapabilityId;
|
||||
|
||||
// Hoisted above the try so the catch block can report whatever is known
|
||||
// even when the failure happens AFTER an escrow was posted (e.g. the
|
||||
// supplier call or settle step fails) — these fields are the
|
||||
// reconciliation spine that joins on-chain settlement to platform
|
||||
// billing, and losing them makes paid-but-failed jobs unreconcilable
|
||||
// (I2 review finding).
|
||||
let result: Awaited<ReturnType<typeof ctx.sdk.submitOcr>> | undefined;
|
||||
let used: SupplierCandidate | undefined;
|
||||
let escrowRefStr: string | null = null;
|
||||
|
||||
try {
|
||||
const candidates = await selectCandidates({
|
||||
indexerUrl: config.indexerUrl,
|
||||
model: parsed.model,
|
||||
capabilityId,
|
||||
fetchFn,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw notFound(
|
||||
"model_not_found",
|
||||
parsed.model === ""
|
||||
? `no available supplier for capability "${capabilityId}"`
|
||||
: `no available supplier for model "${parsed.model}" under "${capabilityId}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const primary = candidates[0];
|
||||
const pf = await preflight(chain, ctx.walletKey.address, primary);
|
||||
if (!pf.ok) {
|
||||
throw paymentRequired(
|
||||
pf.collateralOk
|
||||
? "insufficient balance for this request"
|
||||
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||
{
|
||||
x_vector: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
available_lovelace: pf.availableLovelace.toString(),
|
||||
collateral_ok: pf.collateralOk,
|
||||
deposit_address: ctx.walletKey.address,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Submit. Fall back to the next supplier ONLY on a pre-post
|
||||
// TxConstructionError (no escrow was posted). A SupplierError means the
|
||||
// escrow is already posted — do not retry (the sweeper recovers it).
|
||||
//
|
||||
// submitOcr posts the escrow internally and only returns escrowRef when
|
||||
// it resolves. When the supplier call AFTER the post throws
|
||||
// (SupplierError: timeout, network failure, non-2xx, malformed
|
||||
// response, receipt mismatch) the escrow already landed on chain but
|
||||
// escrowRefStr below would otherwise stay null — the OCR upstream has a
|
||||
// 120s budget, so this is likely the most common paid-but-failed shape.
|
||||
// The SDK emits a "progress" event of type "escrow_posted" (carrying
|
||||
// escrow_ref) the instant the tx confirms, so listen for it around each
|
||||
// attempt and capture the ref from the event rather than only from
|
||||
// submitOcr's return value (I2 follow-up).
|
||||
let lastErr: unknown;
|
||||
for (const cand of candidates) {
|
||||
// Live pre-check, run right here (not once at the top of the request):
|
||||
// skip a candidate that is busy right now instead of paying to find
|
||||
// out. See isSupplierBusy's docstring for the exact signal + posture.
|
||||
if (await isSupplierBusy(cand.endpointUrl, fetchFn)) {
|
||||
lastErr = new SupplierError("supplier_busy", {
|
||||
status: 409,
|
||||
message: "supplier is busy with another job",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const onProgress = (ev: unknown): void => {
|
||||
const e = ev as ProgressEvent;
|
||||
if (e.type === "escrow_posted" && e.escrow_ref) escrowRefStr = e.escrow_ref;
|
||||
};
|
||||
ctx.sdk.on("progress", onProgress);
|
||||
try {
|
||||
result = await ctx.sdk.submitOcr({
|
||||
advertRef: cand.advertRef,
|
||||
image_b64: parsed.image_b64,
|
||||
mime: parsed.mime,
|
||||
output_format: parsed.output_format,
|
||||
payment_lovelace: cand.priceLovelace,
|
||||
});
|
||||
used = cand;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (err instanceof TxConstructionError) continue;
|
||||
throw err;
|
||||
} finally {
|
||||
ctx.sdk.off("progress", onProgress);
|
||||
}
|
||||
}
|
||||
if (!result || !used) {
|
||||
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
|
||||
}
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
originalRefStr: escrowRefStr,
|
||||
fetchFn,
|
||||
});
|
||||
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
|
||||
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: used.model,
|
||||
supplierPkh: used.supplierPkh,
|
||||
escrowRef: escrowRefStr,
|
||||
costLovelace: used.priceLovelace,
|
||||
promptTokens: result.usage.prompt_tokens,
|
||||
completionTokens: result.usage.completion_tokens,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Structured settle line for the wedge-ledger emitter. One line per
|
||||
// settled job; venue/run_user_hash empty when the
|
||||
// caller sent none.
|
||||
console.log(JSON.stringify({
|
||||
evt: "ocr_settled",
|
||||
escrow_ref: escrowRefStr,
|
||||
capability_id: capabilityId,
|
||||
model: used.model,
|
||||
supplier_pkh: used.supplierPkh,
|
||||
cost_lovelace: used.priceLovelace.toString(),
|
||||
receipt_labels: RECEIPT_LABELS_V0,
|
||||
venue: attribution.venue,
|
||||
run_user_hash: attribution.runUserHash,
|
||||
ts: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Re-shape the wallet ({collateral, working}) so the next request keeps
|
||||
// a valid collateral UTxO. Best-effort; serialized by the mutex.
|
||||
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[gateway] wallet-health after ocr:", e instanceof Error ? e.message : e),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
output_format: result.output_format,
|
||||
content: result.content,
|
||||
usage: result.usage,
|
||||
x_vector: {
|
||||
receipt: result.receipt,
|
||||
receipt_signature: result.receiptSignature,
|
||||
escrow_ref: escrowRefStr,
|
||||
receipt_labels: RECEIPT_LABELS_V0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
recordOcrUsage(store, {
|
||||
keyId,
|
||||
capabilityId,
|
||||
model: used?.model ?? parsed.model,
|
||||
supplierPkh: used?.supplierPkh ?? null,
|
||||
escrowRef: escrowRefStr,
|
||||
costLovelace: used?.priceLovelace ?? null,
|
||||
promptTokens: result?.usage.prompt_tokens ?? 0,
|
||||
completionTokens: result?.usage.completion_tokens ?? 0,
|
||||
status: "failed",
|
||||
failureReason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
||||
// Structured failure line mirroring ocr_settled's shape (same venue /
|
||||
// run_user_hash / escrow_ref fields) so paid-but-failed jobs — an
|
||||
// escrow posted on-chain, then a later step (supplier call, settle)
|
||||
// failed — can still be reconciled against on-chain state instead of
|
||||
// vanishing from the wedge-ledger emitter's input (I2 review finding).
|
||||
console.log(JSON.stringify({
|
||||
evt: "ocr_failed",
|
||||
escrow_ref: escrowRefStr,
|
||||
capability_id: capabilityId,
|
||||
model: used?.model ?? parsed.model,
|
||||
supplier_pkh: used?.supplierPkh ?? null,
|
||||
venue: attribution.venue,
|
||||
run_user_hash: attribution.runUserHash,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
ts: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -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-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate).
|
||||
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
||||
* AND the wallet must currently hold a pure-ADA 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;
|
||||
@@ -18,7 +24,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
|
||||
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
|
||||
}
|
||||
|
||||
/** True iff some UTxO is pure-ADA (no native assets) and ≥ collateral floor. */
|
||||
/** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
|
||||
export function hasCollateral(utxos: Utxo[]): boolean {
|
||||
return utxos.some(
|
||||
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface EscrowRow {
|
||||
utxo_ref: string;
|
||||
state: string;
|
||||
posted_at: number;
|
||||
/** Validator reclaim floor (indexer serves it; absent on very old rows). */
|
||||
deliver_by?: number;
|
||||
}
|
||||
|
||||
export async function fetchEscrows(
|
||||
|
||||
116
gateway/src/openai/affinity.ts
Normal file
116
gateway/src/openai/affinity.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* gateway/src/openai/affinity.ts — transcript-prefix session matching.
|
||||
*
|
||||
* Stateless OpenAI clients resend the FULL message history each call; sessions
|
||||
* are stateful. An incoming request belongs to an open session when that
|
||||
* session's mirrored transcript is a prefix of the incoming messages — the
|
||||
* suffix (delta) is the next turn. Client-authored messages (user/system/tool)
|
||||
* must match byte-exactly, but clients routinely reshape ASSISTANT text when
|
||||
* echoing it back (strip <think> reasoning, re-render whitespace), so
|
||||
* assistant content is compared normalized. Strict (byte-exact) matches are
|
||||
* preferred over lenient ones to disambiguate concurrent threads that share a
|
||||
* user-message prefix. The mirror itself is NEVER rewritten with client text:
|
||||
* the delta is forwarded verbatim and mirrored only after a successful turn,
|
||||
* so the gateway mirror and the supplier transcript stay hash-identical for
|
||||
* the receipt response_hash.
|
||||
*
|
||||
* Pure module — no I/O — so the matching rules are unit-testable.
|
||||
*/
|
||||
|
||||
import { chatMessagesEquivalent, toolCallsEqual, type ChatMessage } from "@marketplace/shared/tx";
|
||||
|
||||
export interface AffinityCandidate {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
mirror: ChatMessage[];
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
export interface AffinityMatch {
|
||||
sessionId: string;
|
||||
/** Messages the session has not seen yet — forwarded as the next turn. */
|
||||
delta: ChatMessage[];
|
||||
}
|
||||
|
||||
/** Canonicalize assistant text the way clients mangle it when re-echoing
|
||||
* history: drop <think>…</think> reasoning blocks, collapse whitespace runs,
|
||||
* trim. Exported for tests. */
|
||||
export function normalizeAssistantContent(content: string): string {
|
||||
return content
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Lenient equality: strict for client-authored roles (the client sends the
|
||||
* same bytes both times); assistant content is compared normalized while
|
||||
* assistant tool_calls stay strict (ids anchor tool-result turns). */
|
||||
function chatMessagesLenientEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
if (a.role !== b.role) return false;
|
||||
if (a.role !== "assistant") return chatMessagesEquivalent(a, b);
|
||||
return (
|
||||
normalizeAssistantContent(a.content) === normalizeAssistantContent(b.content) &&
|
||||
toolCallsEqual(a.tool_calls, b.tool_calls)
|
||||
);
|
||||
}
|
||||
|
||||
type PrefixQuality = "strict" | "lenient";
|
||||
|
||||
function classifyPrefix(mirror: ChatMessage[], incoming: ChatMessage[]): PrefixQuality | null {
|
||||
if (mirror.length >= incoming.length) return null;
|
||||
let strict = true;
|
||||
for (let i = 0; i < mirror.length; i++) {
|
||||
if (chatMessagesEquivalent(mirror[i], incoming[i])) continue;
|
||||
if (!chatMessagesLenientEquivalent(mirror[i], incoming[i])) return null;
|
||||
strict = false;
|
||||
}
|
||||
return strict ? "strict" : "lenient";
|
||||
}
|
||||
|
||||
function deltaRespondable(delta: ChatMessage[]): boolean {
|
||||
const last = delta[delta.length - 1];
|
||||
return last !== undefined && (last.role === "user" || last.role === "tool");
|
||||
}
|
||||
|
||||
/** Drop trailing assistant/system messages so the history ends with a
|
||||
* respondable message (regenerate semantics for clients that send a trailing
|
||||
* assistant turn). Empty result = nothing to answer. */
|
||||
export function trimToRespondable(messages: ChatMessage[]): ChatMessage[] {
|
||||
let end = messages.length;
|
||||
while (end > 0 && messages[end - 1].role !== "user" && messages[end - 1].role !== "tool") end--;
|
||||
return messages.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the open session whose mirror best matches the incoming history.
|
||||
* Strict prefixes beat lenient ones; then longest mirror wins (most
|
||||
* conversation reuse); ties go to the most recently used session. Returns
|
||||
* null when nothing matches — caller opens a new session and replays the full
|
||||
* history. Note `incoming.length <= mirror.length` never matches (a client
|
||||
* retry of an already-answered request is a miss by design: the session
|
||||
* transcript must stay append-only).
|
||||
*/
|
||||
export function matchSessionPrefix(
|
||||
candidates: AffinityCandidate[],
|
||||
incoming: ChatMessage[],
|
||||
model: string,
|
||||
): AffinityMatch | null {
|
||||
let best: { cand: AffinityCandidate; quality: PrefixQuality } | null = null;
|
||||
for (const cand of candidates) {
|
||||
if (cand.model !== model) continue;
|
||||
const quality = classifyPrefix(cand.mirror, incoming);
|
||||
if (quality === null) continue;
|
||||
if (!deltaRespondable(incoming.slice(cand.mirror.length))) continue;
|
||||
if (
|
||||
best === null ||
|
||||
(quality === "strict" && best.quality === "lenient") ||
|
||||
(quality === best.quality &&
|
||||
(cand.mirror.length > best.cand.mirror.length ||
|
||||
(cand.mirror.length === best.cand.mirror.length && cand.lastUsedAt > best.cand.lastUsedAt)))
|
||||
) {
|
||||
best = { cand, quality };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
return { sessionId: best.cand.sessionId, delta: incoming.slice(best.cand.mirror.length) };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { preflight } from "../onchain/preflight.js";
|
||||
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||
import { parseChatRequest, type ParsedChatRequest } from "./validate.js";
|
||||
import { runDemoChat } from "./demoChat.js";
|
||||
import {
|
||||
genId,
|
||||
buildChatCompletion,
|
||||
@@ -67,8 +68,17 @@ function recordUsage(
|
||||
export function makeChatCompletionsHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
const parsed = parseChatRequest(req.body);
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
if (keyRow.demo) {
|
||||
// Demo keys run session-backed with tools passthrough, and never fall
|
||||
// back to one-shot (its settle path assumes ≤1 in-flight escrow per
|
||||
// wallet; the demo wallet holds one escrow per concurrent session).
|
||||
// Not under the key mutex — runDemoChat takes it only for session opens.
|
||||
const parsed = parseChatRequest(req.body, { allowTools: true });
|
||||
await runDemoChat(deps, keyRow, ctx, parsed, res);
|
||||
return;
|
||||
}
|
||||
const parsed = parseChatRequest(req.body);
|
||||
await ctx.mutex.run(() => runOneShot(deps, keyRow, ctx, parsed, res));
|
||||
});
|
||||
}
|
||||
@@ -98,7 +108,7 @@ async function runOneShot(
|
||||
throw paymentRequired(
|
||||
pf.collateralOk
|
||||
? "insufficient balance for this request"
|
||||
: "wallet has no pure-ADA UTxO ≥ 5 ADA for collateral; deposit a little more ADA",
|
||||
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||
{
|
||||
x_vector: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
|
||||
413
gateway/src/openai/demoChat.ts
Normal file
413
gateway/src/openai/demoChat.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* gateway/src/openai/demoChat.ts — session-backed /chat/completions for the
|
||||
* shared demo key.
|
||||
*
|
||||
* Demo requests keep the standard OpenAI wire shape (tools included) but run
|
||||
* against real escrow-per-session marketplace conversations: transcript-prefix
|
||||
* affinity maps each stateless request onto an open session and forwards only
|
||||
* the unseen delta; a miss transparently opens a new session (escrow post —
|
||||
* the only chain work, under the key mutex) and replays the full history as
|
||||
* the first turn. Supplier idle-closes are healed by reopening; the sweeper
|
||||
* settles the abandoned escrow.
|
||||
*
|
||||
* Demo keys NEVER fall back to the one-shot path: resolveSubmittedRef's
|
||||
* lone-Submitted-row fallback assumes ≤1 in-flight escrow per wallet, and the
|
||||
* demo wallet holds one escrow per concurrent session.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Response } from "express";
|
||||
import type { ChatMessage, ToolCall } from "@marketplace/shared/tx";
|
||||
import type { GatewayDeps } from "../deps.js";
|
||||
import type { ApiKeyRow, SessionRow } from "../db/store.js";
|
||||
import type { KeyContext } from "../sdk/registry.js";
|
||||
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||
import { matchSessionPrefix, trimToRespondable, type AffinityCandidate } from "./affinity.js";
|
||||
import {
|
||||
CAPABILITY,
|
||||
SessionGoneError,
|
||||
openSessionCore,
|
||||
streamSupplierTurn,
|
||||
type TurnResult,
|
||||
} from "./sessions.js";
|
||||
import type { ParsedChatRequest } from "./validate.js";
|
||||
import { genId, buildChatCompletion, buildChunk, sseData, SSE_DONE, type Usage } from "./shapes.js";
|
||||
import { GatewayError, badRequest, toGatewayError, toErrorBody } from "./errors.js";
|
||||
|
||||
interface DemoSessionMeta {
|
||||
keyId: string;
|
||||
model: string;
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
/** Open sessions owned by the demo executor (sessionId → meta). Mirrors live
|
||||
* in transcripts.ts; this map scopes affinity to demo-opened sessions. */
|
||||
const demoSessions = new Map<string, DemoSessionMeta>();
|
||||
|
||||
/** Forget a session (closed/settled elsewhere, e.g. by the sweeper). */
|
||||
export function dropDemoSession(sessionId: string): void {
|
||||
demoSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
function candidatesFor(keyId: string, model: string): AffinityCandidate[] {
|
||||
const out: AffinityCandidate[] = [];
|
||||
for (const [sessionId, meta] of demoSessions) {
|
||||
if (meta.keyId !== keyId || meta.model !== model) continue;
|
||||
const mirror = transcripts.get(sessionId);
|
||||
if (!mirror) continue;
|
||||
out.push({ sessionId, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Record the session's cost when the demo executor closes its row. Full
|
||||
* mode: the flat session fee (supplier Submitted; the sweeper Accepts the
|
||||
* escrow afterwards). Ticket mode: 0 — nothing settles, the sweeper reclaims
|
||||
* the escrow and the wallet is made whole minus fees. */
|
||||
function recordSessionCost(deps: GatewayDeps, session: SessionRow): void {
|
||||
deps.store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: session.key_id,
|
||||
created_at: Date.now(),
|
||||
kind: "chat_session",
|
||||
model: session.model,
|
||||
capability_id: CAPABILITY,
|
||||
supplier_pkh: session.supplier_pkh,
|
||||
escrow_ref: session.escrow_ref,
|
||||
cost_lovelace: deps.config.chatSettleMode === "ticket" ? "0" : session.price_lovelace,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
status: "completed",
|
||||
failure_reason: null,
|
||||
});
|
||||
}
|
||||
|
||||
function markSessionGone(deps: GatewayDeps, session: SessionRow): void {
|
||||
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||
dropSessionState(session.id);
|
||||
dropDemoSession(session.id);
|
||||
recordSessionCost(deps, session);
|
||||
}
|
||||
|
||||
/** Ask the supplier to settle (Submit) the session. NO gateway chain work —
|
||||
* the sweeper Accepts the resulting Submitted escrow. The supplier releases
|
||||
* its single slot as part of ending, so a truthy return means the supplier is
|
||||
* free again. Best-effort: on failure the sweeper reclaims later. */
|
||||
async function requestSupplierEnd(deps: GatewayDeps, session: SessionRow): Promise<boolean> {
|
||||
try {
|
||||
const res = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/end`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "X-Escrow-Ref": session.escrow_ref },
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
await res.text().catch(() => "");
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Close one demo session (evicted for capacity, or idle-janitored). Runs
|
||||
* under the session lock so an in-flight turn finishes first; takes NO key
|
||||
* mutex (no chain work here — see requestSupplierEnd). Returns the freed
|
||||
* supplier_pkh, or null when the session was already gone or (janitor) was
|
||||
* used again while we waited for the lock. */
|
||||
async function closeDemoSession(
|
||||
deps: GatewayDeps,
|
||||
sessionId: string,
|
||||
opts: { reason: "evicted" | "idle"; idleCutoffMs?: number; nowMs?: number },
|
||||
): Promise<string | null> {
|
||||
if (!demoSessions.has(sessionId)) return null;
|
||||
return getSessionLock(sessionId).run(async () => {
|
||||
const meta = demoSessions.get(sessionId);
|
||||
if (!meta) return null; // lost a race with another closer
|
||||
// Re-check idleness inside the lock: a turn that won the lock first has
|
||||
// bumped lastUsedAt past the sweep's reference clock.
|
||||
if (opts.idleCutoffMs !== undefined && (opts.nowMs ?? Date.now()) - meta.lastUsedAt < opts.idleCutoffMs) {
|
||||
return null;
|
||||
}
|
||||
const session = deps.store.getSession(sessionId);
|
||||
if (!session || session.state !== "open") {
|
||||
dropDemoSession(sessionId);
|
||||
return null;
|
||||
}
|
||||
await requestSupplierEnd(deps, session);
|
||||
markSessionGone(deps, session);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[gateway:demo] closed session ${sessionId} (${opts.reason}), freed supplier ${session.supplier_pkh}`);
|
||||
return session.supplier_pkh;
|
||||
});
|
||||
}
|
||||
|
||||
/** Least-recently-used open demo session for (key, model), excluding prior
|
||||
* victims. Same-model scoping doubles as a guard: a bogus model name has no
|
||||
* demo sessions, so eviction can never loop on a genuine model_not_found. */
|
||||
function pickLruDemoSession(keyId: string, model: string, exclude: ReadonlySet<string>): string | null {
|
||||
let lruId: string | null = null;
|
||||
let lruAt = Infinity;
|
||||
for (const [sessionId, meta] of demoSessions) {
|
||||
if (meta.keyId !== keyId || meta.model !== model || exclude.has(sessionId)) continue;
|
||||
if (meta.lastUsedAt < lruAt) {
|
||||
lruAt = meta.lastUsedAt;
|
||||
lruId = sessionId;
|
||||
}
|
||||
}
|
||||
return lruId;
|
||||
}
|
||||
|
||||
/** Close demo sessions idle past deps.config.demoSessionIdleMs so abandoned
|
||||
* conversations (client gone, title-gen one-offs) release their single-slot
|
||||
* suppliers. Called from the sweeper tick; sequential to keep supplier
|
||||
* /v1/chat/end calls orderly. */
|
||||
export async function sweepIdleDemoSessions(deps: GatewayDeps, nowMs = Date.now()): Promise<void> {
|
||||
const idleMs = deps.config.demoSessionIdleMs;
|
||||
for (const [sessionId, meta] of [...demoSessions]) {
|
||||
if (nowMs - meta.lastUsedAt < idleMs) continue;
|
||||
try {
|
||||
await closeDemoSession(deps, sessionId, { reason: "idle", idleCutoffMs: idleMs, nowMs });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[gateway:demo] idle close of ${sessionId} failed:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_EVICTIONS = 2;
|
||||
|
||||
function isNoSupplierError(err: unknown): boolean {
|
||||
return err instanceof GatewayError && (err.code === "model_not_found" || err.code === "overloaded");
|
||||
}
|
||||
|
||||
/** Open a session, evicting up to MAX_EVICTIONS LRU demo sessions when every
|
||||
* supplier is pinned. Eviction frees the supplier immediately (its slot
|
||||
* releases at Submit) but the indexer's status poll lags, so the retry passes
|
||||
* the freed pkhs through selectCandidates' ignoreStatusFor. Lock ordering is
|
||||
* safe: eviction holds only the session lock, the open holds only the key
|
||||
* mutex — never nested. */
|
||||
async function openSessionWithEviction(
|
||||
deps: GatewayDeps,
|
||||
keyRow: ApiKeyRow,
|
||||
ctx: KeyContext,
|
||||
model: string,
|
||||
): Promise<SessionRow> {
|
||||
const freedPkhs = new Set<string>();
|
||||
const evicted = new Set<string>();
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await ctx.mutex.run(() =>
|
||||
openSessionCore(deps, keyRow, ctx, model, freedPkhs.size > 0 ? { ignoreStatusFor: freedPkhs } : undefined),
|
||||
);
|
||||
} catch (err) {
|
||||
if (attempt >= MAX_EVICTIONS || !isNoSupplierError(err)) throw err;
|
||||
const victimId = pickLruDemoSession(keyRow.id, model, evicted);
|
||||
if (!victimId) throw err;
|
||||
evicted.add(victimId);
|
||||
const pkh = await closeDemoSession(deps, victimId, { reason: "evicted" });
|
||||
if (!pkh) throw err; // nothing actually freed — don't loop
|
||||
freedPkhs.add(pkh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LockedTurnOutcome =
|
||||
| { kind: "done"; result: TurnResult }
|
||||
| { kind: "retry" } // candidate no longer valid — re-run the match
|
||||
| { kind: "gone" }; // supplier lost the session — open a fresh one
|
||||
|
||||
/** Streaming sink shared across turn attempts on one response. */
|
||||
interface StreamSink {
|
||||
onToken: (token: string) => void;
|
||||
emitToolCalls: (toolCalls: ToolCall[]) => void;
|
||||
}
|
||||
|
||||
export async function runDemoChat(
|
||||
deps: GatewayDeps,
|
||||
keyRow: ApiKeyRow,
|
||||
ctx: KeyContext,
|
||||
parsed: ParsedChatRequest,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
// Clients sometimes send histories ending in an assistant (continue/retry
|
||||
// flows) or system message; the chat.v1 supplier can only respond to a
|
||||
// user/tool-terminated turn. Trim trailing unrespondable messages
|
||||
// (regenerate semantics) and reject — before any chain work — only when
|
||||
// nothing respondable remains.
|
||||
const trimmed = trimToRespondable(parsed.messages);
|
||||
if (trimmed.length === 0) {
|
||||
throw badRequest("invalid_messages", "no user or tool message to respond to");
|
||||
}
|
||||
const effective: ParsedChatRequest =
|
||||
trimmed.length === parsed.messages.length ? parsed : { ...parsed, messages: trimmed };
|
||||
|
||||
const id = genId();
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined;
|
||||
let roleSent = false;
|
||||
if (parsed.stream) {
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.write(": connected\n\n");
|
||||
keepalive = setInterval(() => {
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
const ensureRole = (): void => {
|
||||
if (roleSent) return;
|
||||
roleSent = true;
|
||||
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant" }, finishReason: null })));
|
||||
};
|
||||
const sink: StreamSink = {
|
||||
onToken: (token) => {
|
||||
if (!parsed.stream) return;
|
||||
ensureRole();
|
||||
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { content: token }, finishReason: null })));
|
||||
},
|
||||
emitToolCalls: (toolCalls) => {
|
||||
if (!parsed.stream) return;
|
||||
ensureRole();
|
||||
res.write(
|
||||
sseData(
|
||||
buildChunk({
|
||||
id,
|
||||
model: parsed.model,
|
||||
delta: { tool_calls: toolCalls.map((tc, index) => ({ index, ...tc })) },
|
||||
finishReason: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runWithAffinity(deps, keyRow, ctx, effective, sink);
|
||||
if (keepalive) clearInterval(keepalive);
|
||||
|
||||
const usage: Usage = {
|
||||
prompt_tokens: result.usage.prompt_tokens,
|
||||
completion_tokens: result.usage.completion_tokens,
|
||||
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||
};
|
||||
if (parsed.stream) {
|
||||
if (result.assistantMessage.tool_calls?.length) sink.emitToolCalls(result.assistantMessage.tool_calls);
|
||||
ensureRole();
|
||||
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: result.finishReason, usage })));
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
} else {
|
||||
res.status(200).json(
|
||||
buildChatCompletion({
|
||||
id,
|
||||
model: parsed.model,
|
||||
content: result.assistantMessage.content,
|
||||
toolCalls: result.assistantMessage.tool_calls,
|
||||
finishReason: result.finishReason,
|
||||
usage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (keepalive) clearInterval(keepalive);
|
||||
if (parsed.stream && res.headersSent) {
|
||||
try {
|
||||
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithAffinity(
|
||||
deps: GatewayDeps,
|
||||
keyRow: ApiKeyRow,
|
||||
ctx: KeyContext,
|
||||
parsed: ParsedChatRequest,
|
||||
sink: StreamSink,
|
||||
): Promise<TurnResult> {
|
||||
// Affinity: try matching sessions until one serves the turn. The match runs
|
||||
// synchronously (no await between match and lock acquire, so the candidate
|
||||
// set can't shift underneath us); the prefix is re-checked inside the lock
|
||||
// because a concurrent request may have advanced the session first.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const match = matchSessionPrefix(candidatesFor(keyRow.id, parsed.model), parsed.messages, parsed.model);
|
||||
if (!match) break;
|
||||
|
||||
const outcome: LockedTurnOutcome = await getSessionLock(match.sessionId).run(async () => {
|
||||
const meta = demoSessions.get(match.sessionId);
|
||||
const mirror = transcripts.get(match.sessionId);
|
||||
const session = deps.store.getSession(match.sessionId);
|
||||
if (!meta || !mirror || !session || session.state !== "open") {
|
||||
dropDemoSession(match.sessionId);
|
||||
return { kind: "retry" };
|
||||
}
|
||||
const recheck = matchSessionPrefix(
|
||||
[{ sessionId: session.id, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt }],
|
||||
parsed.messages,
|
||||
parsed.model,
|
||||
);
|
||||
if (!recheck) return { kind: "retry" };
|
||||
|
||||
try {
|
||||
const result = await streamSupplierTurn(
|
||||
deps,
|
||||
session,
|
||||
{ messages: recheck.delta, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||
sink.onToken,
|
||||
);
|
||||
meta.lastUsedAt = Date.now();
|
||||
return { kind: "done", result };
|
||||
} catch (err) {
|
||||
if (err instanceof SessionGoneError) {
|
||||
// Supplier idle-closed (or restarted). Close our row, record the
|
||||
// fee (the escrow settles via the sweeper) and reopen fresh.
|
||||
markSessionGone(deps, session);
|
||||
return { kind: "gone" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
if (outcome.kind === "done") return outcome.result;
|
||||
if (outcome.kind === "gone") break;
|
||||
// retry: candidate vanished/advanced — re-match against current state.
|
||||
}
|
||||
|
||||
// Miss: open a new session (chain work → key mutex; evicts an LRU demo
|
||||
// session when every supplier is pinned) and replay the full history as its
|
||||
// first turn.
|
||||
const session = await openSessionWithEviction(deps, keyRow, ctx, parsed.model);
|
||||
demoSessions.set(session.id, { keyId: keyRow.id, model: parsed.model, lastUsedAt: Date.now() });
|
||||
return getSessionLock(session.id).run(async () => {
|
||||
try {
|
||||
const result = await streamSupplierTurn(
|
||||
deps,
|
||||
session,
|
||||
{ messages: parsed.messages, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||
sink.onToken,
|
||||
);
|
||||
// Refresh recency after the (possibly long) first stream so this live
|
||||
// thread isn't the LRU/janitor's first victim.
|
||||
const meta = demoSessions.get(session.id);
|
||||
if (meta) meta.lastUsedAt = Date.now();
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof SessionGoneError) {
|
||||
// Freshly opened yet already gone (supplier crashed mid-claim).
|
||||
// Surface as an upstream error rather than looping.
|
||||
markSessionGone(deps, session);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export const unauthorized = (message = "invalid API key"): GatewayError =>
|
||||
new GatewayError(401, "authentication_error", "invalid_api_key", message);
|
||||
export const paymentRequired = (message: string, extra?: Record<string, unknown>): GatewayError =>
|
||||
new GatewayError(402, "invalid_request_error", "insufficient_funds", message, extra);
|
||||
export const forbidden = (code: string, message: string): GatewayError =>
|
||||
new GatewayError(403, "invalid_request_error", code, message);
|
||||
export const notFound = (code: string, message: string): GatewayError =>
|
||||
new GatewayError(404, "invalid_request_error", code, message);
|
||||
export const rateLimited = (message: string): GatewayError =>
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { GatewayDeps } from "../deps.js";
|
||||
import { asyncHandler } from "../middleware/http.js";
|
||||
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||
import { listModels } from "../routing/selectSupplier.js";
|
||||
import { buildModelsList } from "./shapes.js";
|
||||
import { CAPABILITY as CHAT_CAPABILITY } from "./sessions.js";
|
||||
|
||||
export function makeModelsHandler(deps: GatewayDeps) {
|
||||
return asyncHandler(async (_req: Request, res: Response) => {
|
||||
const models = await listModels({ indexerUrl: deps.config.indexerUrl, fetchFn: deps.fetchFn });
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const keyRow = requireKey(req);
|
||||
// Demo keys only reach chat-capable suppliers (session-backed executor).
|
||||
const models = await listModels({
|
||||
indexerUrl: deps.config.indexerUrl,
|
||||
capabilityId: keyRow.demo ? CHAT_CAPABILITY : undefined,
|
||||
fetchFn: deps.fetchFn,
|
||||
});
|
||||
res.status(200).json(buildModelsList(models));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { TxConstructionError, type ChatMessage } from "@marketplace/shared/tx";
|
||||
import { TxConstructionError, normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||
import { SupplierError } from "@marketplace/buyer/sdk";
|
||||
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||
import type { GatewayDeps } from "../deps.js";
|
||||
import type { ApiKeyRow, GatewayStore, SessionRow } from "../db/store.js";
|
||||
import type { KeyContext } from "../sdk/registry.js";
|
||||
@@ -36,10 +37,15 @@ import {
|
||||
type Usage,
|
||||
} from "./shapes.js";
|
||||
|
||||
const CAPABILITY = "llm.chat.v1";
|
||||
export const CAPABILITY = "llm.chat.v1";
|
||||
|
||||
/** In-memory per-session transcript mirror (sessionId → messages). */
|
||||
const transcripts = new Map<string, ChatMessage[]>();
|
||||
/** Thrown when the supplier no longer knows the session (idle-closed/restart). */
|
||||
export class SessionGoneError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`supplier no longer has session ${sessionId}`);
|
||||
this.name = "SessionGoneError";
|
||||
}
|
||||
}
|
||||
|
||||
function refStr(ref: { txHash: string; index: number }): string {
|
||||
return `${ref.txHash}#${ref.index}`;
|
||||
@@ -56,22 +62,29 @@ export function makeOpenSessionHandler(deps: GatewayDeps) {
|
||||
throw badRequest("invalid_model", "`model` is required");
|
||||
}
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
await ctx.mutex.run(() => openSession(deps, keyRow, ctx, model, res));
|
||||
const session = await ctx.mutex.run(() => openSessionCore(deps, keyRow, ctx, model));
|
||||
res.status(200).json({ id: session.id, object: "chat.session", model, created: nowSec() });
|
||||
});
|
||||
}
|
||||
|
||||
async function openSession(
|
||||
/**
|
||||
* Select a supplier, preflight the wallet, post+claim the session escrow and
|
||||
* persist the SessionRow. Chain work — the CALLER must hold the key mutex
|
||||
* (concurrent PostEscrow from one wallet double-spends the same UTxO).
|
||||
*/
|
||||
export async function openSessionCore(
|
||||
deps: GatewayDeps,
|
||||
keyRow: ApiKeyRow,
|
||||
ctx: KeyContext,
|
||||
model: string,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
opts?: { ignoreStatusFor?: ReadonlySet<string> },
|
||||
): Promise<SessionRow> {
|
||||
const candidates = await selectCandidates({
|
||||
indexerUrl: deps.config.indexerUrl,
|
||||
model,
|
||||
capabilityId: CAPABILITY,
|
||||
fetchFn: deps.fetchFn,
|
||||
ignoreStatusFor: opts?.ignoreStatusFor,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw notFound("model_not_found", `no available chat (llm.chat.v1) supplier for model "${model}"`);
|
||||
@@ -80,7 +93,7 @@ async function openSession(
|
||||
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
|
||||
if (!pf.ok) {
|
||||
throw paymentRequired(
|
||||
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 ADA pure-ADA collateral UTxO",
|
||||
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
|
||||
{
|
||||
x_vector: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
@@ -112,6 +125,16 @@ async function openSession(
|
||||
}
|
||||
if (!started || !used) throw toGatewayError(lastErr ?? new Error("no chat supplier could be engaged"));
|
||||
|
||||
// Deployment sanity: supplier and gateway must agree on the settle mode.
|
||||
// Close behavior is driven per-session by the supplier's /end response, so
|
||||
// a mismatch (rollout window) still settles correctly — just warn.
|
||||
if ((started.settleMode === "ticket") !== (deps.config.chatSettleMode === "ticket")) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[gateway] settle-mode mismatch: supplier ${used.supplierPkh.slice(0, 8)} ran "${started.settleMode}" but gateway is configured "${deps.config.chatSettleMode}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
deps.store.insertSession({
|
||||
id: sessionId,
|
||||
@@ -127,7 +150,9 @@ async function openSession(
|
||||
});
|
||||
transcripts.set(sessionId, []);
|
||||
|
||||
res.status(200).json({ id: sessionId, object: "chat.session", model, created: nowSec() });
|
||||
const session = deps.store.getSession(sessionId);
|
||||
if (!session) throw new Error(`session ${sessionId} vanished after insert`);
|
||||
return session;
|
||||
}
|
||||
|
||||
// ─── messages (one streamed turn) ────────────────────────────────────────────
|
||||
@@ -144,8 +169,12 @@ export function makeSessionMessageHandler(deps: GatewayDeps) {
|
||||
const content = resolveTurnContent(session.id, body);
|
||||
if (content === "") throw badRequest("content_required", "no user content in this turn");
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
await ctx.mutex.run(() => streamTurn(deps, session, content, stream, res));
|
||||
// Turns are chain-free (one HTTP POST to the supplier + SSE relay), so the
|
||||
// per-key mutex — which exists to serialize wallet UTxO spends — is NOT
|
||||
// held here. What needs serialization is this session's transcript:
|
||||
// concurrent turns would interleave supplier appends and corrupt both the
|
||||
// mirror and the receipt hash. Hence a per-session lock.
|
||||
await getSessionLock(session.id).run(() => streamTurn(deps, session, content, stream, res));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,6 +205,88 @@ function foldSystem(sessionId: string, content: string, body: Record<string, unk
|
||||
return content;
|
||||
}
|
||||
|
||||
/** One turn's payload to the supplier: legacy single user string, or a
|
||||
* multi-message delta (e.g. tool results) with optional tools passthrough. */
|
||||
export type TurnPayload =
|
||||
| { content: string }
|
||||
| { messages: ChatMessage[]; tools?: unknown[]; tool_choice?: unknown };
|
||||
|
||||
export interface TurnResult {
|
||||
assistantMessage: ChatMessage;
|
||||
finishReason: "stop" | "tool_calls";
|
||||
usage: { prompt_tokens: number; completion_tokens: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one off-chain turn against the session's supplier and mirror the
|
||||
* result into the session transcript. Caller must hold the session lock.
|
||||
* Throws SessionGoneError when the supplier no longer knows the session
|
||||
* (idle-closed or restarted) so callers can transparently reopen.
|
||||
*/
|
||||
export async function streamSupplierTurn(
|
||||
deps: GatewayDeps,
|
||||
session: SessionRow,
|
||||
payload: TurnPayload,
|
||||
onToken?: (token: string) => void,
|
||||
): Promise<TurnResult> {
|
||||
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (upstream.status === 404) {
|
||||
await upstream.text().catch(() => "");
|
||||
throw new SessionGoneError(session.id);
|
||||
}
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const detail = await upstream.text().catch(() => "");
|
||||
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
|
||||
}
|
||||
|
||||
let assistant = "";
|
||||
let errored: string | undefined;
|
||||
let doneMessage: ChatMessage | null = null;
|
||||
let doneFinish: string | undefined;
|
||||
let usage = { prompt_tokens: 0, completion_tokens: 0 };
|
||||
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
|
||||
if (frame.type === "token" && typeof frame.value === "string") {
|
||||
assistant += frame.value;
|
||||
onToken?.(frame.value);
|
||||
} else if (frame.type === "done") {
|
||||
// Upgraded suppliers echo the final assistant message verbatim; mirror
|
||||
// that exact object so both transcripts stay hash-identical.
|
||||
doneMessage = normalizeChatMessage(frame.message);
|
||||
if (typeof frame.finish_reason === "string") doneFinish = frame.finish_reason;
|
||||
if (frame.usage && typeof frame.usage === "object") {
|
||||
const u = frame.usage as Record<string, unknown>;
|
||||
usage = {
|
||||
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
|
||||
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
|
||||
};
|
||||
}
|
||||
} else if (frame.type === "error") {
|
||||
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
|
||||
}
|
||||
});
|
||||
|
||||
if (errored !== undefined) {
|
||||
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
|
||||
}
|
||||
|
||||
const assistantMessage: ChatMessage = doneMessage ?? { role: "assistant", content: assistant };
|
||||
const finishReason: TurnResult["finishReason"] =
|
||||
doneFinish === "tool_calls" || (assistantMessage.tool_calls?.length ?? 0) > 0 ? "tool_calls" : "stop";
|
||||
|
||||
// Mirror the turn for endChat's response_hash verification / affinity matching.
|
||||
const mirror = transcripts.get(session.id) ?? [];
|
||||
if ("content" in payload) mirror.push({ role: "user", content: payload.content });
|
||||
else mirror.push(...payload.messages);
|
||||
mirror.push(assistantMessage);
|
||||
transcripts.set(session.id, mirror);
|
||||
|
||||
return { assistantMessage, finishReason, usage };
|
||||
}
|
||||
|
||||
async function streamTurn(
|
||||
deps: GatewayDeps,
|
||||
session: SessionRow,
|
||||
@@ -184,15 +295,6 @@ async function streamTurn(
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const id = genId();
|
||||
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const detail = await upstream.text().catch(() => "");
|
||||
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
res.status(200);
|
||||
@@ -203,46 +305,43 @@ async function streamTurn(
|
||||
res.write(sseData(buildChunk({ id, model: session.model, delta: { role: "assistant" }, finishReason: null })));
|
||||
}
|
||||
|
||||
let assistant = "";
|
||||
let errored: string | undefined;
|
||||
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
|
||||
if (frame.type === "token" && typeof frame.value === "string") {
|
||||
assistant += frame.value;
|
||||
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: frame.value }, finishReason: null })));
|
||||
} else if (frame.type === "error") {
|
||||
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
|
||||
let result: TurnResult;
|
||||
try {
|
||||
result = await streamSupplierTurn(deps, session, { content }, (token) => {
|
||||
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: token }, finishReason: null })));
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SessionGoneError) {
|
||||
err = toGatewayError(new SupplierError("supplier_http_error", { status: 404, message: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
if (errored !== undefined) {
|
||||
if (stream) {
|
||||
res.write(sseData(toErrorBody(toGatewayError(new SupplierError("upstream_error", { message: errored })))));
|
||||
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
|
||||
throw toGatewayError(err);
|
||||
}
|
||||
|
||||
// Mirror the transcript for endChat's response_hash verification.
|
||||
const mirror = transcripts.get(session.id) ?? [];
|
||||
mirror.push({ role: "user", content });
|
||||
mirror.push({ role: "assistant", content: assistant });
|
||||
transcripts.set(session.id, mirror);
|
||||
|
||||
if (stream) {
|
||||
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: "stop" })));
|
||||
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: result.finishReason })));
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
} else {
|
||||
const usage: Usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
||||
res.status(200).json(buildChatCompletion({ id, model: session.model, content: assistant, usage }));
|
||||
const usage: Usage = {
|
||||
prompt_tokens: result.usage.prompt_tokens,
|
||||
completion_tokens: result.usage.completion_tokens,
|
||||
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||
};
|
||||
res.status(200).json(buildChatCompletion({ id, model: session.model, content: result.assistantMessage.content, usage }));
|
||||
}
|
||||
}
|
||||
|
||||
interface SupplierFrame {
|
||||
type?: string;
|
||||
value?: string;
|
||||
message?: string;
|
||||
message?: unknown;
|
||||
finish_reason?: unknown;
|
||||
usage?: unknown;
|
||||
}
|
||||
|
||||
/** Read a supplier SSE stream of {type:token|done|error} frames. */
|
||||
@@ -308,7 +407,34 @@ async function closeSession(
|
||||
});
|
||||
|
||||
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||
transcripts.delete(session.id);
|
||||
dropSessionState(session.id);
|
||||
|
||||
if (result.settleMode === "ticket") {
|
||||
// Nothing was Submitted/Accepted: no receipt, no charge (the escrow
|
||||
// returns via the sweeper's reclaim), and no wallet churn to tidy up.
|
||||
deps.store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: keyRow.id,
|
||||
created_at: Date.now(),
|
||||
kind: "chat_session",
|
||||
model: session.model,
|
||||
capability_id: CAPABILITY,
|
||||
supplier_pkh: session.supplier_pkh,
|
||||
escrow_ref: session.escrow_ref,
|
||||
cost_lovelace: "0",
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
status: "completed",
|
||||
failure_reason: null,
|
||||
});
|
||||
res.status(200).json({
|
||||
status: "ended",
|
||||
escrow_ref: session.escrow_ref,
|
||||
settle_mode: "ticket",
|
||||
model: session.model,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = result.receipt.prompt_tokens ?? 0;
|
||||
const completion = result.receipt.completion_tokens ?? 0;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { randomBytes } from "crypto";
|
||||
import type { Receipt } from "@marketplace/shared/receipt";
|
||||
import type { ToolCall } from "@marketplace/shared/tx";
|
||||
|
||||
export function genId(): string {
|
||||
return `chatcmpl-${randomBytes(16).toString("hex")}`;
|
||||
@@ -40,6 +41,8 @@ export function buildChatCompletion(args: {
|
||||
model: string;
|
||||
content: string;
|
||||
usage: Usage;
|
||||
toolCalls?: ToolCall[];
|
||||
finishReason?: "stop" | "tool_calls";
|
||||
vector?: VectorReceipt;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -50,8 +53,12 @@ export function buildChatCompletion(args: {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: args.content },
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: args.content,
|
||||
...(args.toolCalls?.length ? { tool_calls: args.toolCalls } : {}),
|
||||
},
|
||||
finish_reason: args.finishReason ?? "stop",
|
||||
},
|
||||
],
|
||||
usage: args.usage,
|
||||
@@ -63,8 +70,10 @@ export function buildChatCompletion(args: {
|
||||
export function buildChunk(args: {
|
||||
id: string;
|
||||
model: string;
|
||||
delta: { role?: string; content?: string } | Record<string, never>;
|
||||
finishReason: "stop" | null;
|
||||
delta:
|
||||
| { role?: string; content?: string; tool_calls?: Array<{ index: number } & ToolCall> }
|
||||
| Record<string, never>;
|
||||
finishReason: "stop" | "tool_calls" | null;
|
||||
usage?: Usage;
|
||||
vector?: VectorReceipt;
|
||||
}): Record<string, unknown> {
|
||||
|
||||
35
gateway/src/openai/transcripts.ts
Normal file
35
gateway/src/openai/transcripts.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* gateway/src/openai/transcripts.ts — in-memory per-session state shared by
|
||||
* the session routes (sessions.ts), the demo affinity executor (demoChat.ts)
|
||||
* and the sweeper's housekeeping.
|
||||
*
|
||||
* transcripts: mirror of each open session's transcript (sessionId → messages).
|
||||
* Kept hash-identical to the supplier's authoritative transcript (endChat's
|
||||
* response_hash check). Lost on restart — abandoned sessions are recovered
|
||||
* by the supplier idle watchdog + gateway sweeper.
|
||||
* sessionLocks: per-session Mutex serializing turns on ONE session (concurrent
|
||||
* turns would interleave supplier transcript appends). Distinct from the
|
||||
* per-key Mutex, which only serializes on-chain wallet work.
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import { Mutex } from "../sdk/registry.js";
|
||||
|
||||
export const transcripts = new Map<string, ChatMessage[]>();
|
||||
|
||||
const sessionLocks = new Map<string, Mutex>();
|
||||
|
||||
export function getSessionLock(sessionId: string): Mutex {
|
||||
let lock = sessionLocks.get(sessionId);
|
||||
if (!lock) {
|
||||
lock = new Mutex();
|
||||
sessionLocks.set(sessionId, lock);
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
/** Forget all in-memory state for a session (closed/gone). */
|
||||
export function dropSessionState(sessionId: string): void {
|
||||
transcripts.delete(sessionId);
|
||||
sessionLocks.delete(sessionId);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import { normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||
import { badRequest } from "./errors.js";
|
||||
|
||||
export interface ParsedChatRequest {
|
||||
@@ -10,21 +10,30 @@ export interface ParsedChatRequest {
|
||||
messages: ChatMessage[];
|
||||
maxTokens?: number;
|
||||
stream: boolean;
|
||||
tools?: unknown[];
|
||||
toolChoice?: unknown;
|
||||
}
|
||||
|
||||
const ROLES = new Set(["system", "user", "assistant"]);
|
||||
|
||||
/** Validate an OpenAI chat.completions-style body. Rejects tools/functions;
|
||||
* silently ignores temperature/n/stop/top_p/etc (documented). */
|
||||
export function parseChatRequest(body: unknown): ParsedChatRequest {
|
||||
/** Validate an OpenAI chat.completions-style body. Default path rejects
|
||||
* tools/functions; `allowTools` (demo keys) accepts tools/tool_choice, the
|
||||
* `tool` role, null content and assistant tool_calls, normalizing every
|
||||
* message into canonical mirror shape. Silently ignores temperature/n/stop/
|
||||
* top_p/etc (documented). */
|
||||
export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean }): ParsedChatRequest {
|
||||
const allowTools = opts?.allowTools === true;
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||
}
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
if ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b) {
|
||||
if (!allowTools && ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b)) {
|
||||
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
|
||||
}
|
||||
if (allowTools && ("functions" in b || "function_call" in b)) {
|
||||
throw badRequest("unsupported_parameter", "legacy functions/function_call are not supported; use tools");
|
||||
}
|
||||
|
||||
const model = b.model;
|
||||
if (typeof model !== "string" || model === "") {
|
||||
@@ -37,6 +46,14 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
||||
}
|
||||
const messages: ChatMessage[] = [];
|
||||
for (const m of rawMessages) {
|
||||
if (allowTools) {
|
||||
const msg = normalizeChatMessage(m);
|
||||
if (!msg) {
|
||||
throw badRequest("invalid_messages", "each message must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?}");
|
||||
}
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof m !== "object" ||
|
||||
m === null ||
|
||||
@@ -50,6 +67,13 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
||||
messages.push({ role: mm.role, content: mm.content });
|
||||
}
|
||||
|
||||
let tools: unknown[] | undefined;
|
||||
let toolChoice: unknown;
|
||||
if (allowTools && Array.isArray(b.tools) && b.tools.length > 0) {
|
||||
tools = b.tools;
|
||||
toolChoice = b.tool_choice;
|
||||
}
|
||||
|
||||
let maxTokens: number | undefined;
|
||||
const mt = b.max_tokens ?? b.max_completion_tokens;
|
||||
if (mt !== undefined && mt !== null) {
|
||||
@@ -60,5 +84,5 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
||||
}
|
||||
|
||||
const stream = b.stream === true;
|
||||
return { model, messages, maxTokens, stream };
|
||||
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||
}
|
||||
|
||||
@@ -53,9 +53,17 @@ 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;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
/** supplier_pkhs eligible regardless of polled status — used after the demo
|
||||
* executor frees a supplier the indexer poller (~20 s cadence) still reports
|
||||
* busy. sdk.startChat's live /status pre-check still guards genuinely-busy
|
||||
* suppliers. */
|
||||
ignoreStatusFor?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,9 +85,11 @@ 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 (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
}
|
||||
const advertRef = parseRef(raw.utxo_ref);
|
||||
if (!advertRef) continue;
|
||||
candidates.push({
|
||||
@@ -101,19 +111,27 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** Distinct models across Active suppliers (for GET /openai/v1/models). */
|
||||
/** Distinct models across Active suppliers (for GET /openai/v1/models).
|
||||
* Pass capabilityId to list only models under one capability (demo keys see
|
||||
* only llm.chat.v1 models — the session-backed executor's routing target). */
|
||||
export async function listModels(opts: {
|
||||
indexerUrl: string;
|
||||
capabilityId?: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
}): Promise<string[]> {
|
||||
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
||||
const res = await fetchFn(`${opts.indexerUrl}/suppliers`);
|
||||
const url = opts.capabilityId
|
||||
? `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`
|
||||
: `${opts.indexerUrl}/suppliers`;
|
||||
const res = await fetchFn(url);
|
||||
if (!res.ok) throw new Error(`indexer /suppliers returned ${res.status}`);
|
||||
const body = (await res.json()) as unknown;
|
||||
if (!Array.isArray(body)) throw new Error("indexer /suppliers did not return an array");
|
||||
const models = new Set<string>();
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.advert_status === "Active" && typeof raw.model === "string") models.add(raw.model);
|
||||
if (raw.advert_status !== "Active" || typeof raw.model !== "string") continue;
|
||||
if (opts.capabilityId !== undefined && raw.capability_id !== opts.capabilityId) continue;
|
||||
models.add(raw.model);
|
||||
}
|
||||
return [...models].sort();
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* POST /openai/v1/chat/sessions[/:id/messages|/close].
|
||||
*/
|
||||
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
||||
import type { GatewayDeps } from "./deps.js";
|
||||
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
|
||||
import { ipRateLimit, keyRateLimit } from "./middleware/rateLimit.js";
|
||||
import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLimit.js";
|
||||
import { sendError } from "./middleware/http.js";
|
||||
import { notFound } from "./openai/errors.js";
|
||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||
import { makeModelsHandler } from "./openai/models.js";
|
||||
import {
|
||||
makeOpenSessionHandler,
|
||||
@@ -23,11 +24,44 @@ import {
|
||||
import { makeSignupHandler, makeAccountHandler, makeWithdrawHandler } from "./account/routes.js";
|
||||
import { INDEX_HTML } from "./ui/page.js";
|
||||
|
||||
/**
|
||||
* CORS for browser clients on an allowlisted Origin (e.g. the marketplace
|
||||
* frontend's "Generate API key" page, served from a different host than the
|
||||
* gateway). Only echoes Access-Control-* headers when the request's Origin is
|
||||
* in the configured allowlist; an empty allowlist disables CORS entirely.
|
||||
* No credentials are allowed — the signup call is a plain bearer-less POST, so
|
||||
* cookies never ride along cross-origin. Preflight OPTIONS is answered 204.
|
||||
*/
|
||||
function corsMiddleware(allowOrigins: string[]) {
|
||||
const allow = new Set(allowOrigins);
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const origin = req.headers.origin;
|
||||
if (typeof origin === "string" && allow.has(origin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Max-Age", "600");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(deps: GatewayDeps): Express {
|
||||
const app = express();
|
||||
// Behind traefik/Cloudflare — trust the proxy chain so req.ip is the client.
|
||||
app.set("trust proxy", true);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||
// the route; every other path keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
@@ -40,15 +74,23 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
// Public signup, rate-limited per IP.
|
||||
app.post("/signup", ipRateLimit(deps.config.signupRate), makeSignupHandler(deps));
|
||||
|
||||
// Bearer-gated routes: auth → per-key rate limit → handler.
|
||||
// Bearer-gated routes: auth → per-key rate limit (skips demo keys) →
|
||||
// per-IP demo limit (demo keys only) → handler.
|
||||
const auth = makeApiKeyAuth(deps.store);
|
||||
const krl = keyRateLimit(deps.config.keyRate);
|
||||
const gated = [auth, krl] as const;
|
||||
const dirl = demoIpRateLimit(deps.config.demoIpRate);
|
||||
const gated = [auth, krl, dirl] as const;
|
||||
|
||||
app.get("/account", auth, makeAccountHandler(deps));
|
||||
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||
|
||||
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
|
||||
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
|
||||
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
|
||||
// limits) run BEFORE the parser so an unauthenticated caller cannot
|
||||
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
|
||||
// can actually throttle the request (2026-08 review finding C2).
|
||||
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
|
||||
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||
|
||||
@@ -10,26 +10,120 @@
|
||||
* state and never blind-reclaim a Submitted escrow. The on-chain validator is
|
||||
* the source of truth for deadlines, so premature/late attempts simply fail and
|
||||
* are caught here; the age thresholds just avoid racing in-flight requests.
|
||||
*
|
||||
* Chat sessions get special handling:
|
||||
* - Their deliver_by is ~30 min (vs ~10 min one-shot), so rows belonging to
|
||||
* an OPEN session use CHAT_RECLAIM_AFTER_MS — otherwise every open session
|
||||
* older than 11 min would trigger a doomed Reclaim attempt per sweep while
|
||||
* holding the key's mutex.
|
||||
* - When a session's escrow reaches a terminal state (Accepted here, spent
|
||||
* elsewhere, or Reclaimed), the session row is closed, its in-memory state
|
||||
* dropped, and a usage row recorded — abandoned demo sessions (nobody calls
|
||||
* /close) settle and get billed through this path.
|
||||
*
|
||||
* Escrow↔session lineage: the indexer keeps one row per utxo_ref per state
|
||||
* (terminal spends update nothing), and the validator preserves posted_at
|
||||
* across states — so a session's lineage is "rows sharing the posted_at of the
|
||||
* session's original Open ref row".
|
||||
*
|
||||
* Because terminal spends never update indexer rows, every settled escrow
|
||||
* would be retried (one doomed chain query each) on every sweep forever. The
|
||||
* in-process `settledRefs` memory skips refs this process has already settled
|
||||
* or found spent; a restart just causes one harmless retry burst.
|
||||
*
|
||||
* Ticket mode (config.chatSettleMode === "ticket"): session escrows are never
|
||||
* Claimed — they sit Open until reclaimed here after deliver_by. Reclaiming
|
||||
* an Open ticket does NOT close a still-open session row: nothing re-checks
|
||||
* the escrow after /v1/chat/start, so the conversation (and its affinity
|
||||
* mirror) lives on, bounded only by the idle janitor.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import type { GatewayDeps } from "./deps.js";
|
||||
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm } from "./onchain/settle.js";
|
||||
import type { SessionRow } from "./db/store.js";
|
||||
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm, type EscrowRow } from "./onchain/settle.js";
|
||||
import { parseRef } from "./routing/selectSupplier.js";
|
||||
import { dropSessionState } from "./openai/transcripts.js";
|
||||
import { dropDemoSession, sweepIdleDemoSessions } from "./openai/demoChat.js";
|
||||
import { CAPABILITY as CHAT_CAPABILITY } from "./openai/sessions.js";
|
||||
|
||||
// Don't touch escrows newer than this — an in-flight request is settling them.
|
||||
const MIN_AGE_MS = 90_000;
|
||||
// Reclaim Open/Claimed escrows older than this (past deliver_by ~10m + buffer).
|
||||
const RECLAIM_AFTER_MS = 11 * 60 * 1000;
|
||||
// Chat-session escrows: deliver_by ≈ posted_at + 30 min, so wait longer.
|
||||
const CHAT_RECLAIM_AFTER_MS = 35 * 60 * 1000;
|
||||
// Reclaim only fires once the validator allows it (validity lower bound >=
|
||||
// deliver_by); attempting earlier is a guaranteed-doomed tx build.
|
||||
const DELIVER_BY_BUFFER_MS = 60_000;
|
||||
|
||||
/** Refs this process already settled (Accepted/Reclaimed) or found spent.
|
||||
* The indexer never updates terminal rows, so without this every historical
|
||||
* escrow is retried each sweep. Bounded FIFO; restart = one retry burst. */
|
||||
const settledRefs = new Set<string>();
|
||||
const SETTLED_REFS_MAX = 5_000;
|
||||
|
||||
function markSettled(ref: string): void {
|
||||
settledRefs.add(ref);
|
||||
if (settledRefs.size > SETTLED_REFS_MAX) {
|
||||
const first = settledRefs.values().next().value;
|
||||
if (first !== undefined) settledRefs.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[gateway:sweeper] ${msg}`);
|
||||
}
|
||||
|
||||
export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
||||
function closeSessionRow(
|
||||
deps: GatewayDeps,
|
||||
session: SessionRow,
|
||||
outcome: { status: "completed" | "reclaimed"; costLovelace: string | null },
|
||||
): void {
|
||||
// Re-read: an in-flight close (route handler or demo executor) may have won.
|
||||
const current = deps.store.getSession(session.id);
|
||||
if (!current || current.state !== "open") return;
|
||||
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||
dropSessionState(session.id);
|
||||
dropDemoSession(session.id);
|
||||
deps.store.insertUsage({
|
||||
id: randomUUID(),
|
||||
key_id: session.key_id,
|
||||
created_at: Date.now(),
|
||||
kind: "chat_session",
|
||||
model: session.model,
|
||||
capability_id: CHAT_CAPABILITY,
|
||||
supplier_pkh: session.supplier_pkh,
|
||||
escrow_ref: session.escrow_ref,
|
||||
cost_lovelace: outcome.costLovelace,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
status: outcome.status,
|
||||
failure_reason: null,
|
||||
});
|
||||
log(`closed abandoned session ${session.id} (${outcome.status})`);
|
||||
}
|
||||
|
||||
/** Chain-settle functions, injectable for tests. */
|
||||
export interface SweepSettleFns {
|
||||
accept: typeof acceptAndConfirm;
|
||||
reclaim: typeof reclaimAndConfirm;
|
||||
}
|
||||
|
||||
export async function runSweepOnce(
|
||||
deps: GatewayDeps,
|
||||
settle: SweepSettleFns = { accept: acceptAndConfirm, reclaim: reclaimAndConfirm },
|
||||
): Promise<void> {
|
||||
// Idle demo sessions first: closing them makes the supplier Submit, so this
|
||||
// sweep (or the next) can Accept the escrow right after.
|
||||
await sweepIdleDemoSessions(deps).catch((err) =>
|
||||
log(`demo idle janitor: ${err instanceof Error ? err.message : String(err)}`),
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
for (const keyRow of deps.store.listKeys()) {
|
||||
let rows;
|
||||
let rows: EscrowRow[];
|
||||
try {
|
||||
rows = await fetchEscrows(deps.config.indexerUrl, keyRow.wallet_pkh, deps.fetchFn);
|
||||
} catch (err) {
|
||||
@@ -37,20 +131,71 @@ export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lineage-match open sessions: the session stores its ORIGINAL Open ref;
|
||||
// rows sharing that row's posted_at are the same escrow in later states.
|
||||
const sessionByPostedAt = new Map<number, SessionRow>();
|
||||
for (const session of deps.store.listOpenSessionsByKey(keyRow.id)) {
|
||||
const origin = rows.find((r) => r.utxo_ref === session.escrow_ref);
|
||||
if (origin) sessionByPostedAt.set(origin.posted_at, session);
|
||||
}
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
for (const row of rows) {
|
||||
const ref = parseRef(row.utxo_ref);
|
||||
if (!ref) continue;
|
||||
if (settledRefs.has(row.utxo_ref)) continue;
|
||||
const ageMs = now - (row.posted_at ?? 0);
|
||||
if (ageMs < MIN_AGE_MS) continue;
|
||||
const chatSession = sessionByPostedAt.get(row.posted_at);
|
||||
|
||||
try {
|
||||
if (row.state === "Submitted") {
|
||||
await ctx.mutex.run(() => acceptAndConfirm(deps.chain, ctx.walletKey, ref));
|
||||
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
|
||||
} else if ((row.state === "Open" || row.state === "Claimed") && ageMs > RECLAIM_AFTER_MS) {
|
||||
await ctx.mutex.run(() => reclaimAndConfirm(deps.chain, ctx.walletKey, ref));
|
||||
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
|
||||
try {
|
||||
await ctx.mutex.run(() => settle.accept(deps.chain, ctx.walletKey, ref));
|
||||
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
|
||||
markSettled(row.utxo_ref);
|
||||
if (chatSession) {
|
||||
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||
}
|
||||
} catch (err) {
|
||||
// Accept fails routinely when the UTxO is already spent (settled
|
||||
// by an in-flight request, an earlier sweep, or supplier Release).
|
||||
// For session lineages, confirm the spend on-chain before closing
|
||||
// the row — an Accept that failed for another reason (timeout)
|
||||
// must leave the session open for the next sweep.
|
||||
if ((await deps.chain.queryUtxo(ref)) === null) {
|
||||
markSettled(row.utxo_ref);
|
||||
if (chatSession) {
|
||||
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||
}
|
||||
} else if (!chatSession) {
|
||||
// Preserve the historical quiet-skip for one-shot escrows.
|
||||
void err;
|
||||
}
|
||||
}
|
||||
} else if (row.state === "Open" || row.state === "Claimed") {
|
||||
const reclaimAfterMs = chatSession ? CHAT_RECLAIM_AFTER_MS : RECLAIM_AFTER_MS;
|
||||
// The validator only allows Reclaim at/after deliver_by — skip
|
||||
// attempts that cannot succeed yet (early-closed ticket sessions).
|
||||
const pastDeliverBy =
|
||||
typeof row.deliver_by === "number" ? now > row.deliver_by + DELIVER_BY_BUFFER_MS : true;
|
||||
if (ageMs > reclaimAfterMs && pastDeliverBy) {
|
||||
await ctx.mutex.run(() => settle.reclaim(deps.chain, ctx.walletKey, ref));
|
||||
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
|
||||
markSettled(row.utxo_ref);
|
||||
if (chatSession) {
|
||||
const ticketOpen = deps.config.chatSettleMode === "ticket" && row.state === "Open";
|
||||
if (ticketOpen) {
|
||||
// Ticket escrow reclaimed out from under a LIVE session —
|
||||
// by design. Keep the session row + mirror; only the idle
|
||||
// janitor / close paths end ticket sessions.
|
||||
log(`ticket mode: escrow reclaimed, session ${chatSession.id} stays open`);
|
||||
} else {
|
||||
// Funds returned — the session never settled; record no cost.
|
||||
closeSessionRow(deps, chatSession, { status: "reclaimed", costLovelace: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Expected when the validator rejects (wrong window/state) or the UTxO
|
||||
@@ -58,9 +203,6 @@ export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark closed/settled chat sessions whose escrow is no longer active.
|
||||
// (Best-effort housekeeping; the escrow recovery above is the load-bearing part.)
|
||||
}
|
||||
|
||||
/** Start the periodic sweeper. Returns a stop() function. */
|
||||
|
||||
@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vector Marketplace — OpenAI Gateway</title>
|
||||
<title>Vector Marketplace - OpenAI Gateway</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Vector Marketplace — OpenAI-compatible Gateway</h1>
|
||||
<h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
|
||||
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
|
||||
|
||||
<h2>1. Create an API key</h2>
|
||||
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
<div id="signupOut"></div>
|
||||
|
||||
<h2>2. Fund your wallet</h2>
|
||||
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 ADA collateral + fees).</p>
|
||||
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
|
||||
|
||||
<h2>3. Account</h2>
|
||||
<div class="row">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
|
||||
*
|
||||
* After every settled request (and on a periodic tick) we re-shape the wallet to
|
||||
* {≥5 ADA collateral UTxO, working UTxO} via the shared consolidate builder, so
|
||||
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA
|
||||
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
|
||||
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
|
||||
* collateral candidate. No-op when the wallet is already healthy.
|
||||
*/
|
||||
|
||||
|
||||
143
gateway/test/affinity.test.ts
Normal file
143
gateway/test/affinity.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* affinity.test.ts — transcript-prefix session matching rules.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
matchSessionPrefix,
|
||||
normalizeAssistantContent,
|
||||
trimToRespondable,
|
||||
type AffinityCandidate,
|
||||
} from "../src/openai/affinity.js";
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
|
||||
const u = (content: string): ChatMessage => ({ role: "user", content });
|
||||
const a = (content: string): ChatMessage => ({ role: "assistant", content });
|
||||
const TOOL_CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
|
||||
|
||||
function cand(sessionId: string, mirror: ChatMessage[], lastUsedAt = 0, model = "m"): AffinityCandidate {
|
||||
return { sessionId, model, mirror, lastUsedAt };
|
||||
}
|
||||
|
||||
describe("matchSessionPrefix", () => {
|
||||
it("matches an exact prefix and returns the delta", () => {
|
||||
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("hello"), u("more")], "m");
|
||||
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
|
||||
});
|
||||
|
||||
it("accepts a multi-message tool delta ending with a tool result", () => {
|
||||
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||
const incoming = [...mirror, { role: "tool" as const, content: "3pm", tool_call_id: "call_1" }];
|
||||
const got = matchSessionPrefix([cand("s1", mirror)], incoming, "m");
|
||||
expect(got?.sessionId).toBe("s1");
|
||||
expect(got?.delta).toHaveLength(1);
|
||||
expect(got?.delta[0].role).toBe("tool");
|
||||
});
|
||||
|
||||
it("rejects deltas that end with a non-respondable role", () => {
|
||||
const got = matchSessionPrefix([cand("s1", [u("hi")])], [u("hi"), a("stray assistant")], "m");
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it("never matches when incoming is not longer than the mirror (retry of answered request)", () => {
|
||||
const mirror = [u("hi"), a("hello")];
|
||||
expect(matchSessionPrefix([cand("s1", mirror)], mirror, "m")).toBeNull();
|
||||
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi")], "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects diverged histories (prefix mismatch)", () => {
|
||||
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("DIFFERENT"), u("more")], "m");
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the model to match", () => {
|
||||
expect(matchSessionPrefix([cand("s1", [u("hi")], 0, "other")], [u("hi"), u("more")], "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the longest mirror, then most recently used", () => {
|
||||
const incoming = [u("hi"), a("hello"), u("more")];
|
||||
const longest = matchSessionPrefix(
|
||||
[cand("short", [u("hi")], 99), cand("long", [u("hi"), a("hello")], 0)],
|
||||
incoming,
|
||||
"m",
|
||||
);
|
||||
expect(longest?.sessionId).toBe("long");
|
||||
|
||||
const tie = matchSessionPrefix(
|
||||
[cand("old", [u("hi"), a("hello")], 1), cand("recent", [u("hi"), a("hello")], 2)],
|
||||
incoming,
|
||||
"m",
|
||||
);
|
||||
expect(tie?.sessionId).toBe("recent");
|
||||
});
|
||||
|
||||
it("matches an empty mirror (fresh session) against any respondable history", () => {
|
||||
const incoming = [{ role: "system" as const, content: "be nice" }, u("hi")];
|
||||
const got = matchSessionPrefix([cand("fresh", [])], incoming, "m");
|
||||
expect(got).toEqual({ sessionId: "fresh", delta: incoming });
|
||||
});
|
||||
|
||||
it("treats content null-normalization as equivalent (tool-call assistant echo)", () => {
|
||||
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||
// Client echoes the assistant message back with content:"" (normalized from null upstream).
|
||||
const incoming = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [{ ...TOOL_CALL }] }, { role: "tool" as const, content: "x", tool_call_id: "call_1" }];
|
||||
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")?.sessionId).toBe("s1");
|
||||
});
|
||||
|
||||
it("matches leniently when the client reshapes the assistant echo (think-strip + whitespace)", () => {
|
||||
const mirror = [u("hi"), a("<think>plan it</think>Hello there\n")];
|
||||
const got = matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello there"), u("more")], "m");
|
||||
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
|
||||
});
|
||||
|
||||
it("stays strict on user/tool divergence even with lenient assistant matching", () => {
|
||||
const mirror = [u("hi "), a("Hello")];
|
||||
// User content differs by whitespace — client-authored roles must be byte-exact.
|
||||
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello"), u("more")], "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects assistant tool_calls divergence under lenient matching", () => {
|
||||
const mirror = [u("hi"), { role: "assistant" as const, content: "x", tool_calls: [TOOL_CALL] }];
|
||||
const changed = { ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } };
|
||||
const incoming = [u("hi"), { role: "assistant" as const, content: "x ", tool_calls: [changed] }, { role: "tool" as const, content: "t", tool_call_id: "call_1" }];
|
||||
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers a strict match over a lenient one regardless of recency", () => {
|
||||
const incoming = [u("hi"), a("Hello"), u("more")];
|
||||
const got = matchSessionPrefix(
|
||||
[
|
||||
cand("lenient", [u("hi"), a("Hello ")], 99),
|
||||
cand("strict", [u("hi"), a("Hello")], 1),
|
||||
],
|
||||
incoming,
|
||||
"m",
|
||||
);
|
||||
expect(got?.sessionId).toBe("strict");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAssistantContent", () => {
|
||||
it("strips think blocks, collapses whitespace and trims", () => {
|
||||
expect(normalizeAssistantContent("<think>a\nb</think> Hi\n\nthere friend ")).toBe("Hi there friend");
|
||||
expect(normalizeAssistantContent("<THINK>x</THINK>ok")).toBe("ok");
|
||||
expect(normalizeAssistantContent("plain")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimToRespondable", () => {
|
||||
it("drops trailing assistant/system messages", () => {
|
||||
expect(trimToRespondable([u("hi"), a("partial")])).toEqual([u("hi")]);
|
||||
expect(trimToRespondable([u("hi"), a("x"), { role: "system" as const, content: "nudge" }])).toEqual([u("hi")]);
|
||||
});
|
||||
|
||||
it("returns empty when nothing respondable remains", () => {
|
||||
expect(trimToRespondable([a("hello")])).toEqual([]);
|
||||
expect(trimToRespondable([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps already-respondable histories unchanged", () => {
|
||||
const msgs = [u("hi"), a("x"), u("more")];
|
||||
expect(trimToRespondable(msgs)).toEqual(msgs);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/server.js";
|
||||
import { sweepIdleDemoSessions } from "../src/openai/demoChat.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 type { GatewayConfig } from "../src/config.js";
|
||||
import type { GatewayDeps } from "../src/deps.js";
|
||||
|
||||
const MASTER = "ab".repeat(32);
|
||||
|
||||
const SUPPLIERS = [
|
||||
{ utxo_ref: "aa".repeat(32) + "#0", supplier_pkh: "s1", capability_id: "llm.text.generate.v1", model: "qwen", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
|
||||
{ utxo_ref: "bb".repeat(32) + "#0", supplier_pkh: "s2", capability_id: "llm.chat.v1", model: "kimi", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
|
||||
];
|
||||
|
||||
function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
|
||||
function makeDeps(
|
||||
fetchFn?: typeof globalThis.fetch,
|
||||
overrides?: {
|
||||
demoIpRate?: { max: number; windowMs: number };
|
||||
chain?: GatewayDeps["chain"];
|
||||
chatSettleMode?: "full" | "ticket";
|
||||
},
|
||||
): GatewayDeps {
|
||||
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
|
||||
const store = new GatewayStore(dbDir);
|
||||
const config: GatewayConfig = {
|
||||
masterKeyHex: "ab".repeat(32),
|
||||
masterKeyHex: MASTER,
|
||||
indexerUrl: "http://ix",
|
||||
ogmiosUrl: "http://og",
|
||||
networkId: 0,
|
||||
@@ -26,11 +40,16 @@ function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
|
||||
dbDir,
|
||||
signupRate: { max: 1000, windowMs: 60_000 },
|
||||
keyRate: { max: 1000, windowMs: 60_000 },
|
||||
demoIpRate: overrides?.demoIpRate ?? { max: 1000, windowMs: 60_000 },
|
||||
sweeperIntervalMs: 60_000,
|
||||
demoSessionIdleMs: 180_000,
|
||||
chatSettleMode: overrides?.chatSettleMode ?? "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = { queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"];
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
const registry = new SdkRegistry({
|
||||
chain,
|
||||
indexerUrl: config.indexerUrl,
|
||||
@@ -103,3 +122,450 @@ describe("gateway HTTP", () => {
|
||||
expect(res.body.error.code).toBe("unsupported_parameter");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shared demo key (session-backed completions) ───────────────────────────
|
||||
|
||||
const TOOLS = [{ type: "function", function: { name: "get_time", parameters: {} } }];
|
||||
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||
|
||||
/** 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"];
|
||||
|
||||
function supplierSse(frames: unknown[]): Response {
|
||||
const body = frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
function doneFrame(message: Record<string, unknown>, finishReason: string, usage = { prompt_tokens: 3, completion_tokens: 2 }) {
|
||||
return { type: "done", message, finish_reason: finishReason, usage };
|
||||
}
|
||||
|
||||
/** fetchFn serving the indexer supplier list and scripted /v1/chat/message
|
||||
* responses. Pass `suppliers` to serve a mutable list (eviction tests flip
|
||||
* status mid-test); /v1/chat/end calls are recorded and 200-acked. */
|
||||
function demoFetch(messageResponses: Array<() => Response>, suppliers?: () => unknown[]) {
|
||||
const messageCalls: Array<Record<string, unknown>> = [];
|
||||
const endCalls: string[] = [];
|
||||
let i = 0;
|
||||
const fetchFn = (async (url: unknown, init?: { body?: string; headers?: Record<string, string> }) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/v1/chat/message")) {
|
||||
messageCalls.push(init?.body ? (JSON.parse(init.body) as Record<string, unknown>) : {});
|
||||
const make = messageResponses[Math.min(i, messageResponses.length - 1)];
|
||||
i += 1;
|
||||
return make();
|
||||
}
|
||||
if (u.includes("/v1/chat/end")) {
|
||||
endCalls.push(init?.headers?.["X-Escrow-Ref"] ?? "");
|
||||
return new Response(JSON.stringify({ status: "submitted" }), { status: 200 });
|
||||
}
|
||||
if (u.includes("/suppliers")) return new Response(JSON.stringify(suppliers ? suppliers() : SUPPLIERS), { status: 200 });
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
return { fetchFn, messageCalls, endCalls };
|
||||
}
|
||||
|
||||
/** Insert a demo-flagged key directly (public signup can't mint one) and stub
|
||||
* the SDK's chain-touching startChat on the pre-warmed per-key context. */
|
||||
function setupDemoKey(deps: GatewayDeps) {
|
||||
const rawKey = `vk_test_${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: "shared-demo",
|
||||
wallet_pkh: walletKey.pubKeyHash,
|
||||
deposit_address: walletKey.address,
|
||||
enc_priv_nonce: sealed.nonce,
|
||||
enc_priv_ct: sealed.ct,
|
||||
enc_priv_tag: sealed.tag,
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
demo: 1,
|
||||
});
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const startChat = vi.fn(async () => ({
|
||||
escrowRef: { txHash: randomBytes(32).toString("hex"), index: 0 },
|
||||
sessionNonce: "nonce",
|
||||
supplierBaseUrl: "http://sup",
|
||||
}));
|
||||
(ctx.sdk as { startChat: unknown }).startChat = startChat;
|
||||
return { rawKey, keyRow, startChat };
|
||||
}
|
||||
|
||||
describe("gateway demo key", () => {
|
||||
it("runs a session-backed completion with tools and reuses the session via affinity", async () => {
|
||||
const { fetchFn, messageCalls } = demoFetch([
|
||||
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
|
||||
() => supplierSse([{ type: "token", value: "It is 3pm" }, doneFrame({ role: "assistant", content: "It is 3pm" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
// Turn 1: model asks for a tool call.
|
||||
const history: Array<Record<string, unknown>> = [{ role: "user", content: "what time is it?" }];
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: history, tools: TOOLS });
|
||||
expect(res1.status).toBe(200);
|
||||
expect(res1.body.choices[0].message.tool_calls).toEqual([TOOL_CALL]);
|
||||
expect(res1.body.choices[0].finish_reason).toBe("tool_calls");
|
||||
expect(startChat).toHaveBeenCalledTimes(1);
|
||||
// First turn replays the full history and forwards tools.
|
||||
expect((messageCalls[0].messages as unknown[]).length).toBe(1);
|
||||
expect(messageCalls[0].tools).toEqual(TOOLS);
|
||||
|
||||
// Turn 2: client echoes the assistant tool_calls + tool result.
|
||||
history.push({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
|
||||
history.push({ role: "tool", content: "3pm", tool_call_id: "call_1" });
|
||||
const res2 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: history, tools: TOOLS });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.choices[0].message.content).toBe("It is 3pm");
|
||||
expect(res2.body.choices[0].finish_reason).toBe("stop");
|
||||
// Affinity hit: no new session, only the unseen delta is forwarded.
|
||||
expect(startChat).toHaveBeenCalledTimes(1);
|
||||
const delta = messageCalls[1].messages as Array<{ role: string }>;
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].role).toBe("tool");
|
||||
});
|
||||
|
||||
it("streams tool_calls chunks with finish_reason tool_calls", async () => {
|
||||
const { fetchFn } = demoFetch([
|
||||
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey } = setupDemoKey(deps);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "time?" }], tools: TOOLS, stream: true });
|
||||
expect(res.status).toBe(200);
|
||||
const chunks = res.text
|
||||
.split("\n\n")
|
||||
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
|
||||
.map((l) => JSON.parse(l.slice(6)));
|
||||
const toolChunk = chunks.find((c) => c.choices?.[0]?.delta?.tool_calls);
|
||||
expect(toolChunk.choices[0].delta.tool_calls[0].function.name).toBe("get_time");
|
||||
expect(chunks.at(-1).choices[0].finish_reason).toBe("tool_calls");
|
||||
expect(res.text).toContain("data: [DONE]");
|
||||
});
|
||||
|
||||
it("transparently reopens when the supplier lost the session (idle-close)", async () => {
|
||||
const { fetchFn, messageCalls } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||
() => new Response("not found", { status: 404 }),
|
||||
() => supplierSse([{ type: "token", value: "Sure" }, doneFrame({ role: "assistant", content: "Sure" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Extended history matches the session, but the supplier 404s the turn →
|
||||
// the executor closes the session and reopens, replaying full history.
|
||||
const history = [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Hi" },
|
||||
{ role: "user", content: "more please" },
|
||||
];
|
||||
const res2 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: history });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.choices[0].message.content).toBe("Sure");
|
||||
expect(startChat).toHaveBeenCalledTimes(2);
|
||||
expect((messageCalls[2].messages as unknown[]).length).toBe(3);
|
||||
// The dead session's fee was recorded for spend audit.
|
||||
const usage = deps.store.listUsage(deps.store.listAllKeys().find((k) => k.demo === 1)!.id, 10);
|
||||
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "1000000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects histories with nothing respondable before any chain work", async () => {
|
||||
const deps = makeDeps(undefined, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "assistant", content: "hello" }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe("invalid_messages");
|
||||
expect(startChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers a trailing-assistant history by trimming to the last user message (regenerate)", async () => {
|
||||
const { fetchFn, messageCalls } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "Again" }, doneFrame({ role: "assistant", content: "Again" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({
|
||||
model: "kimi",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "partial answer" },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.choices[0].message.content).toBe("Again");
|
||||
expect(startChat).toHaveBeenCalledTimes(1);
|
||||
// The trailing assistant message is dropped from the replay.
|
||||
expect((messageCalls[0].messages as Array<{ role: string }>).map((m) => m.role)).toEqual(["user"]);
|
||||
});
|
||||
|
||||
it("keeps the session when the client reshapes the assistant echo (lenient affinity)", async () => {
|
||||
const { fetchFn, messageCalls } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "<think>hm</think>Hi there" }, doneFrame({ role: "assistant", content: "<think>hm</think>Hi there" }, "stop")]),
|
||||
() => supplierSse([{ type: "token", value: "Sure" }, doneFrame({ role: "assistant", content: "Sure" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Client echoes the assistant reply with the think block stripped and
|
||||
// whitespace collapsed — still the same session, delta only.
|
||||
const res2 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({
|
||||
model: "kimi",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
{ role: "user", content: "more" },
|
||||
],
|
||||
});
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.choices[0].message.content).toBe("Sure");
|
||||
expect(startChat).toHaveBeenCalledTimes(1);
|
||||
expect((messageCalls[1].messages as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts the LRU demo session when every supplier is pinned", async () => {
|
||||
const suppliers = [{ ...SUPPLIERS[1] }]; // single chat supplier, mutable status
|
||||
const { fetchFn, messageCalls, endCalls } = demoFetch(
|
||||
[
|
||||
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||
() => supplierSse([{ type: "token", value: "New" }, doneFrame({ role: "assistant", content: "New" }, "stop")]),
|
||||
],
|
||||
() => suppliers,
|
||||
);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// The lone supplier is now pinned by session 1 (indexer sees it working).
|
||||
suppliers[0] = { ...suppliers[0], status: "working" };
|
||||
|
||||
// Unrelated history → affinity miss → no free supplier → evict session 1
|
||||
// (supplier /v1/chat/end) and retry, bypassing the stale indexer status.
|
||||
const res2 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "different topic" }] });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.choices[0].message.content).toBe("New");
|
||||
expect(startChat).toHaveBeenCalledTimes(2);
|
||||
expect(endCalls).toHaveLength(1);
|
||||
expect((messageCalls[1].messages as unknown[]).length).toBe(1);
|
||||
// Evicted session closed + billed.
|
||||
const keyId = deps.store.listAllKeys().find((k) => k.demo === 1)!.id;
|
||||
const usage = deps.store.listUsage(keyId, 10);
|
||||
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "1000000")).toBe(true);
|
||||
expect(deps.store.listOpenSessionsByKey(keyId)).toHaveLength(1); // only session 2
|
||||
});
|
||||
|
||||
it("surfaces the original no-supplier error when there is nothing to evict", async () => {
|
||||
const suppliers = [{ ...SUPPLIERS[1], status: "working" }];
|
||||
const { fetchFn, endCalls } = demoFetch([() => new Response("unused", { status: 500 })], () => suppliers);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe("model_not_found");
|
||||
expect(startChat).not.toHaveBeenCalled();
|
||||
expect(endCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ticket mode: session close settles nothing and bills zero", async () => {
|
||||
const { fetchFn } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain, chatSettleMode: "ticket" });
|
||||
const { rawKey, keyRow } = setupDemoKey(deps);
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const endChat = vi.fn(async (opts: { escrowRef: unknown }) => ({
|
||||
settleMode: "ticket" as const,
|
||||
escrowRef: opts.escrowRef,
|
||||
}));
|
||||
(ctx.sdk as { endChat: unknown }).endChat = endChat;
|
||||
const app = createApp(deps);
|
||||
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
const session = deps.store.listOpenSessionsByKey(keyRow.id)[0];
|
||||
const close = await request(app)
|
||||
.post(`/openai/v1/chat/sessions/${session.id}/close`)
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({});
|
||||
expect(close.status).toBe(200);
|
||||
expect(close.body.settle_mode).toBe("ticket");
|
||||
expect(close.body.accepted_ref).toBeUndefined();
|
||||
expect(endChat).toHaveBeenCalledTimes(1);
|
||||
|
||||
const usage = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "0")).toBe(true);
|
||||
});
|
||||
|
||||
it("ticket mode: janitor-closed demo sessions bill zero", async () => {
|
||||
const { fetchFn, endCalls } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain, chatSettleMode: "ticket" });
|
||||
const { rawKey, keyRow } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
await sweepIdleDemoSessions(deps, Date.now() + deps.config.demoSessionIdleMs + 1);
|
||||
expect(endCalls.length).toBeGreaterThan(0);
|
||||
const usage = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "0")).toBe(true);
|
||||
});
|
||||
|
||||
it("janitor closes idle demo sessions so the next request opens fresh", async () => {
|
||||
const { fetchFn, messageCalls, endCalls } = demoFetch([
|
||||
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||
() => supplierSse([{ type: "token", value: "Fresh" }, doneFrame({ role: "assistant", content: "Fresh" }, "stop")]),
|
||||
]);
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const { rawKey, startChat } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const res1 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Pretend demoSessionIdleMs elapsed: the sweeper tick closes the session.
|
||||
await sweepIdleDemoSessions(deps, Date.now() + deps.config.demoSessionIdleMs + 1);
|
||||
expect(endCalls).toHaveLength(1);
|
||||
const keyId = deps.store.listAllKeys().find((k) => k.demo === 1)!.id;
|
||||
expect(deps.store.listOpenSessionsByKey(keyId)).toHaveLength(0);
|
||||
|
||||
// Continuing the thread now misses affinity and reopens with full replay.
|
||||
const res2 = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({
|
||||
model: "kimi",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Hi" },
|
||||
{ role: "user", content: "more" },
|
||||
],
|
||||
});
|
||||
expect(res2.status).toBe(200);
|
||||
expect(startChat).toHaveBeenCalledTimes(2);
|
||||
expect((messageCalls[1].messages as unknown[]).length).toBe(3);
|
||||
});
|
||||
|
||||
it("blocks withdraw and hides the deposit address for demo keys", async () => {
|
||||
const deps = makeDeps();
|
||||
const { rawKey } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const wd = await request(app)
|
||||
.post("/account/withdraw")
|
||||
.set("authorization", `Bearer ${rawKey}`)
|
||||
.send({ to_address: "addr_test1abc" });
|
||||
expect(wd.status).toBe(403);
|
||||
expect(wd.body.error.code).toBe("demo_key_restricted");
|
||||
|
||||
const acct = await request(app).get("/account").set("authorization", `Bearer ${rawKey}`);
|
||||
expect(acct.status).toBe(200);
|
||||
expect(acct.body.deposit_address).toBeNull();
|
||||
expect(acct.body.demo).toBe(true);
|
||||
});
|
||||
|
||||
it("lists only chat-capable models for demo keys", async () => {
|
||||
const fetchFn = (async (url: unknown) =>
|
||||
new Response(JSON.stringify(String(url).includes("/suppliers") ? SUPPLIERS.filter((s) => !String(url).includes("capability_id") || s.capability_id === "llm.chat.v1") : []), { status: 200 })) as unknown as typeof globalThis.fetch;
|
||||
const deps = makeDeps(fetchFn);
|
||||
const { rawKey } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
const demoModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${rawKey}`);
|
||||
expect(demoModels.body.data.map((m: { id: string }) => m.id)).toEqual(["kimi"]);
|
||||
|
||||
const signup = await request(app).post("/signup").send({});
|
||||
const normalModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${signup.body.api_key as string}`);
|
||||
expect(normalModels.body.data.map((m: { id: string }) => m.id).sort()).toEqual(["kimi", "qwen"]);
|
||||
});
|
||||
|
||||
it("rate-limits demo requests per IP (and not per key)", async () => {
|
||||
const deps = makeDeps(undefined, { demoIpRate: { max: 1, windowMs: 60_000 } });
|
||||
const { rawKey } = setupDemoKey(deps);
|
||||
const app = createApp(deps);
|
||||
|
||||
// Model with no chat suppliers → cheap 404 AFTER passing the limiter.
|
||||
const body = { model: "nope", messages: [{ role: "user", content: "hi" }] };
|
||||
const first = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
|
||||
expect(first.status).toBe(404);
|
||||
const second = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
|
||||
expect(second.status).toBe(429);
|
||||
|
||||
// Non-demo keys are untouched by the demo IP limiter.
|
||||
const signup = await request(app).post("/signup").send({});
|
||||
const normal = await request(app)
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("authorization", `Bearer ${signup.body.api_key as string}`)
|
||||
.send(body);
|
||||
expect(normal.status).not.toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
79
gateway/test/migration.test.ts
Normal file
79
gateway/test/migration.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* migration.test.ts — the demo-column migration on pre-existing databases.
|
||||
*
|
||||
* GatewayStore has no migration framework (pure CREATE TABLE IF NOT EXISTS),
|
||||
* so opening a DB created before the `demo` column must ALTER it in place.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { mkdirSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import Database from "better-sqlite3";
|
||||
import { GatewayStore } from "../src/db/store.js";
|
||||
|
||||
const LEGACY_API_KEYS_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
label TEXT,
|
||||
wallet_pkh TEXT NOT NULL,
|
||||
deposit_address TEXT NOT NULL,
|
||||
enc_priv_nonce TEXT NOT NULL,
|
||||
enc_priv_ct TEXT NOT NULL,
|
||||
enc_priv_tag TEXT NOT NULL,
|
||||
master_key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
function keyRow(id: string) {
|
||||
return {
|
||||
id,
|
||||
key_hash: `hash-${id}`,
|
||||
key_prefix: "vk_test_xxxx",
|
||||
label: null,
|
||||
wallet_pkh: "pkh",
|
||||
deposit_address: "addr_test1x",
|
||||
enc_priv_nonce: "n",
|
||||
enc_priv_ct: "c",
|
||||
enc_priv_tag: "t",
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("GatewayStore demo-column migration", () => {
|
||||
it("adds the demo column to a legacy DB and defaults existing keys to 0", () => {
|
||||
const dir = join(tmpdir(), `gw-mig-${randomUUID()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const raw = new Database(join(dir, "gateway.db"));
|
||||
raw.exec(LEGACY_API_KEYS_SQL);
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO api_keys (id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
|
||||
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled)
|
||||
VALUES (@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
|
||||
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0)`,
|
||||
)
|
||||
.run(keyRow("legacy"));
|
||||
raw.close();
|
||||
|
||||
const store = new GatewayStore(dir);
|
||||
const legacy = store.getKeyByHash("hash-legacy");
|
||||
expect(legacy?.demo).toBe(0);
|
||||
|
||||
store.insertKey({ ...keyRow("demo-key"), demo: 1 });
|
||||
expect(store.getKeyByHash("hash-demo-key")?.demo).toBe(1);
|
||||
});
|
||||
|
||||
it("persists the demo flag on fresh databases (default 0)", () => {
|
||||
const dir = join(tmpdir(), `gw-fresh-${randomUUID()}`);
|
||||
const store = new GatewayStore(dir);
|
||||
store.insertKey(keyRow("plain"));
|
||||
expect(store.getKeyByHash("hash-plain")?.demo).toBe(0);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
187
gateway/test/sweeper.test.ts
Normal file
187
gateway/test/sweeper.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* sweeper.test.ts — reclaim/close decoupling + settled-refs memory.
|
||||
*
|
||||
* Ticket mode: reclaiming a live session's Open escrow must NOT close the
|
||||
* session row or drop its transcript mirror (the conversation continues on a
|
||||
* spent ticket). Full mode keeps close-on-reclaim. Claimed rows close even
|
||||
* under ticket config (they can only come from full-mode suppliers). The
|
||||
* settled-refs memory prevents re-attempting refs already settled, and the
|
||||
* deliver_by gate skips reclaims the validator would reject.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
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 { runSweepOnce } from "../src/sweeper.js";
|
||||
import { transcripts } from "../src/openai/transcripts.js";
|
||||
import type { GatewayConfig } from "../src/config.js";
|
||||
import type { GatewayDeps } from "../src/deps.js";
|
||||
import type { EscrowRow } from "../src/onchain/settle.js";
|
||||
|
||||
const MASTER = "ab".repeat(32);
|
||||
|
||||
function makeSweepDeps(opts: { chatSettleMode: "full" | "ticket"; escrows: EscrowRow[] }) {
|
||||
const dbDir = join(tmpdir(), `gw-sweep-${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: 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"];
|
||||
const registry = new SdkRegistry({
|
||||
chain,
|
||||
indexerUrl: config.indexerUrl,
|
||||
networkId: config.networkId,
|
||||
masterKeyHex: config.masterKeyHex,
|
||||
max: config.sdkRegistryMax,
|
||||
});
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
if (String(url).includes("/escrows")) return new Response(JSON.stringify(opts.escrows), { status: 200 });
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const deps: GatewayDeps = { config, store, chain, registry, fetchFn };
|
||||
|
||||
const rawKey = `vk_test_${randomBytes(24).toString("hex")}`;
|
||||
const privHex = genPrivKeyHex();
|
||||
const walletKey = deriveWalletKey(privHex, 0);
|
||||
const sealed = seal(privHex, MASTER);
|
||||
store.insertKey({
|
||||
id: randomUUID(),
|
||||
key_hash: hashApiKey(rawKey),
|
||||
key_prefix: rawKey.slice(0, 12),
|
||||
label: "sweep-test",
|
||||
wallet_pkh: walletKey.pubKeyHash,
|
||||
deposit_address: walletKey.address,
|
||||
enc_priv_nonce: sealed.nonce,
|
||||
enc_priv_ct: sealed.ct,
|
||||
enc_priv_tag: sealed.tag,
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
demo: 1,
|
||||
});
|
||||
const keyRow = store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
return { deps, keyRow };
|
||||
}
|
||||
|
||||
function insertOpenSession(deps: GatewayDeps, keyId: string, escrowRef: string): string {
|
||||
const id = randomUUID();
|
||||
deps.store.insertSession({
|
||||
id,
|
||||
key_id: keyId,
|
||||
escrow_ref: escrowRef,
|
||||
session_nonce: "nonce",
|
||||
supplier_base_url: "http://sup",
|
||||
supplier_pkh: "s1",
|
||||
model: "kimi",
|
||||
price_lovelace: "200000",
|
||||
state: "open",
|
||||
opened_at: Date.now() - 40 * 60 * 1000,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function openRow(refChar: string, opts?: Partial<EscrowRow>): EscrowRow {
|
||||
return {
|
||||
utxo_ref: `${refChar.repeat(64)}#0`,
|
||||
state: "Open",
|
||||
posted_at: Date.now() - 40 * 60 * 1000,
|
||||
deliver_by: Date.now() - 5 * 60 * 1000,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
const settleOk = () => ({
|
||||
accept: vi.fn(async () => undefined) as never,
|
||||
reclaim: vi.fn(async () => undefined) as never,
|
||||
});
|
||||
|
||||
describe("sweeper — ticket-mode reclaim/close decoupling", () => {
|
||||
it("reclaims a live session's Open ticket but keeps the session (and mirror) alive", async () => {
|
||||
const row = openRow("1");
|
||||
const { deps, keyRow } = makeSweepDeps({ chatSettleMode: "ticket", escrows: [row] });
|
||||
const sessionId = insertOpenSession(deps, keyRow.id, row.utxo_ref);
|
||||
transcripts.set(sessionId, [{ role: "user", content: "hi" }]);
|
||||
const settle = settleOk();
|
||||
|
||||
await runSweepOnce(deps, settle);
|
||||
|
||||
expect(settle.reclaim).toHaveBeenCalledTimes(1);
|
||||
expect(deps.store.getSession(sessionId)!.state).toBe("open"); // NOT closed
|
||||
expect(transcripts.get(sessionId)).toBeDefined(); // mirror intact
|
||||
expect(deps.store.listUsage(keyRow.id, 10)).toHaveLength(0); // nothing billed
|
||||
|
||||
transcripts.delete(sessionId);
|
||||
});
|
||||
|
||||
it("full mode: reclaim closes the session row", async () => {
|
||||
const row = openRow("2");
|
||||
const { deps, keyRow } = makeSweepDeps({ chatSettleMode: "full", escrows: [row] });
|
||||
const sessionId = insertOpenSession(deps, keyRow.id, row.utxo_ref);
|
||||
const settle = settleOk();
|
||||
|
||||
await runSweepOnce(deps, settle);
|
||||
|
||||
expect(settle.reclaim).toHaveBeenCalledTimes(1);
|
||||
expect(deps.store.getSession(sessionId)!.state).toBe("closed");
|
||||
const usage = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(usage.some((u) => u.status === "reclaimed")).toBe(true);
|
||||
});
|
||||
|
||||
it("ticket config still closes sessions behind Claimed rows (full-mode suppliers)", async () => {
|
||||
const row = openRow("3", { state: "Claimed" });
|
||||
const { deps, keyRow } = makeSweepDeps({ chatSettleMode: "ticket", escrows: [row] });
|
||||
const sessionId = insertOpenSession(deps, keyRow.id, row.utxo_ref);
|
||||
const settle = settleOk();
|
||||
|
||||
await runSweepOnce(deps, settle);
|
||||
|
||||
expect(settle.reclaim).toHaveBeenCalledTimes(1);
|
||||
expect(deps.store.getSession(sessionId)!.state).toBe("closed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweeper — reclaim gating + settled-refs memory", () => {
|
||||
it("skips reclaim while deliver_by has not passed", async () => {
|
||||
const row = openRow("4", { deliver_by: Date.now() + 10 * 60 * 1000 });
|
||||
const { deps, keyRow } = makeSweepDeps({ chatSettleMode: "ticket", escrows: [row] });
|
||||
insertOpenSession(deps, keyRow.id, row.utxo_ref);
|
||||
const settle = settleOk();
|
||||
|
||||
await runSweepOnce(deps, settle);
|
||||
|
||||
expect(settle.reclaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-attempt refs it already settled (in-process memory)", async () => {
|
||||
const row = openRow("5");
|
||||
const { deps, keyRow } = makeSweepDeps({ chatSettleMode: "ticket", escrows: [row] });
|
||||
insertOpenSession(deps, keyRow.id, row.utxo_ref);
|
||||
const settle = settleOk();
|
||||
|
||||
await runSweepOnce(deps, settle);
|
||||
await runSweepOnce(deps, settle);
|
||||
|
||||
expect(settle.reclaim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -84,10 +84,31 @@ describe("routing/selectSupplier", () => {
|
||||
expect(await listModels({ indexerUrl: "http://ix", fetchFn: jsonResponse(rows) })).toEqual(["m", "other"]);
|
||||
});
|
||||
|
||||
it("lists only one capability's models when capabilityId is given", async () => {
|
||||
expect(await listModels({ indexerUrl: "http://ix", capabilityId: "llm.chat.v1", fetchFn: jsonResponse(rows) })).toEqual(["m"]);
|
||||
});
|
||||
|
||||
it("parseRef", () => {
|
||||
expect(parseRef("aa".repeat(32) + "#3")).toEqual({ txHash: "aa".repeat(32), index: 3 });
|
||||
expect(parseRef("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignoreStatusFor readmits a busy supplier by pkh (stale indexer status)", async () => {
|
||||
const busy = [
|
||||
{ ...rows[0], status: "working" },
|
||||
{ ...rows[1], status: "offline" },
|
||||
];
|
||||
const base = { indexerUrl: "http://ix", model: "m", capabilityId: "llm.text.generate.v1" };
|
||||
const excluded = await selectCandidates({ ...base, fetchFn: jsonResponse(busy) });
|
||||
expect(excluded).toEqual([]);
|
||||
|
||||
const readmitted = await selectCandidates({
|
||||
...base,
|
||||
fetchFn: jsonResponse(busy),
|
||||
ignoreStatusFor: new Set(["s1"]),
|
||||
});
|
||||
expect(readmitted.map((c) => c.supplierPkh)).toEqual(["s1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai/validate", () => {
|
||||
@@ -104,6 +125,37 @@ describe("openai/validate", () => {
|
||||
expect(() => parseChatRequest({ model: "m", messages: [] })).toThrow();
|
||||
expect(() => parseChatRequest({ messages: [{ role: "user", content: "x" }] })).toThrow();
|
||||
});
|
||||
|
||||
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||
|
||||
it("allowTools accepts tools, tool role, null content and assistant tool_calls", () => {
|
||||
const p = parseChatRequest(
|
||||
{
|
||||
model: "m",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: null, tool_calls: [TOOL_CALL] },
|
||||
{ role: "tool", content: "3pm", tool_call_id: "call_1" },
|
||||
],
|
||||
tools: [{ type: "function", function: { name: "get_time", parameters: {} } }],
|
||||
tool_choice: "auto",
|
||||
},
|
||||
{ allowTools: true },
|
||||
);
|
||||
expect(p.tools).toHaveLength(1);
|
||||
expect(p.toolChoice).toBe("auto");
|
||||
expect(p.messages[1]).toEqual({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
|
||||
expect(p.messages[2]).toEqual({ role: "tool", content: "3pm", tool_call_id: "call_1" });
|
||||
});
|
||||
|
||||
it("allowTools still rejects legacy functions and the default path still rejects tool role", () => {
|
||||
expect(() =>
|
||||
parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], functions: [] }, { allowTools: true }),
|
||||
).toThrow(GatewayError);
|
||||
expect(() =>
|
||||
parseChatRequest({ model: "m", messages: [{ role: "tool", content: "x", tool_call_id: "c" }] }),
|
||||
).toThrow(GatewayError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai/shapes", () => {
|
||||
@@ -121,6 +173,24 @@ describe("openai/shapes", () => {
|
||||
expect(ch.object).toBe("chat.completion.chunk");
|
||||
expect((ch.choices as any)[0].delta.content).toBe("tok");
|
||||
});
|
||||
it("tool_calls chunk + finish_reason tool_calls", () => {
|
||||
const tc = { index: 0, id: "call_1", type: "function" as const, function: { name: "f", arguments: "{}" } };
|
||||
const ch = buildChunk({ id: "id", model: "m", delta: { tool_calls: [tc] }, finishReason: "tool_calls" });
|
||||
expect((ch.choices as any)[0].delta.tool_calls).toEqual([tc]);
|
||||
expect((ch.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
it("completion with tool_calls and finish_reason", () => {
|
||||
const cc = buildChatCompletion({
|
||||
id: "id",
|
||||
model: "m",
|
||||
content: "",
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
toolCalls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }],
|
||||
finishReason: "tool_calls",
|
||||
});
|
||||
expect((cc.choices as any)[0].message.tool_calls).toHaveLength(1);
|
||||
expect((cc.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
it("renderMessages folds roles", () => {
|
||||
expect(renderMessages([{ role: "system", content: "be nice" }, { role: "user", content: "hi" }])).toBe("System: be nice\n\nUser: hi");
|
||||
});
|
||||
@@ -138,8 +208,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);
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"happy-dom": "^15.7.4",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.4.3"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@marketplace/shared": "workspace:*",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
|
||||
@@ -29,13 +29,18 @@
|
||||
"cbor-x": "^1.5.4",
|
||||
"eventsource": "^2.0.2",
|
||||
"express": "^4.21.2",
|
||||
"happy-dom": "^20.8.9",
|
||||
"happy-dom": "^15.7.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.2.6"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
packages/shared/src/tx/chatMessage.ts
Normal file
88
packages/shared/src/tx/chatMessage.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* tx/chatMessage.ts — canonical ChatMessage normalization + equivalence.
|
||||
*
|
||||
* The supplier's session transcript and the gateway's in-memory mirror must be
|
||||
* hash-identical (receipt response_hash = sha256(canonicalize(transcript))),
|
||||
* so both sides build messages through normalizeChatMessage: optional fields
|
||||
* are truly absent when empty, content is never null, and vendor extras are
|
||||
* stripped. chatMessagesEquivalent is the tolerant comparison used by the
|
||||
* demo-endpoint transcript-prefix affinity matcher.
|
||||
*/
|
||||
|
||||
import type { ChatMessage, ToolCall } from "./types.js";
|
||||
|
||||
const ROLES = new Set(["system", "user", "assistant", "tool"]);
|
||||
|
||||
function normalizeToolCall(raw: unknown): ToolCall | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const tc = raw as Record<string, unknown>;
|
||||
const fn = tc.function as Record<string, unknown> | undefined;
|
||||
if (typeof tc.id !== "string" || tc.id === "") return null;
|
||||
if (tc.type !== "function") return null;
|
||||
if (typeof fn !== "object" || fn === null) return null;
|
||||
if (typeof fn.name !== "string" || fn.name === "") return null;
|
||||
if (typeof fn.arguments !== "string") return null;
|
||||
return { id: tc.id, type: "function", function: { name: fn.name, arguments: fn.arguments } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and canonicalize an untrusted message-shaped value.
|
||||
* Returns null when the value is not a usable ChatMessage.
|
||||
*/
|
||||
export function normalizeChatMessage(raw: unknown): ChatMessage | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const m = raw as Record<string, unknown>;
|
||||
if (typeof m.role !== "string" || !ROLES.has(m.role)) return null;
|
||||
const role = m.role as ChatMessage["role"];
|
||||
|
||||
let content: string;
|
||||
if (typeof m.content === "string") content = m.content;
|
||||
else if (m.content === null || m.content === undefined) content = "";
|
||||
else return null;
|
||||
|
||||
const msg: ChatMessage = { role, content };
|
||||
|
||||
if (m.tool_calls !== undefined && m.tool_calls !== null) {
|
||||
if (role !== "assistant" || !Array.isArray(m.tool_calls)) return null;
|
||||
const calls: ToolCall[] = [];
|
||||
for (const raw of m.tool_calls) {
|
||||
const tc = normalizeToolCall(raw);
|
||||
if (!tc) return null;
|
||||
calls.push(tc);
|
||||
}
|
||||
if (calls.length > 0) msg.tool_calls = calls;
|
||||
}
|
||||
|
||||
if (m.tool_call_id !== undefined && m.tool_call_id !== null) {
|
||||
if (role !== "tool" || typeof m.tool_call_id !== "string" || m.tool_call_id === "") return null;
|
||||
msg.tool_call_id = m.tool_call_id;
|
||||
}
|
||||
if (role === "tool" && msg.tool_call_id === undefined) return null;
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function toolCallsEqual(a?: ToolCall[], b?: ToolCall[]): boolean {
|
||||
const la = a ?? [];
|
||||
const lb = b ?? [];
|
||||
if (la.length !== lb.length) return false;
|
||||
return la.every((tc, i) =>
|
||||
tc.id === lb[i].id &&
|
||||
tc.function.name === lb[i].function.name &&
|
||||
tc.function.arguments === lb[i].function.arguments,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant equality for affinity prefix matching. Both sides are expected to
|
||||
* already be normalized; comparison ignores incidental key order and treats
|
||||
* missing tool fields as empty.
|
||||
*/
|
||||
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
return (
|
||||
a.role === b.role &&
|
||||
a.content === b.content &&
|
||||
(a.tool_call_id ?? "") === (b.tool_call_id ?? "") &&
|
||||
toolCallsEqual(a.tool_calls, b.tool_calls)
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ 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";
|
||||
|
||||
export interface PostChatEscrowParams {
|
||||
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
|
||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||
const promptHash = chatSessionPromptHash({ session_nonce });
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
|
||||
state: "Open",
|
||||
};
|
||||
|
||||
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||
// later bump (min-ada floor).
|
||||
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||
|
||||
// Live path: reuse the capability-agnostic chat builder (messages = []).
|
||||
// The on-chain commitment is the escrowDatum (carrying our session-init
|
||||
// prompt_hash); messages are only used for the mock cbor body, which we omit.
|
||||
|
||||
@@ -38,6 +38,7 @@ 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";
|
||||
|
||||
export interface PostEscrowParams {
|
||||
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
|
||||
// supplier-side validation in M1-C can verify role/content/system integrity.
|
||||
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
|
||||
state: "Open",
|
||||
};
|
||||
|
||||
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||
// later bump (min-ada floor).
|
||||
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||
|
||||
// Live path: produce real Cardano CBOR via lucid-evolution.
|
||||
if (detectCborBackend(chain) === "live") {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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";
|
||||
|
||||
/** TTS request envelope. The buyer-side hash and the supplier-side hash
|
||||
@@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx(
|
||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||
const promptHash = ttsPromptHash(request);
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
|
||||
state: "Open",
|
||||
};
|
||||
|
||||
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||
// later bump (min-ada floor).
|
||||
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||
|
||||
// Live path: reuse the chat builder. The live cbor path is already
|
||||
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
||||
// empty array so the type checker is satisfied; the actual on-chain
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* tx/index.ts — public exports for M1-B tx builders.
|
||||
*/
|
||||
|
||||
export type { ChatMessage, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||
export { TxConstructionError } from "./types.js";
|
||||
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.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";
|
||||
@@ -36,6 +38,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";
|
||||
|
||||
@@ -358,6 +358,45 @@ function rethrowAsTxError(err: unknown, fallback: string): never {
|
||||
throw new TxConstructionError(fallback, msg);
|
||||
}
|
||||
|
||||
/** Choose PostEscrow inputs that PRESERVE a pure-ADA collateral UTxO for the
|
||||
* later Accept/Reclaim script-spend. PostEscrow is NOT a script spend, so lucid
|
||||
* is otherwise free to consume the dedicated ≥5-ADA collateral UTxO as an
|
||||
* ordinary input — leaving the wallet with no collateral candidate and
|
||||
* stranding the escrow when Accept later runs (the fragmentation bug observed
|
||||
* on back-to-back requests).
|
||||
*
|
||||
* Reserve the SMALLEST pure-ADA UTxO ≥ collateral floor and fund the escrow
|
||||
* from the remaining UTxOs. Returns null (→ default lucid coin selection) when
|
||||
* reservation isn't possible or needed:
|
||||
* - a single UTxO (nothing to reserve),
|
||||
* - no qualifying pure-ADA collateral UTxO, or
|
||||
* - excluding it would underfund the escrow. The large-single-UTxO case is
|
||||
* safe under default selection anyway: the change output is itself a
|
||||
* pure-ADA UTxO well above the collateral floor. */
|
||||
export function reserveCollateralInputs(
|
||||
utxos: LucidUTxO[],
|
||||
lockedLovelace: bigint,
|
||||
): LucidUTxO[] | null {
|
||||
if (utxos.length < 2) return null;
|
||||
const candidates = utxos
|
||||
.filter(
|
||||
(u) =>
|
||||
u.assets.lovelace >= COLLATERAL_MIN_LOVELACE &&
|
||||
Object.keys(u.assets).every((k) => k === "lovelace"),
|
||||
)
|
||||
.sort((a, b) => (a.assets.lovelace < b.assets.lovelace ? -1 : 1));
|
||||
if (candidates.length === 0) return null;
|
||||
const reserved = candidates[0];
|
||||
const remaining = utxos.filter(
|
||||
(u) => !(u.txHash === reserved.txHash && u.outputIndex === reserved.outputIndex),
|
||||
);
|
||||
if (remaining.length === 0) return null;
|
||||
const remainingLovelace = remaining.reduce((s, u) => s + u.assets.lovelace, 0n);
|
||||
// Cover the locked output + tx fee + a min-change cushion; else fall back.
|
||||
if (remainingLovelace < lockedLovelace + 2_000_000n) return null;
|
||||
return remaining;
|
||||
}
|
||||
|
||||
// ─── PostEscrow ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForEscrow(
|
||||
@@ -382,6 +421,12 @@ export async function buildLiveTxForEscrow(
|
||||
|
||||
const lockedLovelace = totalLocked < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : totalLocked;
|
||||
|
||||
// Preserve a pure-ADA collateral UTxO so the buyer's later Accept/Reclaim
|
||||
// script-spend can find collateral. Without this, PostEscrow coin selection
|
||||
// can consume the 5-ADA collateral UTxO and strand the escrow at Accept.
|
||||
const walletUtxos = await lucid.wallet().getUtxos();
|
||||
const reservedInputs = reserveCollateralInputs(walletUtxos, lockedLovelace);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
@@ -404,7 +449,12 @@ export async function buildLiveTxForEscrow(
|
||||
// becomes change. Same fix as Claim/Submit/Accept paths.
|
||||
.setMinFee(500_000n);
|
||||
|
||||
const completed = await txBuilder.complete();
|
||||
// When we can reserve collateral, restrict coin selection to the remaining
|
||||
// (working) UTxOs so the collateral candidate survives for Accept. Else
|
||||
// fall back to lucid's default selection (single/large-UTxO cases are safe).
|
||||
const completed = reservedInputs
|
||||
? await txBuilder.complete({ presetWalletInputs: reservedInputs, localUPLCEval: false })
|
||||
: await txBuilder.complete();
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "post-escrow build failed");
|
||||
|
||||
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum.
|
||||
*
|
||||
* The escrow validator requires value_equal(continuing, in) on Claim and
|
||||
* Submit, so the lovelace locked at POST time must already satisfy min-ada
|
||||
* for every datum the output will ever carry. The largest is the Submitted
|
||||
* state: submitted_at None -> Some(POSIX ms) and result_receipt_hash
|
||||
* None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted
|
||||
* lovelace sits below that state's min-ada, lucid bumps the Submit
|
||||
* continuing output to min-ada, value_equal fails, and the validator
|
||||
* errors with no traces (the 2026-08-07 mainnet incident: the 33-byte
|
||||
* "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted
|
||||
* output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat
|
||||
* capability stayed 49,310 under, which is why only OCR Submits failed).
|
||||
*
|
||||
* escrowLockFloor() returns the lovelace to lock: the economic total
|
||||
* (payment + both bonds), raised to the Submitted-state min-ada plus a
|
||||
* safety margin when the total alone is too small.
|
||||
*/
|
||||
|
||||
import type { EscrowDatum } from "../../cbor/types.js";
|
||||
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||
|
||||
/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */
|
||||
const UTXO_COST_PER_BYTE = 4310n;
|
||||
|
||||
/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */
|
||||
const OUTPUT_OVERHEAD_BYTES = 160n;
|
||||
|
||||
/** Covers CBOR framing drift between this estimate and the ledger's exact
|
||||
* serialization (address, coin width, datum wrapper). Generous on purpose:
|
||||
* 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */
|
||||
const SAFETY_MARGIN_LOVELACE = 50_000n;
|
||||
|
||||
/** Serialized-output byte estimate for an enterprise script address output
|
||||
* carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the
|
||||
* arithmetic validated against mainnet on 2026-08-07 (minada-calc). */
|
||||
function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint {
|
||||
const addr = 1n + 29n;
|
||||
const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n);
|
||||
const datumField = 1n + 2n + 3n + BigInt(datumBytes);
|
||||
const mapOverhead = 3n;
|
||||
return addr + coin + datumField + mapOverhead;
|
||||
}
|
||||
|
||||
/** Min-ada for the escrow output when it carries `datum`. */
|
||||
export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint {
|
||||
const datumBytes = encodeEscrowDatum(datum).length / 2;
|
||||
const size = estimateOutputBytes(datumBytes, lovelace);
|
||||
return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE;
|
||||
}
|
||||
|
||||
/** The lovelace a POST must lock so the escrow output satisfies min-ada in
|
||||
* every reachable state, including Submitted. `economicTotal` is
|
||||
* payment + buyer_bond + supplier_bond. */
|
||||
export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint {
|
||||
const submittedShape: EscrowDatum = {
|
||||
...openDatum,
|
||||
// Representative worst-case Submit fields: a 13-digit POSIX ms stamp
|
||||
// and a 32-byte receipt hash — sizes, not values, matter here.
|
||||
submitted_at: openDatum.deliver_by,
|
||||
result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
state: "Submitted",
|
||||
};
|
||||
const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE;
|
||||
return floor > economicTotal ? floor : economicTotal;
|
||||
}
|
||||
42
packages/shared/src/tx/internal/reserveCollateral.test.ts
Normal file
42
packages/shared/src/tx/internal/reserveCollateral.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { reserveCollateralInputs } from "./liveCbor.js";
|
||||
|
||||
// Minimal LucidUTxO-shaped fixtures (the helper only reads txHash/outputIndex/assets).
|
||||
const pure = (id: string, lovelace: bigint) =>
|
||||
({ txHash: id, outputIndex: 0, assets: { lovelace } }) as never;
|
||||
const mixed = (id: string, lovelace: bigint) =>
|
||||
({ txHash: id, outputIndex: 0, assets: { lovelace, "policy.tok": 1n } }) as never;
|
||||
|
||||
const LOCKED = 2_200_000n; // price + buyer_bond + supplier_bond
|
||||
const ids = (r: ReturnType<typeof reserveCollateralInputs>) =>
|
||||
(r ?? []).map((u: { txHash: string }) => u.txHash);
|
||||
|
||||
describe("reserveCollateralInputs", () => {
|
||||
it("reserves a 5-ADA collateral UTxO and funds from the rest", () => {
|
||||
const r = reserveCollateralInputs([pure("collat", 5_000_000n), pure("work", 6_000_000n)], LOCKED);
|
||||
expect(ids(r)).toEqual(["work"]); // collat (smallest ≥5) reserved
|
||||
});
|
||||
|
||||
it("reserves the SMALLEST qualifying candidate", () => {
|
||||
const r = reserveCollateralInputs([pure("big", 9_000_000n), pure("small", 5_000_000n)], LOCKED);
|
||||
expect(ids(r)).toEqual(["big"]); // small reserved as collateral
|
||||
});
|
||||
|
||||
it("only reserves PURE-ADA UTxOs (ignores ones carrying native assets)", () => {
|
||||
const r = reserveCollateralInputs([mixed("mixed", 10_000_000n), pure("purecol", 6_000_000n)], LOCKED);
|
||||
expect(ids(r)).toEqual(["mixed"]); // purecol reserved, fund from the mixed UTxO
|
||||
});
|
||||
|
||||
it("returns null for a single UTxO (large change stays as collateral under default selection)", () => {
|
||||
expect(reserveCollateralInputs([pure("only", 12_000_000n)], LOCKED)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no UTxO qualifies as collateral", () => {
|
||||
expect(reserveCollateralInputs([pure("a", 4_000_000n), pure("b", 4_000_000n)], LOCKED)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when reserving collateral would underfund the escrow", () => {
|
||||
// {5 collateral, 3 working}: working can't cover locked + cushion → fall back.
|
||||
expect(reserveCollateralInputs([pure("collat", 5_000_000n), pure("work", 3_000_000n)], LOCKED)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,14 +11,29 @@
|
||||
|
||||
import type { OutputReference } from "../chain/ChainProvider.js";
|
||||
|
||||
/**
|
||||
* ToolCall — OpenAI-compatible function tool call emitted by an assistant turn.
|
||||
* `function.arguments` is the raw JSON string as produced by the model.
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessage — OpenAI-compatible chat message.
|
||||
* v1 supports user/system/assistant roles. The wire shape is intentionally
|
||||
* narrow; future capabilities (tools, multimodal) will extend or replace it.
|
||||
* `content` is always a string ("" for tool-call-only assistant messages —
|
||||
* OpenAI's `content: null` is normalized on ingest). `tool_calls` /
|
||||
* `tool_call_id` MUST be omitted (not set to undefined) when absent: the
|
||||
* receipt hash canonicalizes the transcript objects, so field presence is
|
||||
* part of the identity shared between supplier transcript and gateway mirror.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
File diff suppressed because one or more lines are too long
1003
pnpm-lock.yaml
generated
1003
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,6 @@ packages:
|
||||
- "indexer"
|
||||
- "indexer-ui"
|
||||
- "wallet-monitor"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
|
||||
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).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user