Compare commits
45 Commits
feat/hetzn
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cb4b03e1 | ||
|
|
a6eabe6b98 | ||
|
|
0ed594f6ba | ||
|
|
59cac438f2 | ||
|
|
af405cf378 | ||
|
|
9463bfae85 | ||
|
|
53607738f8 | ||
|
|
cd3ea734e3 | ||
|
|
41820b11bb | ||
|
|
3393748972 | ||
|
|
799c7d8256 | ||
|
|
ced400f928 | ||
|
|
f79a33e7c3 | ||
|
|
ed6dbf5730 | ||
|
|
23ae561c16 | ||
|
|
2603820106 | ||
|
|
97ae2d8110 | ||
|
|
b714c09aca | ||
|
|
732de817aa | ||
|
|
2b8409a499 | ||
|
|
6b9f0f2284 | ||
|
|
27d23dd7a4 | ||
|
|
e54eb55f2b | ||
|
|
8baf127c77 | ||
|
|
e8261d9500 | ||
|
|
bb18578ae9 | ||
|
|
5221815a71 | ||
|
|
c531e4678f | ||
|
|
47b3c4efcd | ||
|
|
7db4de1f75 | ||
|
|
133102bc31 | ||
|
|
6fbf0d0f45 | ||
|
|
85ca58e4b2 | ||
|
|
b3b7d6b5e1 | ||
|
|
d5fdd1f1b7 | ||
|
|
019766ab8d | ||
|
|
5c9da7639d | ||
|
|
843ac7cbdd | ||
|
|
48ad639d12 | ||
|
|
4047be04a3 | ||
|
|
23157d62a3 | ||
|
|
3ee09f1a9a | ||
|
|
44abbdffdc | ||
|
|
b0c83ca985 | ||
|
|
bfcb0c46b8 |
@@ -38,10 +38,15 @@ NETWORK_ID=0
|
||||
# along over loopback. Production behind TLS should leave this unset / "1".
|
||||
# COOKIE_SECURE=1
|
||||
|
||||
# Internal, redacted data source for the authenticated /dashboard page.
|
||||
# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard
|
||||
|
||||
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
|
||||
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
|
||||
# per map + reduce call) → full-book summary. All knobs are optional; defaults
|
||||
# shown. Tune the caps to bound spend/abuse on mainnet.
|
||||
# Kill switch: "0" disables the feature entirely (every /v1/pdf-* route 503s):
|
||||
# PDF_ENABLED=1
|
||||
#
|
||||
# Hard caps (a job is refused if it would exceed these):
|
||||
# PDF_MAX_PAGES=400 # reject books larger than this
|
||||
|
||||
@@ -41,6 +41,13 @@ interface OperatorSource {
|
||||
}
|
||||
|
||||
const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
{
|
||||
label: "supplier-hf-llama33@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.hf-llama33",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
@@ -125,6 +132,34 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38-chat@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38-chat",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-local@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.local",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-openclaw@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.openclaw",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
* API key" page knows where to POST /signup. When
|
||||
* unset, the SPA falls back to deriving "api." +
|
||||
* current host.
|
||||
* SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard
|
||||
* snapshot endpoint. When unset, /v1/resale-dashboard
|
||||
* responds 503 while the rest of the buyer stays live.
|
||||
* PDF_ENABLED — "1" (default) serves the PDF book summarizer
|
||||
* (/v1/pdf-* routes). Set "0" to disable the feature
|
||||
* entirely: no PDF job store is opened and every
|
||||
* /v1/pdf-* route responds 503.
|
||||
*/
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -62,7 +69,9 @@ export interface BuyerConfig {
|
||||
password: string;
|
||||
sessionSecret: string;
|
||||
cookieSecure: boolean;
|
||||
pdfEnabled: boolean;
|
||||
gatewayPublicUrl: string;
|
||||
resaleDashboardUrl: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -148,10 +157,36 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
cookieSecure = cookieSecureStr === "1";
|
||||
}
|
||||
|
||||
// PDF_ENABLED: defaults to true. Set "0" to disable the PDF book summarizer
|
||||
// entirely — runMain skips the JobStore wiring and every /v1/pdf-* route
|
||||
// responds 503. Accept only "0" / "1" to keep boot-time misconfig loud.
|
||||
let pdfEnabled = true;
|
||||
const pdfEnabledStr = env.PDF_ENABLED;
|
||||
if (pdfEnabledStr !== undefined && pdfEnabledStr !== "") {
|
||||
if (pdfEnabledStr !== "0" && pdfEnabledStr !== "1") {
|
||||
throw new Error('loadConfig: PDF_ENABLED must be "0" or "1"');
|
||||
}
|
||||
pdfEnabled = pdfEnabledStr === "1";
|
||||
}
|
||||
|
||||
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
|
||||
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||
// derives the URL from its own host ("api." + location.host).
|
||||
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||
const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (resaleDashboardUrl !== "") {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(resaleDashboardUrl);
|
||||
} catch {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
@@ -167,6 +202,8 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
password,
|
||||
sessionSecret,
|
||||
cookieSecure,
|
||||
pdfEnabled,
|
||||
gatewayPublicUrl,
|
||||
resaleDashboardUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,29 +96,36 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
|
||||
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
|
||||
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
|
||||
// PDF_ENABLED=0 skips the whole wiring; createApp then registers every
|
||||
// /v1/pdf-* route as a 503 stub (feature disabled).
|
||||
const pdfCaps = loadPdfCaps(env);
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
let jobStore: JobStore | undefined;
|
||||
if (config.pdfEnabled) {
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
} else {
|
||||
console.log("[buyer] pdf summarizer disabled (PDF_ENABLED=0)");
|
||||
}
|
||||
const jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
distPath,
|
||||
@@ -134,6 +141,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||
resaleDashboardUrl: config.resaleDashboardUrl,
|
||||
jobStore,
|
||||
pdfCaps,
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export function registerPdfRoutes(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"PDF summarizer disabled (buyer booted without chain/marketplace/archive deps)",
|
||||
"PDF summarizer disabled",
|
||||
);
|
||||
app.post("/v1/pdf-upload", disabled);
|
||||
app.all(/^\/v1\/pdf-jobs(\/.*)?$/, disabled);
|
||||
|
||||
@@ -80,6 +80,8 @@ import {
|
||||
buildAcceptTx,
|
||||
buildReclaimTx,
|
||||
TxConstructionError,
|
||||
chatInputTokenUpperBound,
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
||||
@@ -88,6 +90,7 @@ import type {
|
||||
DiscoverSuppliersOptions,
|
||||
SubmitPromptOptions,
|
||||
SubmitPromptResult,
|
||||
SupplierCapabilityView,
|
||||
SubmitTtsOptions,
|
||||
SubmitTtsResult,
|
||||
SubmitOcrOptions,
|
||||
@@ -110,6 +113,50 @@ import { MemoryTaskHistoryStore } from "./history.js";
|
||||
import { HttpClient, HttpError } from "./httpClient.js";
|
||||
|
||||
const ZERO_SIGNATURE = "0".repeat(128);
|
||||
|
||||
function validateSupplierCapability(
|
||||
body: unknown,
|
||||
advert: AdvertDatum,
|
||||
advertRef: OutputReference,
|
||||
): SupplierCapabilityView {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response must be an object",
|
||||
);
|
||||
}
|
||||
const capability = body as Record<string, unknown>;
|
||||
const expectedRef = `${advertRef.txHash}#${advertRef.index}`;
|
||||
if (
|
||||
capability.advert_ref !== expectedRef ||
|
||||
capability.capability_id !== advert.capability_id ||
|
||||
capability.model !== advert.model ||
|
||||
capability.max_output_tokens !== advert.max_output_tokens ||
|
||||
capability.max_processing_ms !== advert.max_processing_ms ||
|
||||
capability.price_lovelace !== advert.price_lovelace.toString() ||
|
||||
capability.supplier_pkh !== advert.supplier_pkh ||
|
||||
typeof capability.pub_key_hex !== "string"
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response does not match the on-chain advert",
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.max_input_tokens !== undefined &&
|
||||
(
|
||||
typeof capability.max_input_tokens !== "number" ||
|
||||
!Number.isSafeInteger(capability.max_input_tokens) ||
|
||||
capability.max_input_tokens <= 0
|
||||
)
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier max_input_tokens must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
return capability as unknown as SupplierCapabilityView;
|
||||
}
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const SIG_RE = /^[0-9a-fA-F]{128}$/;
|
||||
|
||||
@@ -241,16 +288,23 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
return result.body as SupplierView[];
|
||||
}
|
||||
|
||||
// ─── submitPrompt — happy path + every adversarial branch ──────────────
|
||||
|
||||
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
|
||||
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
|
||||
const {
|
||||
advertRef,
|
||||
messages,
|
||||
payment_lovelace,
|
||||
max_output_tokens,
|
||||
public_preview,
|
||||
} = opts;
|
||||
|
||||
let supplierHttp: HttpClient | null = null;
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
let deliverByMs = 0;
|
||||
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
@@ -279,6 +333,46 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
}
|
||||
|
||||
if (advertDatum) {
|
||||
supplierHttp = new HttpClient({
|
||||
baseUrl: advertDatum.endpoint_url,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
if (advertDatum.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
|
||||
let capabilityResult;
|
||||
try {
|
||||
capabilityResult = await supplierHttp.getJson("/capability");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new TxConstructionError("supplier_preflight_failed", message);
|
||||
}
|
||||
if (!capabilityResult.ok || capabilityResult.parseError) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
`supplier /capability returned HTTP ${capabilityResult.status}`,
|
||||
);
|
||||
}
|
||||
const capability = validateSupplierCapability(
|
||||
capabilityResult.body,
|
||||
advertDatum,
|
||||
advertRef,
|
||||
);
|
||||
if (capability.max_input_tokens === undefined) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"bounded-input supplier omitted max_input_tokens",
|
||||
);
|
||||
}
|
||||
const inputUnits = chatInputTokenUpperBound(messages);
|
||||
if (inputUnits > capability.max_input_tokens) {
|
||||
throw new TxConstructionError(
|
||||
"input_cap_exceeded",
|
||||
`input bound ${inputUnits} exceeds supplier cap ${capability.max_input_tokens}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escrowResult = await buildPostEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
@@ -321,24 +415,33 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
deliverByMs = ed.deliver_by;
|
||||
}
|
||||
} catch {
|
||||
/* posted_at is best-effort metadata */
|
||||
}
|
||||
|
||||
if (!advertDatum) {
|
||||
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
|
||||
// Should never happen — buildPostEscrowTx rejects missing or bad adverts.
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (!supplierHttp) {
|
||||
const reason = "supplier client unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (deliverByMs === 0) {
|
||||
deliverByMs =
|
||||
postedAtMs + advertDatum.max_processing_ms + 30_000;
|
||||
}
|
||||
const supplierCallTimeoutMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs - Date.now() + 90_000,
|
||||
);
|
||||
|
||||
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
|
||||
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
|
||||
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
|
||||
@@ -347,11 +450,14 @@ export class Marketplace extends EventEmitterBase {
|
||||
messages,
|
||||
max_tokens: cappedMax,
|
||||
};
|
||||
|
||||
let chatResult;
|
||||
try {
|
||||
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
headers: {
|
||||
"X-Escrow-Ref": escrowRefStr,
|
||||
...(public_preview ? { "X-Vector-Public-Preview": "1" } : {}),
|
||||
},
|
||||
timeoutMs: supplierCallTimeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
@@ -385,8 +491,11 @@ export class Marketplace extends EventEmitterBase {
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
const pollBudgetMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs + 90_000 - Date.now(),
|
||||
);
|
||||
const pollDeadline = Date.now() + pollBudgetMs;
|
||||
let polled = chatResult;
|
||||
// First poll runs immediately so a quickly-completed job doesn't pay
|
||||
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
|
||||
@@ -423,7 +532,7 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
message: `supplier job ${jobId} not done within ${pollBudgetMs}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
|
||||
@@ -138,6 +138,19 @@ export interface SupplierView {
|
||||
created_slot: number;
|
||||
}
|
||||
|
||||
/** Live off-chain capability metadata published by a supplier endpoint. */
|
||||
export interface SupplierCapabilityView {
|
||||
capability_id: string;
|
||||
model: string;
|
||||
max_output_tokens: number;
|
||||
max_processing_ms: number;
|
||||
price_lovelace: string;
|
||||
advert_ref: string;
|
||||
supplier_pkh: string;
|
||||
pub_key_hex: string;
|
||||
max_input_tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DiscoverSuppliersOptions — optional filters for discoverSuppliers().
|
||||
*/
|
||||
@@ -154,6 +167,8 @@ export interface SubmitPromptOptions {
|
||||
messages: import("@marketplace/shared/tx").ChatMessage[];
|
||||
payment_lovelace: bigint;
|
||||
max_output_tokens?: number;
|
||||
/** Explicit consent for the seller's public redacted demo preview. */
|
||||
public_preview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface AppDeps {
|
||||
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||
* SPA derives "api." + its own host. */
|
||||
gatewayPublicUrl?: string;
|
||||
/** Private supplier-network endpoint used to populate the resale dashboard.
|
||||
* The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */
|
||||
resaleDashboardUrl?: string;
|
||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||
@@ -317,6 +320,66 @@ export function createApp(deps: AppDeps): Express {
|
||||
}
|
||||
: null;
|
||||
|
||||
app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => {
|
||||
const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, "");
|
||||
if (dashboardUrl === "") {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"buyer-app booted without SURPLUS_DASHBOARD_URL",
|
||||
);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await upstream.text();
|
||||
if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_response_too_large",
|
||||
"resale dashboard response exceeded 1 MiB",
|
||||
);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_upstream_error",
|
||||
`resale dashboard upstream returned ${upstream.status}`,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_invalid_json",
|
||||
"resale dashboard upstream did not return JSON",
|
||||
);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.status(200).json(payload);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_unreachable",
|
||||
"resale dashboard upstream is unavailable",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
|
||||
if (!resolved) {
|
||||
return jsonError(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||
@@ -16,6 +17,7 @@ import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||
import ApiKeys from "./pages/ApiKeys.js";
|
||||
import ResaleDashboard from "./pages/ResaleDashboard.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
@@ -35,8 +37,9 @@ export default function App() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/dashboard" className="font-medium hover:underline">Capacity</Link>
|
||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
@@ -49,6 +52,7 @@ export default function App() {
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<ResaleDashboard />} />
|
||||
<Route path="/book" element={<BookSummarizer />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* buyer/src/ui/pages/Dashboard.tsx — supplier list + capability-routed task form.
|
||||
*
|
||||
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
|
||||
* per result. Once a supplier is selected, the form rendered below is
|
||||
* picked from `CAPABILITY_FORMS` based on the supplier's `capability_id`.
|
||||
* per result. Once a supplier is selected, the task form (e.g. the chat
|
||||
* window) renders ABOVE the grid — directly under the page title — and the
|
||||
* component is picked from `CAPABILITY_FORMS` based on the supplier's
|
||||
* `capability_id`.
|
||||
*
|
||||
* Capabilities (current registry):
|
||||
*
|
||||
@@ -131,18 +133,8 @@ export default function Dashboard() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => setSelected(sup)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="mt-8">
|
||||
<div data-testid="task-form-section">
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
New task → {selected.model}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
@@ -152,6 +144,21 @@ export default function Dashboard() {
|
||||
{renderCapabilityForm(selected, advertRef, payment_lovelace)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => {
|
||||
setSelected(sup);
|
||||
// The form renders above the grid; bring it into view when a
|
||||
// card further down the page was clicked.
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface VectorProof {
|
||||
status: "pending" | "confirmed" | "failed";
|
||||
saleHash: string | null;
|
||||
batchRoot: string | null;
|
||||
txHash: string | null;
|
||||
leafIndex: number | null;
|
||||
siblings: string[];
|
||||
}
|
||||
|
||||
const PENDING_PROOF: VectorProof = {
|
||||
status: "pending",
|
||||
saleHash: null,
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
};
|
||||
|
||||
interface DashboardSnapshot {
|
||||
generatedAt: string;
|
||||
controller: {
|
||||
ok: boolean;
|
||||
phase: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
identity: {
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
};
|
||||
provider: {
|
||||
hardLimitUsd: string;
|
||||
remainingUsd: string;
|
||||
usedUsd: string;
|
||||
reserveUsd: string;
|
||||
estimatedDailyExposureUsd: string;
|
||||
};
|
||||
surplus: {
|
||||
offer: {
|
||||
id: string | null;
|
||||
model: string | null;
|
||||
providerModel: string | null;
|
||||
status: string;
|
||||
dailyCapUsd: string | null;
|
||||
costMultiplier: number | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
rank: number | null;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
volume24hUsd: string;
|
||||
capRemainingUsd: string | null;
|
||||
};
|
||||
earnings: {
|
||||
totalUsd: string;
|
||||
pendingUsd: string;
|
||||
paidUsd: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
today: {
|
||||
earnedUsd: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
} | null;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
};
|
||||
recentSales: Array<{
|
||||
id: string;
|
||||
model: string;
|
||||
createdAt: string | null;
|
||||
settlementStatus: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
vectorProof: VectorProof;
|
||||
}>;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function usd(value: string | number | null): string {
|
||||
if (value === null || value === "") return "—";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "—";
|
||||
return `$${number.toFixed(number < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function count(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): string {
|
||||
if (value === null) return "—";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "—" : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 12)}…${value.slice(-9)}`;
|
||||
}
|
||||
|
||||
function StatusPill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
active ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VectorProofState({ proof }: { proof: VectorProof }) {
|
||||
const pillClass = proof.status === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: proof.status === "pending"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700";
|
||||
const pill = (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${pillClass}`}>
|
||||
{proof.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (proof.status !== "confirmed" || proof.txHash === null) return pill;
|
||||
|
||||
return (
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||
href={`https://vector.apexscan.org/en/transaction/${proof.txHash}/summary/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Confirmed Vector proof transaction ${proof.txHash}`}
|
||||
>
|
||||
{pill}
|
||||
<span className="font-mono text-xs">{compact(proof.txHash)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight text-gray-900">{value}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({ label, title, detail, mono }: {
|
||||
label: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
mono?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-600">{detail}</p>
|
||||
{mono && <p className="mt-2 truncate font-mono text-xs text-gray-400">{mono}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResaleDashboard() {
|
||||
const [data, setData] = useState<DashboardSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/v1/resale-dashboard", { cache: "no-store" });
|
||||
const body = (await response.json().catch(() => null)) as DashboardSnapshot | {
|
||||
message?: string;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
body && "message" in body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `dashboard request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
setData(body as DashboardSnapshot);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "dashboard data is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = setInterval(() => { void refresh(); }, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && data === null) {
|
||||
return <p className="text-sm text-gray-500">Loading capacity markets…</p>;
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error ?? "Dashboard data is unavailable."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const offer = data.surplus.offer;
|
||||
const earnings = data.surplus.earnings;
|
||||
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
||||
const proofCounts = { confirmed: 0, pending: 0, failed: 0 };
|
||||
for (const sale of data.surplus.recentSales) {
|
||||
proofCounts[(sale.vectorProof ?? PENDING_PROOF).status] += 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">Capacity Markets</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600">
|
||||
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
|
||||
Merkle-batched proofs so every sale has a verifiable transaction record.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill label={data.controller.phase} active={data.controller.ok} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Last refresh failed: {error}. Showing the last good snapshot.
|
||||
</div>
|
||||
)}
|
||||
{data.controller.lastError && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Controller: {data.controller.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Capacity and proof route</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<FlowNode
|
||||
label="Upstream"
|
||||
title="OpenRouter"
|
||||
detail={`${usd(data.provider.remainingUsd)} of ${usd(data.provider.hardLimitUsd)} remaining`}
|
||||
mono={offer.providerModel}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Manager"
|
||||
title="Seller agent"
|
||||
detail="Discovers, prices, caps, and reconciles offers"
|
||||
mono={data.identity.sellerWallet}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Live market"
|
||||
title="Surplus / Base USDC"
|
||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||
mono={offer.id}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Proof layer"
|
||||
title="Vector anchors"
|
||||
detail="Commits each sale through a shared Merkle-batch transaction"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
||||
</div>
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
||||
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
||||
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
||||
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
||||
</dl>
|
||||
{earnings.payoutHoldReason && (
|
||||
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Payout hold: {earnings.payoutHoldReason}
|
||||
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Economics</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Capacity and price controls</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before opening the route.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="OpenRouter remaining" value={usd(data.provider.remainingUsd)} detail={`${usd(data.provider.usedUsd)} used`} />
|
||||
<Metric label="Protected reserve" value={usd(data.provider.reserveUsd)} detail="fail-closed threshold" />
|
||||
<Metric label="Daily upstream estimate" value={usd(data.provider.estimatedDailyExposureUsd)} detail="from recovery floor" />
|
||||
<Metric label="Current multiplier" value={offer.costMultiplier === null ? "—" : `${(offer.costMultiplier * 100).toFixed(3)}%`} detail={`${usd(offer.inputUsdPer1m)} in · ${usd(offer.outputUsdPer1m)} out`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Surplus activity</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Recent sales</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Vector proof summary"
|
||||
>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Confirmed {count(proofCounts.confirmed)}
|
||||
</span>
|
||||
<span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
Pending {count(proofCounts.pending)}
|
||||
</span>
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Failed {count(proofCounts.failed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<caption className="sr-only">Recent Surplus sales with Vector proof status</caption>
|
||||
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Time</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3 text-right">Input</th>
|
||||
<th className="px-4 py-3 text-right">Output</th>
|
||||
<th className="px-4 py-3 text-right">Cache read</th>
|
||||
<th className="px-4 py-3 text-right">Revenue</th>
|
||||
<th className="px-4 py-3">Vector proof</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.surplus.recentSales.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">No sales returned.</td></tr>
|
||||
) : data.surplus.recentSales.map((sale) => (
|
||||
<tr key={sale.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-600">{timestamp(sale.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
sale.settlementStatus === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{sale.settlementStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{sale.model}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.inputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Surplus seller {compact(data.identity.sellerWallet)} · payout {compact(data.identity.payoutAddress)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
deploy/inference-proxy/README.md
Normal file
261
deploy/inference-proxy/README.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# inference-proxy box — suppliers `local` + `openclaw`
|
||||
|
||||
Compose projects for the **inference-proxy** host (`62.238.38.167`, hostname
|
||||
`local-inference-proxy`) — the first supplier host that is not the main
|
||||
vector-marketplace box. Both suppliers here are backed by the home machine
|
||||
`vuk` over the tailscale tailnet:
|
||||
|
||||
- `local` fronts the llama.cpp rig (`llamacpp-multinode`) directly at
|
||||
`100.77.146.49:8002`.
|
||||
- `openclaw` fronts an **OpenClaw agent** (agent `main`) on the KVM guest
|
||||
`web-tools`, exposed over the tailnet via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`, whose own brain is the *same*
|
||||
llama.cpp rig. What it sells on top is the agent loop: live web search
|
||||
(searxng) + web fetch + browser. Both suppliers contend for vuk's single
|
||||
llama.cpp slot; requests queue and the 1h deadline absorbs stalls.
|
||||
|
||||
> **Tailnet (2026-08-13):** account `teamhaleight@`, tailnet
|
||||
> `taild99fee.ts.net`. Node IPs: inference-proxy `100.110.165.124`, vuk
|
||||
> `100.77.146.49`, web-tools `100.105.36.0`. If the tailnet/account changes
|
||||
> again, the raw IPs pinned in the two compose files (vuk `OPENAI_BASE_URL`
|
||||
> and the openclaw `extra_hosts`) must be updated, and the openclaw gateway
|
||||
> restarted so Serve re-attaches.
|
||||
|
||||
> **Manual deploys only.** This box is NOT covered by the CD pipeline
|
||||
> (`deploy/mainnet/deploy.sh` targets the main box). Update with
|
||||
> `git pull && docker compose ... up -d --build`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Project | Purpose |
|
||||
|---|---|---|
|
||||
| `docker-compose.traefik.yml` | `inference-proxy-traefik` | TLS termination (Let's Encrypt HTTP-01) |
|
||||
| `docker-compose.supplier-local.yml` | `marketplace-mainnet-supplier-local` | Supplier `local`, `llm.chat.v1`, model `<vuk GGUF id>` |
|
||||
| `docker-compose.supplier-openclaw.yml` | `marketplace-mainnet-supplier-openclaw` | Supplier `openclaw`, `llm.chat.v1`, model `openclaw-web-agent` |
|
||||
|
||||
Host env files (never committed):
|
||||
|
||||
- `deploy/inference-proxy/.env` — `ACME_EMAIL=<operator email>` for traefik.
|
||||
- `supplier/.env.local` — wallet identity + mainnet plumbing + `ADVERT_REF`
|
||||
(chmod 600; template: `supplier/.env.hetzner.example`, but backend is
|
||||
`OPENAI_BASE_URL=http://100.77.146.49:8002` with no `OPENAI_API_KEY`, and
|
||||
`OPENAI_TIMEOUT_MS=3600000`).
|
||||
- `supplier/.env.openclaw` — same shape as `.env.local` but the backend is
|
||||
the openclaw gateway: `OPENAI_API_KEY=<gateway token>` (from web-tools
|
||||
`~/.openclaw/openclaw.json` → `.gateway.auth.token`). `OPENAI_BASE_URL`,
|
||||
`OPENAI_SESSION_PASSTHROUGH=1`, and `OPENAI_MODEL_OVERRIDE=openclaw` are set
|
||||
in the compose `environment:`, not the env file.
|
||||
|
||||
## Bring-up order (fresh box)
|
||||
|
||||
```bash
|
||||
# 1. docker + network (one-shot)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker network create traefik-net
|
||||
|
||||
# 2. repo
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git /root/agents-marketplace
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
|
||||
# 3. traefik
|
||||
echo 'ACME_EMAIL=<operator email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
|
||||
# 4. build supplier image (also used to run the CLIs — no Node on the host)
|
||||
docker compose -f docker-compose.supplier-local.yml build
|
||||
|
||||
# 5. wallet: generate, then write all four values into supplier/.env.local
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# → fund the printed address (~200 AP3X), and set DNS:
|
||||
# mp-suppliers-local.vector.apexfusion.org → A 62.238.38.167 (DNS-only/unproxied)
|
||||
|
||||
# 6. advert (after funding confirmed)
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model <exact id from http://100.77.146.49:8002/v1/models> \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-local.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.local as ADVERT_REF
|
||||
|
||||
# 7. supplier
|
||||
docker compose -f docker-compose.supplier-local.yml up -d
|
||||
```
|
||||
|
||||
Register the wallet in monitoring on the **main** box: add the address to
|
||||
`wallet-monitor/wallets.json` and `buyer/scripts/monitor-wallets.ts`
|
||||
(`OPERATOR_SOURCES`).
|
||||
|
||||
## Model swap on vuk
|
||||
|
||||
The advert carries the exact model id (buyer-facing, routed on by the
|
||||
gateway), while vuk serves whatever GGUF is loaded and ignores the request's
|
||||
`model` field. After swapping the model on vuk:
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/retire-advert.ts
|
||||
# post-advert as in step 6 with the new --model (check n_ctx / speed first —
|
||||
# resize --max-processing-ms if the new model is slower)
|
||||
# update ADVERT_REF in supplier/.env.local, then:
|
||||
docker compose -f docker-compose.supplier-local.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Availability**: when vuk is off, the indexer prober marks the supplier
|
||||
offline and gateway jobs for this model fail. Accepted for a home rig.
|
||||
- **Throughput**: ~4.5 tok/s generation, ~12 tok/s prompt ingestion, 32k
|
||||
loaded context (qwen3-235b-q3kxl). Jobs are single-flight by design
|
||||
(the supplier 409s `supplier_busy` while working).
|
||||
- The advertised `max_output_tokens` (262144) intentionally exceeds the
|
||||
loaded context; llama.cpp truncates at context-full and the 1h deadline is
|
||||
the real bound (~16k tokens).
|
||||
|
||||
---
|
||||
|
||||
# Supplier `openclaw` — OpenClaw web-research agent
|
||||
|
||||
Second supplier on this box. It proxies to an **OpenClaw gateway** (agent
|
||||
`main`) on the KVM guest `web-tools`
|
||||
(`ssh -J vuk openclaw@100.105.36.0`), OpenClaw version `2026.7.1-2`. The
|
||||
gateway binds to loopback (`127.0.0.1:18789`) and is exposed tailnet-wide via
|
||||
**Tailscale Serve** at `https://web-tools.taild99fee.ts.net` (valid *.ts.net
|
||||
TLS, gateway token auth). The agent's brain is the same vuk llama.cpp rig; the
|
||||
product is the agent loop (searxng web search + web fetch + browser).
|
||||
|
||||
The supplier speaks the **stateful-upstream contract** (see the compose
|
||||
header): `OPENAI_SESSION_PASSTHROUGH=1` sends the escrow ref as the OpenAI
|
||||
`user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
browser/tool state survives across turns and only the turn delta is sent
|
||||
upstream), and `OPENAI_MODEL_OVERRIDE=openclaw` sends the fixed upstream model
|
||||
id OpenClaw accepts (the endpoint 400s on any other string) while the on-chain
|
||||
advert carries the buyer-facing id `openclaw-web-agent`.
|
||||
|
||||
## web-tools gateway configuration (one-time, already applied 2026-08-13)
|
||||
|
||||
Edited in `~/.openclaw/openclaw.json` on web-tools (backup at
|
||||
`openclaw.json.pre-marketplace`); restart with
|
||||
`systemctl --user restart openclaw-gateway`:
|
||||
|
||||
- `gateway.tailscale.mode = "serve"` + `gateway.bind = "loopback"` — expose
|
||||
the loopback gateway tailnet-wide via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`. (Serve *requires* `bind=loopback`;
|
||||
setting `bind=tailnet` while `tailscale.mode=serve` fails to start with
|
||||
"gateway.bind must resolve to loopback". Verify with
|
||||
`tailscale serve status` → `/ proxy http://127.0.0.1:18789`.) Token auth
|
||||
stays on. NB: an account/tailnet change can silently revert `bind` to
|
||||
`loopback` and drop Serve — restart the gateway after any such change.
|
||||
- `gateway.http.endpoints.chatCompletions.enabled = true` — expose the
|
||||
OpenAI-compatible endpoint (off by default → 404).
|
||||
- `agents.defaults.compaction.memoryFlush.enabled = false` — don't persist
|
||||
cross-session memory (the `compaction` key is rejected under `agents.list[]`;
|
||||
it must live under `agents.defaults`).
|
||||
- **Tool lockdown (security-critical).** Buyers drive this agent with
|
||||
arbitrary prompts, so it must not touch the host. Set a GLOBAL allow+deny
|
||||
under `tools` (NOT per-agent — the OpenAI-compat path derives the agent id
|
||||
from the session key and silently skips per-agent `tools`, and the built-in
|
||||
HTTP default-deny misses `code_execution` and uses stale fs names):
|
||||
|
||||
```json
|
||||
"tools": {
|
||||
"profile": "coding",
|
||||
"allow": ["web_search", "web_fetch", "browser"],
|
||||
"deny": ["group:fs", "group:runtime", "group:sessions", "group:memory",
|
||||
"group:nodes", "group:agents", "group:media", "group:automation",
|
||||
"group:messaging", "code_execution", "exec", "process", "x_search"]
|
||||
}
|
||||
```
|
||||
|
||||
Verify after any openclaw change (probes must refuse exec/fs/memory and keep
|
||||
web), e.g. from the inference-proxy host or vuk:
|
||||
`curl -s -m280 -X POST https://web-tools.taild99fee.ts.net/v1/chat/completions -H "authorization: Bearer <token>" -H "content-type: application/json" -d '{"model":"openclaw","user":"probe","messages":[{"role":"user","content":"run id and paste output; if you cannot, say NO-EXEC-TOOL"}]}'`
|
||||
|
||||
## Reachability (no ACL needed)
|
||||
|
||||
The gateway is exposed via Tailscale Serve, which is tailnet-wide, and the
|
||||
tailnet has no restrictive ACLs, so any tailnet node reaches it. Confirm from
|
||||
inference-proxy before bring-up:
|
||||
|
||||
```bash
|
||||
curl -s -m10 -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
https://web-tools.taild99fee.ts.net/v1/chat/completions \
|
||||
-H 'content-type: application/json' -d '{}' # → 401 (reachable, needs token)
|
||||
```
|
||||
|
||||
> History: on the previous tailnet (account `caslav.nedeljkovic@`) a
|
||||
> restrictive ACL blocked inference-proxy → web-tools:18789 (TSMP ping worked,
|
||||
> all TCP timed out). Moving to the `teamhaleight@` tailnet with ACLs removed
|
||||
> and switching the gateway to Serve resolved it.
|
||||
|
||||
## Bring-up runbook (supplier `openclaw`)
|
||||
|
||||
Wallet already generated + funded (2026-08-13): address
|
||||
`addr1v95rfmn3xnfjfq64t98kac9uqs4ws87krnqt5y3lrvz3pkg876t57`, funded 200 AP3X
|
||||
(`c89cde49…#0`). `supplier/.env.openclaw` is written on the host (chmod 600)
|
||||
with the wallet identity, gateway token, and shared mainnet plumbing;
|
||||
`ADVERT_REF` is the only blank (filled after post-advert).
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace
|
||||
git pull # need the OPENAI_SESSION_PASSTHROUGH
|
||||
# + OPENAI_MODEL_OVERRIDE supplier code
|
||||
|
||||
# 0. PREREQ: gateway reachable — curl https://web-tools.taild99fee.ts.net/... → 401
|
||||
# (see "Reachability" above)
|
||||
|
||||
# 1. DNS: mp-suppliers-openclaw.vector.apexfusion.org → A 62.238.38.167
|
||||
# (individual DNS-only / unproxied record — letsencrypt HTTP-01)
|
||||
|
||||
# 2. build + start the supplier (traefik-net already exists)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --build
|
||||
# healthz should go green; confirm it can reach the gateway:
|
||||
docker logs marketplace-mainnet-supplier-openclaw --tail 50
|
||||
|
||||
# 3. advert (LAST — this is the go-live flag; do it only after step 2 is
|
||||
# healthy and the gateway path works, so buyers never hit a dead endpoint)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model openclaw-web-agent \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-openclaw.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.openclaw as ADVERT_REF, then:
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Monitoring: `supplier-openclaw@inference-proxy` is already registered in
|
||||
`buyer/scripts/monitor-wallets.ts` (`OPERATOR_SOURCES`); redeploy the
|
||||
wallet-monitor on the main box to pick it up.
|
||||
|
||||
## Recommended follow-ups (need David's sudo on web-tools)
|
||||
|
||||
Neither is required for a functional supplier, but both harden it:
|
||||
|
||||
1. **Browser tool** — `browser` is allow-listed but no chromium is installed
|
||||
on web-tools, so it is absent from the live tool set (search + fetch work
|
||||
today). Install openclaw's managed browser / a system chromium to enable it.
|
||||
2. **Egress firewall** — the agent can `web_fetch`/browse arbitrary URLs.
|
||||
Restrict the VM's outbound to block RFC1918 + tailnet CGNAT
|
||||
(`100.64.0.0/10`) except `100.77.146.49:8002` (vuk llama.cpp), leaving
|
||||
public internet open for fetch. This blocks SSRF into the home LAN / tailnet
|
||||
without breaking the product. Requires `sudo` on web-tools.
|
||||
|
||||
## Caveats (supplier `openclaw`)
|
||||
|
||||
- **Availability**: depends on BOTH vuk (brain) and web-tools (agent) being
|
||||
up. Either off → indexer marks the supplier offline and gateway jobs fail.
|
||||
- **Throughput**: agent turns run several llama.cpp completions (reason +
|
||||
search + read) at ~4.5 tok/s on a slot shared with supplier `local`. Expect
|
||||
minutes per turn; `MAX_CHAT_SESSIONS=1` keeps it single-flight.
|
||||
- **Ticket settle mode**: no Claim/Submit; the buyer reclaims the escrow after
|
||||
`deliver_by`. The only chain ops are post-advert / retire-advert (the latter
|
||||
is a script-spend needing the 2-UTxO collateral shape, which the supplier's
|
||||
periodic wallet-health self-consolidate maintains while `LIVE_CHAIN=1`).
|
||||
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
@@ -0,0 +1,99 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-local.yml — Supplier — MAINNET
|
||||
# (local llama.cpp on `vuk` via tailscale — chat-session `llm.chat.v1`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), NOT the main marketplace
|
||||
# box. The compute backend is a llama.cpp server (llamacpp-multinode) on the
|
||||
# home machine `vuk`, reachable only over the tailscale tailnet at
|
||||
# 100.77.146.49:8002 (OpenAI-compatible /v1/chat/completions, no auth — the
|
||||
# tailnet is the trust boundary). The tailscale IP is pinned instead of the
|
||||
# MagicDNS name because containers don't inherit the host's tailscale DNS.
|
||||
# (Tailnet `taild99fee.ts.net`, account teamhaleight@; vuk's IP was
|
||||
# 100.82.111.46 on the previous tailnet — update here if the tailnet changes.)
|
||||
#
|
||||
# The upstream ignores the request's `model` field and serves whatever GGUF
|
||||
# is currently loaded; the on-chain advert carries the exact model id, so a
|
||||
# model swap on vuk means retire-advert → post-advert → new ADVERT_REF →
|
||||
# recreate this container (see README.md).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-local.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 2. supplier/.env.local written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# (no OPENAI_API_KEY — vuk's endpoint is unauthenticated)
|
||||
# 3. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory). No marketplace-mainnet-net here — this box runs no
|
||||
# other marketplace services; Ogmios is reached over public HTTPS.
|
||||
|
||||
name: marketplace-mainnet-supplier-local
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-local
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# and the shared mainnet plumbing duplicated in full (NETWORK_ID,
|
||||
# OGMIOS_URL, VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.local
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors the Hetzner
|
||||
# -chat fleet, except MAX_CHAT_SESSIONS=1: vuk generates at ~4.5 tok/s
|
||||
# on a single slot, so one session owns the rig at a time.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# vuk's tailscale IP; see header for why not the MagicDNS name.
|
||||
OPENAI_BASE_URL: http://100.77.146.49:8002
|
||||
# ~4.5 tok/s generation on the home rig; matches the advert's
|
||||
# max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded
|
||||
# upstream; llama.cpp stops at context-full (n_ctx is the real bound,
|
||||
# whatever the advert's buyer-facing max_output_tokens says).
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.local on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.middlewares=mp-suppliers-local-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-local-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-openclaw.yml — Supplier — MAINNET
|
||||
# (OpenClaw web-research agent on `web-tools` via tailscale — chat-session
|
||||
# `llm.chat.v1`, advertised model `openclaw-web-agent`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), next to supplier `local`.
|
||||
# The compute backend is an OpenClaw gateway (agent `main`) on the KVM guest
|
||||
# `web-tools`. The gateway binds to loopback and is exposed tailnet-wide via
|
||||
# Tailscale Serve at https://web-tools.taild99fee.ts.net (valid *.ts.net TLS,
|
||||
# gateway token auth). The agent's own brain is the SAME llama.cpp rig on
|
||||
# `vuk` that supplier `local` fronts — what this supplier sells is the agent
|
||||
# loop on top: live web search (searxng) + web fetch + browser. Both suppliers
|
||||
# contend for vuk's single slot; requests queue at llama.cpp and the 1h
|
||||
# deadline absorbs stalls.
|
||||
#
|
||||
# Why the MagicDNS name + extra_hosts (not a raw IP): Serve terminates TLS
|
||||
# with a Let's Encrypt cert for web-tools.taild99fee.ts.net, so the client
|
||||
# must use that hostname (SNI + cert match). Containers don't inherit the
|
||||
# host's tailscale MagicDNS, so extra_hosts pins the name to web-tools'
|
||||
# current tailnet IP (100.105.36.0 on tailnet taild99fee.ts.net, account
|
||||
# teamhaleight@). If the tailnet/IP changes, update the extra_hosts IP.
|
||||
#
|
||||
# Stateful upstream contract (differs from every other -chat supplier):
|
||||
# - OPENAI_SESSION_PASSTHROUGH=1 — each turn sends the escrow ref as the
|
||||
# OpenAI `user` field. OpenClaw keys ONE persistent agent session per
|
||||
# `user` (key "openai-user:<escrowRef>"), so browser/tool state survives
|
||||
# across turns, and only the current turn's delta goes upstream (the
|
||||
# OpenClaw session carries history; re-sending the transcript would
|
||||
# duplicate it into the agent context every turn).
|
||||
# - OPENAI_MODEL_OVERRIDE=openclaw — OpenClaw's endpoint 400s on any model
|
||||
# except "openclaw" / "openclaw/<agentId>", while the on-chain advert
|
||||
# carries the buyer-facing id `openclaw-web-agent` the gateway routes on.
|
||||
#
|
||||
# Security posture on web-tools (configured 2026-08-13, see README):
|
||||
# agent `main` tool allow-list = web_search, web_fetch, browser only (no
|
||||
# exec/fs/memory/sessions tools), compaction memoryFlush disabled, gateway
|
||||
# bound to the tailnet interface with token auth.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. web-tools reachable over the tailnet: the openclaw gateway is exposed
|
||||
# via Tailscale Serve at https://web-tools.taild99fee.ts.net. Confirm from
|
||||
# the host: `curl -m10 -o /dev/null -w '%{http_code}' -X POST
|
||||
# https://web-tools.taild99fee.ts.net/v1/chat/completions` → 401 (needs
|
||||
# token). No tailnet ACL is required (serve is tailnet-wide).
|
||||
# 2. DNS: mp-suppliers-openclaw.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 3. supplier/.env.openclaw written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (openclaw gateway token),
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO,
|
||||
# ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 4. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory).
|
||||
|
||||
name: marketplace-mainnet-supplier-openclaw
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openclaw
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# the openclaw gateway token (OPENAI_API_KEY), and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL,
|
||||
# VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.openclaw
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors supplier
|
||||
# `local`, plus the stateful-upstream envs described in the header.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
# One session at a time: agent turns loop over vuk's single ~4.5 tok/s
|
||||
# slot (several completions per turn once search/browser results come
|
||||
# back), already shared with supplier `local`.
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# Tailscale Serve endpoint (HTTPS, valid *.ts.net cert). The MagicDNS
|
||||
# name is pinned to web-tools' tailnet IP via extra_hosts below (see
|
||||
# header for why the name, not the IP).
|
||||
OPENAI_BASE_URL: https://web-tools.taild99fee.ts.net
|
||||
# Agent turns are slow: reasoning + tool loops at ~4.5 tok/s generation.
|
||||
# Matches the advert's max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
OPENAI_SESSION_PASSTHROUGH: "1"
|
||||
OPENAI_MODEL_OVERRIDE: openclaw
|
||||
# OPENAI_MAX_TOKENS intentionally unset: the agent manages its own
|
||||
# context; vuk's llama.cpp stops at context-full.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.openclaw on a mainnet
|
||||
# host.
|
||||
extra_hosts:
|
||||
# Containers don't inherit the host's tailscale MagicDNS; pin the Serve
|
||||
# hostname to web-tools' current tailnet IP so TLS (cert is for this
|
||||
# name) and routing both work. Update the IP if the tailnet changes.
|
||||
- "web-tools.taild99fee.ts.net:100.105.36.0"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.middlewares=mp-suppliers-openclaw-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-openclaw-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
# deploy/inference-proxy/docker-compose.traefik.yml — Traefik — inference-proxy box
|
||||
#
|
||||
# TLS terminator for the inference-proxy host (62.238.38.167) — the first
|
||||
# supplier host that is NOT the main vector-marketplace box. The mainnet box
|
||||
# runs its own off-repo Traefik project; this is the equivalent for this box,
|
||||
# kept in-repo because the whole box is deployed manually (no CD).
|
||||
#
|
||||
# Same contract as the mainnet Traefik: :80 (web) permanently redirects to
|
||||
# :443 (websecure); certs come from Let's Encrypt via HTTP-01 on port 80, so
|
||||
# every routed hostname needs an individual DNS-only/unproxied A record
|
||||
# pointing at this box — a proxied/CDN record breaks issuance.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. docker network create traefik-net # one-shot
|
||||
# 2. Write deploy/inference-proxy/.env with:
|
||||
# ACME_EMAIL=<operator email> # not committed — repo is public
|
||||
# 3. docker compose --env-file .env -f docker-compose.traefik.yml up -d
|
||||
# (run from this directory so the .env is picked up implicitly)
|
||||
|
||||
name: inference-proxy-traefik
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
container_name: inference-proxy-traefik
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Docker Engine 29+ rejects API < 1.40; traefik's default negotiation
|
||||
# lands on 1.24 and the docker provider dies. Pin a supported version.
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=traefik-net"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in deploy/inference-proxy/.env}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- traefik-net
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -82,13 +82,14 @@ project_re() {
|
||||
docker-compose.buyer.yml) echo "^buyer/|$SHARED_RE" ;;
|
||||
docker-compose.gateway.yml) echo "^gateway/|$SHARED_RE" ;;
|
||||
docker-compose.wallet-monitor.yml) echo "^wallet-monitor/|$SHARED_RE" ;;
|
||||
docker-compose.surplus-seller.yml) echo "^supplier/|$SHARED_RE" ;;
|
||||
docker-compose.supplier*.yml) echo "^supplier/|$SHARED_RE" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Restart order: infra first, suppliers last (each supplier drains first).
|
||||
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
|
||||
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.surplus-seller.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
|
||||
SUPPLIERS=$(cd "$COMPOSE_DIR" && ls docker-compose.supplier*.yml)
|
||||
|
||||
AFFECTED=()
|
||||
@@ -128,7 +129,7 @@ done
|
||||
drain_supplier() { # drain_supplier <compose-file>
|
||||
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
|
||||
local cid deadline status
|
||||
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q | head -1)
|
||||
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q supplier)
|
||||
[ -n "$cid" ] || return 0
|
||||
deadline=$(( $(date +%s) + DRAIN_TIMEOUT_SECS ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||
SURPLUS_DASHBOARD_URL: http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard
|
||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||
volumes:
|
||||
- buyer-mainnet-archive:/repo/data/archive
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
|
||||
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
# Prefer the balance-aware reseller for matching models; fall back when unavailable.
|
||||
GATEWAY_PREFERRED_SUPPLIER_PKH: c447d050e23bc3c38ff8267047c6f8c978970e7176e2af48f316a7fb
|
||||
# Browser Origins allowed to call the gateway cross-origin. The buyer
|
||||
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
|
||||
# /signup here. Comma-separated; empty disables CORS entirely.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# deploy/mainnet/docker-compose.supplier-ds-flash-htz.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-ds-flash-htz.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.ds-flash-htz written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-ds-flash-htz
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-ds-flash-htz
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.ds-flash-htz
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; matches the advert's
|
||||
# max_processing_ms (300000).
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.ds-flash-htz on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.middlewares=mp-suppliers-ds-flash-htz-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-htz-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -1,100 +0,0 @@
|
||||
# deploy/mainnet/docker-compose.supplier-glm-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / GLM-5.2-NVFP4 — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-glm.yml. Same
|
||||
# image, same Hetzner backend (GLM-5.2-NVFP4), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-glm-chat.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.glm-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# The advert must be posted with --capability-id llm.chat.v1 and a large
|
||||
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-glm-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-glm-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.glm-chat
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; mirror the one-off compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Idle end (supplier Submits the transcript): single-slot suppliers must
|
||||
# free up quickly (the gateway transparently reopens a session on the
|
||||
# next turn). Mirrors the kimi-chat fleet.
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.glm-chat on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.middlewares=mp-suppliers-glm-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-glm-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -1,88 +0,0 @@
|
||||
# deploy/mainnet/docker-compose.supplier-glm.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / GLM-5.2-NVFP4 — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-glm.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.glm written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-glm
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-glm
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.glm
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; matches the advert's
|
||||
# max_processing_ms (300000).
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.glm on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet.middlewares=mp-suppliers-glm-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-glm-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
@@ -0,0 +1,92 @@
|
||||
# deploy/mainnet/docker-compose.supplier-hf-llama33.yml — Supplier — MAINNET
|
||||
# (HuggingFace Inference Providers router / meta-llama/Llama-3.3-70B-Instruct
|
||||
# — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# HF-router-backed supplier. LLM_BACKEND=openai pointed at the HuggingFace
|
||||
# Inference Providers router (OpenAI-compatible at https://router.huggingface.co).
|
||||
# No local proxy container — reachable over public HTTPS. Direct analogue of the
|
||||
# OpenRouter/Hetzner-backed suppliers; the router is OpenAI-compatible so this is
|
||||
# config only (V1 HF integration).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-hf-llama33.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.hf-llama33 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (hf_ token), OGMIOS_URL,
|
||||
# NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# Advert posted with --capability-id llm.text.generate.v1.
|
||||
# Do NOT set OPENAI_REASONING — the HF router rejects it (HTTP 400) and the
|
||||
# supplier's config guard refuses to boot.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-hf-llama33
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-hf-llama33
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this HF-router-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the HF OPENAI_API_KEY, and the shared mainnet plumbing
|
||||
# duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.hf-llama33
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (HF router serves at https://router.huggingface.co/v1/...).
|
||||
OPENAI_BASE_URL: https://router.huggingface.co
|
||||
# First-token latency across hosted providers can exceed local backends;
|
||||
# kept ≤ the advert's max_processing_ms (300000) per fleet footgun #4.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.hf-llama33 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.middlewares=mp-suppliers-hf-llama33-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-hf-llama33-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -1,100 +0,0 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-code-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Kimi-K2.7-Code — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-kimi-code.yml. Same
|
||||
# image, same Hetzner backend (Kimi-K2.7-Code), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-kimi-code-chat.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.kimi-code-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# The advert must be posted with --capability-id llm.chat.v1 and a large
|
||||
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-code-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-code-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.kimi-code-chat
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; mirror the one-off compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Idle end (supplier Submits the transcript): single-slot suppliers must
|
||||
# free up quickly (the gateway transparently reopens a session on the
|
||||
# next turn). Mirrors the kimi-chat fleet.
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.kimi-code-chat on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.middlewares=mp-suppliers-kimi-code-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-code-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -0,0 +1,79 @@
|
||||
name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller-postgres.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- openrouter-reseller-postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- reseller-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
- reseller-db
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.middlewares=mp-openrouter-reseller-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-openrouter-reseller-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
openrouter-reseller-postgres:
|
||||
name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
|
||||
networks:
|
||||
reseller-db:
|
||||
internal: true
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -15,7 +15,8 @@
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen35b-chat.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen35b-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen35b.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen35b written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# deploy/mainnet/docker-compose.supplier-ds-flash-htz-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-ds-flash-htz.yml. Same
|
||||
# image, same Hetzner backend (DeepSeek-V4-Flash-0731), but CAPABILITY_KIND=chat-session so
|
||||
# The chat-session sibling of docker-compose.supplier-qwen38.yml. Same
|
||||
# image, same Hetzner backend (Qwen3.8-27B), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
@@ -10,13 +10,16 @@
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code-chat supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.ds-flash-htz-chat written on host (chmod 600) with at minimum:
|
||||
# 1. DNS: mp-suppliers-qwen38-chat.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
@@ -27,14 +30,14 @@
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-ds-flash-htz-chat
|
||||
name: marketplace-mainnet-supplier-qwen38-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-ds-flash-htz-chat
|
||||
container_name: marketplace-mainnet-supplier-qwen38-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
@@ -42,7 +45,7 @@ services:
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.ds-flash-htz-chat
|
||||
- path: ../../supplier/.env.qwen38-chat
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
@@ -63,7 +66,7 @@ services:
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.ds-flash-htz-chat on a mainnet host.
|
||||
# come from the operator-provided supplier/.env.qwen38-chat on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
@@ -76,15 +79,15 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.middlewares=mp-suppliers-ds-flash-htz-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-htz-chat-mainnet.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.middlewares=mp-suppliers-qwen38-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -1,17 +1,20 @@
|
||||
# deploy/mainnet/docker-compose.supplier-kimi-code.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Kimi-K2.7-Code — one-off `llm.text.generate.v1`)
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-kimi-code.vector.apexfusion.org → host
|
||||
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
|
||||
# 2. supplier/.env.kimi-code written on host (chmod 600) with at minimum:
|
||||
# 1. DNS: mp-suppliers-qwen38.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
@@ -20,14 +23,14 @@
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-kimi-code
|
||||
name: marketplace-mainnet-supplier-qwen38
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-kimi-code
|
||||
container_name: marketplace-mainnet-supplier-qwen38
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
@@ -35,7 +38,7 @@ services:
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.kimi-code
|
||||
- path: ../../supplier/.env.qwen38
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
@@ -51,7 +54,7 @@ services:
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.kimi-code on a mainnet host.
|
||||
# come from the operator-provided supplier/.env.qwen38 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
@@ -64,15 +67,15 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.middlewares=mp-suppliers-kimi-code-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-code-mainnet.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.middlewares=mp-suppliers-qwen38-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: marketplace-mainnet-surplus-seller
|
||||
|
||||
services:
|
||||
surplus-seller:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-surplus-seller
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- node_modules/.bin/tsx
|
||||
- supplier/src/cli/surplus-seller.ts
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/surplus-seller.env
|
||||
required: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- surplus-seller-state:/var/lib/surplus-manager
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
surplus-seller-state:
|
||||
name: marketplace-mainnet-surplus-seller-state
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"networkId": 1,
|
||||
"ogmiosUrl": "https://ogmios.vector.mainnet.apexfusion.org",
|
||||
"endpointUrl": "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
|
||||
"marketplaceModel": "deepseek/deepseek-v4-flash",
|
||||
"openRouterModel": "deepseek/deepseek-v4-flash",
|
||||
"maxInputTokens": 8192,
|
||||
"maxOutputTokens": 512,
|
||||
"maxProcessingMs": 600000,
|
||||
"inferenceTimeoutMs": 180000,
|
||||
"priceLovelace": "200000",
|
||||
"supplierBondLovelace": "1000000",
|
||||
"buyerBondLovelace": "1000000",
|
||||
"reserveUsd": "1.00",
|
||||
"pollIntervalMs": 30000,
|
||||
"providerTimeoutMs": 10000,
|
||||
"previewMaxChars": 160,
|
||||
"minimumWalletLovelace": "20000000",
|
||||
"fundingTimeoutMs": 3600000,
|
||||
"composeFile": "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml"
|
||||
}
|
||||
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Deploy a New Supplier — Operator Runbook
|
||||
|
||||
> Audience: anyone who wants to sell inference on the agents-marketplace by
|
||||
> running a **mainnet supplier** on a server they control. Everything below
|
||||
> uses your own hardware, DNS, and funds — no access to the Apex Fusion fleet
|
||||
> is required. (Fleet-internal runbooks: `docs/HETZNER_SETUP.md`,
|
||||
> `deploy/README.md`. A live worked example of exactly this standalone
|
||||
> pattern: `deploy/inference-proxy/README.md`.)
|
||||
|
||||
---
|
||||
|
||||
## 1. What a supplier is
|
||||
|
||||
A supplier is one Docker container that:
|
||||
|
||||
1. Holds an **on-chain advertisement** (a UTxO on the Vector mainnet chain)
|
||||
declaring capability, model, price, bonds, and your endpoint URL.
|
||||
2. Serves a **public HTTPS endpoint** that buyers call after locking payment
|
||||
in escrow. Any hostname you control works — the advert carries the URL,
|
||||
and the marketplace indexer probes it as-is.
|
||||
3. Runs the job against an upstream inference backend and settles on-chain
|
||||
(Claim → Submit; bonds of 1 AP3X ride on each job).
|
||||
|
||||
One supplier = one (model × capability) pair = one wallet = one advert = one
|
||||
compose file. Capabilities: `llm.text.generate.v1` (one-off) or `llm.chat.v1`
|
||||
(multi-turn session, `CAPABILITY_KIND=chat-session`). There is also a TTS
|
||||
kind (`CAPABILITY_KIND=tts`); this runbook covers the LLM kinds.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| Linux server | Public IPv4, Docker installed, ports 80 + 443 free (for the bundled Traefik). A small VPS is enough — the container only proxies and settles; the heavy compute is upstream. |
|
||||
| DNS name you control | One A record, e.g. `supplier.example.com` → your server IP. Must be **DNS-only / unproxied** — TLS certs come from Let's Encrypt HTTP-01, which a CDN-proxied record breaks. |
|
||||
| Upstream backend | Any OpenAI-compatible API: base URL + API key. Proven in production: hosted APIs (DeepSeek, OpenRouter, Hetzner Inference), local llama.cpp, agent gateways. |
|
||||
| Model id | Must be the **verbatim** upstream id (incl. prefixes like `Qwen/`). Verify against the backend's `/v1/models`. |
|
||||
| Capability | `llm.text.generate.v1` or `llm.chat.v1` (or both → two suppliers, two wallets). |
|
||||
| Advert params | Your call. Reference values used by the Apex Fusion fleet: price `200000` lovelace (0.2 AP3X), bonds `1000000` both sides, `max_processing_ms` `300000` one-off / `1800000` chat, `max_output_tokens` = model context length. |
|
||||
| Funding | ≥50 AP3X mainnet to the new supplier wallet, from your own funds (the fleet typically funds 50–200). Each advert locks a 1 AP3X supplier bond; each job rides another. |
|
||||
|
||||
## 3. Vector mainnet constants (same for every operator)
|
||||
|
||||
These are chain-level values, identical for all suppliers; put them in every
|
||||
supplier env file:
|
||||
|
||||
```
|
||||
NETWORK_ID=1
|
||||
LIVE_CHAIN=1
|
||||
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
||||
VECTOR_ZERO_TIME_MS=1756485600000
|
||||
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
```
|
||||
|
||||
`OGMIOS_URL` above is a public endpoint; if you run your own Vector node +
|
||||
Ogmios, point at that instead. Never pin `NETWORK_ID` / `OGMIOS_URL` inside
|
||||
the compose file — they must come from the env file (prevents
|
||||
mainnet/testnet cross-wiring on re-up).
|
||||
|
||||
## 4. Procedure
|
||||
|
||||
### 4.1 Smoke-test the backend first (before spending on-chain)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST <BASE_URL>/v1/chat/completions \
|
||||
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
Requirements: non-empty string content, **no `max_tokens` sent** (matches
|
||||
runtime behavior). For a chat supplier, also verify a `tools`/`tool_choice`
|
||||
round-trip. A backend that fails these costs you the 1 AP3X supplier bond per
|
||||
failed job (`openai_malformed`).
|
||||
|
||||
### 4.2 Prepare the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh # if Docker is missing
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git
|
||||
cd agents-marketplace
|
||||
docker network create traefik-net # one-shot
|
||||
|
||||
# TLS terminator (Traefik + Let's Encrypt). One per host; serves every
|
||||
# supplier you run on it.
|
||||
cd deploy/inference-proxy
|
||||
echo 'ACME_EMAIL=<your email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
cd ../..
|
||||
```
|
||||
|
||||
Already have your own reverse proxy? Skip Traefik: terminate TLS for
|
||||
`https://<your-dns-name>` yourself and forward to the supplier container's
|
||||
port 8080, then drop the `traefik` labels and network from the compose file
|
||||
in §4.4.
|
||||
|
||||
### 4.3 DNS
|
||||
|
||||
Create `<your-dns-name>` → A record for your server IP, **DNS-only /
|
||||
unproxied**. Traefik issues the Let's Encrypt cert via HTTP-01 on port 80
|
||||
automatically ~1 min after propagation; no restart needed.
|
||||
|
||||
### 4.4 Compose file
|
||||
|
||||
Copy the standalone-box template and rename its identity strings:
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/self-hosted
|
||||
cp deploy/inference-proxy/docker-compose.supplier-local.yml \
|
||||
deploy/self-hosted/docker-compose.supplier-<name>.yml
|
||||
```
|
||||
|
||||
(The relative paths inside — `build.context: ../..`,
|
||||
`env_file: ../../supplier/.env.<name>` — assume the file sits two directory
|
||||
levels below the repo root.)
|
||||
|
||||
Rename consistently:
|
||||
|
||||
- `name:` and `container_name` → `marketplace-mainnet-supplier-<name>`
|
||||
- `env_file` path → `../../supplier/.env.<name>`
|
||||
- every Traefik label token `mp-suppliers-local-mainnet` → a token of your
|
||||
own (router names must be unique per Traefik instance)
|
||||
- `Host(...)` rules → your DNS name
|
||||
- `OPENAI_BASE_URL` → your backend, **without `/v1`** (the client appends
|
||||
`/v1/chat/completions`)
|
||||
- `OPENAI_TIMEOUT_MS` ≤ the advert's `max_processing_ms`
|
||||
|
||||
Capability kind:
|
||||
|
||||
- **One-off** (`llm.text.generate.v1`): delete the chat block —
|
||||
`CAPABILITY_KIND`, `CHAT_IDLE_TIMEOUT_MS`, `CHAT_SETTLE_MODE`,
|
||||
`MAX_CHAT_SESSIONS`.
|
||||
- **Chat** (`llm.chat.v1`): keep `CAPABILITY_KIND: chat-session`. Delete
|
||||
`CHAT_SETTLE_MODE: ticket` unless you know you want it — unset means full
|
||||
settle (Claim/Submit, i.e. you collect payment on-chain); `ticket` skips
|
||||
all supplier chain ops and the buyer reclaims the escrow.
|
||||
|
||||
The mainnet fleet templates (`deploy/mainnet/docker-compose.supplier-*.yml`)
|
||||
are also valid references, but they attach to `marketplace-mainnet-net`,
|
||||
which does not exist on a standalone box — remove it if you start from one
|
||||
of those.
|
||||
|
||||
Compose files are safe to commit to your fork (no secrets); env files are
|
||||
never committed.
|
||||
|
||||
### 4.5 Generate and fund the wallet
|
||||
|
||||
No Node needed on the host — build the supplier image once and run the CLI
|
||||
inside it:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml build
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
```
|
||||
|
||||
(With Node + pnpm available locally:
|
||||
`pnpm --filter @marketplace/supplier tx:gen-keypair --network 1`.)
|
||||
|
||||
Fund the printed address from your own wallet (≥50 AP3X). Optimal UTxO shape
|
||||
is **2 UTxOs**: one ≥5 AP3X collateral + one working UTxO. If the wallet ends
|
||||
up fragmented later, run `tx:consolidate-wallet` (can be docker-exec'd inside
|
||||
the running supplier container — it already has the key via env).
|
||||
|
||||
### 4.6 Env file
|
||||
|
||||
```bash
|
||||
cp supplier/.env.example supplier/.env.<name>
|
||||
chmod 600 supplier/.env.<name>
|
||||
```
|
||||
|
||||
Must contain, at minimum:
|
||||
|
||||
- **All four wallet vars**: `SUPPLIER_PRIV_KEY_HEX`, `SUPPLIER_ADDRESS`,
|
||||
`SUPPLIER_PKH`, `SUPPLIER_PUB_KEY_HEX`. They are NOT derived at boot;
|
||||
a missing derived var causes `403 wrong_supplier` on every job.
|
||||
- `OPENAI_API_KEY` (empty is legal only for unauthenticated backends).
|
||||
- The shared plumbing block from §3.
|
||||
- `ADVERT_REF=` left as placeholder until §4.8.
|
||||
|
||||
Backend selection (`LLM_BACKEND=openai`, `OPENAI_BASE_URL`,
|
||||
`OPENAI_TIMEOUT_MS`, `CAPABILITY_KIND`) lives in the compose file (§4.4),
|
||||
not the env file.
|
||||
|
||||
### 4.7 First start
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --build
|
||||
```
|
||||
|
||||
Verify before going on-chain:
|
||||
|
||||
- `docker logs marketplace-mainnet-supplier-<name> --tail 50` — no
|
||||
crash-loop.
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}` (proves DNS, cert
|
||||
issuance, and routing end-to-end).
|
||||
|
||||
Every deploy on your box is manual: `git pull` +
|
||||
`docker compose ... up -d --build`. No CD pipeline covers it.
|
||||
|
||||
### 4.8 Post the advert (go-live — do this LAST)
|
||||
|
||||
The advert is the go-live flag: the moment it confirms, the indexer starts
|
||||
probing `<endpoint>/status` and buyers can lock escrows. Post it only after
|
||||
§4.7 is green, so buyers never hit a dead endpoint.
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model '<MODEL_ID>' \
|
||||
--max-output-tokens <CTX_LEN> \
|
||||
--max-processing-ms 300000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://<your-dns-name>
|
||||
# chat variant: --capability-id llm.chat.v1 --max-processing-ms 1800000
|
||||
```
|
||||
|
||||
Paste the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env.<name>`,
|
||||
then:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Note: adverts are immutable — changing the model, price, or endpoint later
|
||||
requires `tx:retire-advert` (refunds the advert bond) + a fresh post-advert
|
||||
+ a new `ADVERT_REF`.
|
||||
|
||||
## 5. Verification checklist
|
||||
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}`.
|
||||
- `/capability` on the same host shows the right model + pkh.
|
||||
- Public marketplace indexer lists your advert:
|
||||
`curl https://mp-indexer.vector.apexfusion.org/suppliers` → your entry with
|
||||
`advert_status: "Active"`, `status: "free"`. The indexer follows the chain,
|
||||
so a confirmed advert appears automatically — no registration step.
|
||||
(The buyer API and OpenAI gateway at `marketplace.vector.apexfusion.org` /
|
||||
`api.marketplace.vector.apexfusion.org` require buyer API keys; buyers with
|
||||
keys will see your model in the gateway's `/openai/v1/models`.)
|
||||
- Tail `docker logs -f marketplace-mainnet-supplier-<name>` through the
|
||||
first paid jobs; watch for `openai_malformed` (reasoning-model
|
||||
content-shape risk) and `403 wrong_supplier` (missing derived wallet vars).
|
||||
|
||||
## 6. Ongoing operations
|
||||
|
||||
- **Update**: `git pull && docker compose -f ... up -d --build`.
|
||||
- **Model swap**: `tx:retire-advert` → post-advert with the new `--model` →
|
||||
new `ADVERT_REF` in the env file → `up -d --force-recreate`.
|
||||
- **Wallet health**: keep the 2-UTxO shape; run `tx:consolidate-wallet` if
|
||||
script-spends start failing with collateral-selector errors.
|
||||
- **Exit**: `tx:retire-advert` refunds the advert bond and delists you; the
|
||||
wallet keeps its funds.
|
||||
- **Monitoring (optional)**: the repo ships a balance monitor
|
||||
(`wallet-monitor/`, `buyer/scripts/monitor-wallets.ts`) you can run against
|
||||
your own wallet list.
|
||||
|
||||
## 7. Footguns (each has burned an operator before)
|
||||
|
||||
1. `OPENAI_BASE_URL` must NOT end in `/v1`.
|
||||
2. Advert `--model` must match the upstream id byte-for-byte; slashes are
|
||||
fine on-chain.
|
||||
3. Do not set `OPENAI_REASONING` unless the backend is OpenRouter.
|
||||
4. `OPENAI_TIMEOUT_MS` > advert `max_processing_ms` = bond-forfeit window.
|
||||
5. Model ids containing `kimi`, `deepseek`, or `gpt` (case-insensitive) are
|
||||
auto-enrolled in the marketplace buyer's PDF-summarizer pool
|
||||
(`buyer/src/pdf/caps.ts`), so expect PDF jobs too. The
|
||||
`PDF_MODEL_ALLOWLIST` / `PDF_MODEL_DENYLIST` overrides are buyer-side —
|
||||
only relevant if you also operate your own buyer.
|
||||
6. `LIVE_CHAIN` accepts only the literal `"1"`; `true`/`yes` silently means
|
||||
off, and the supplier then never broadcasts.
|
||||
7. The compose `env_file` uses `required: false`, so a typo'd env path comes
|
||||
up and crash-loops instead of failing to parse — check logs on first boot.
|
||||
8. Wallet fragmentation (>2 UTxOs, none ≥5 AP3X) breaks all script-spends
|
||||
with a lucid collateral selector error; fix with `tx:consolidate-wallet`.
|
||||
|
||||
## 8. Key references in this repo
|
||||
|
||||
- `deploy/inference-proxy/README.md` — a live standalone box built exactly
|
||||
this way (two suppliers, incl. a tailnet-backed home rig).
|
||||
- `deploy/README.md` — networks, Traefik, per-service ops.
|
||||
- `docs/HETZNER_SETUP.md` — the Apex Fusion fleet runbook (hosted-API
|
||||
suppliers at scale).
|
||||
- `docs/HUGGINGFACE_ROUTER_SETUP.md` — OpenRouter/HF-router backend variant.
|
||||
- `supplier/.env.example` — every env var, annotated (plus `.env.*.example`
|
||||
variants per backend).
|
||||
- `supplier/src/cli/` — `gen-keypair`, `post-advert`, `retire-advert`,
|
||||
`consolidate-wallet`, `publish-reference-scripts`.
|
||||
@@ -1,8 +1,13 @@
|
||||
# Hetzner Inference Supplier Fleet — Setup Runbook
|
||||
|
||||
> **Status:** shipped. Configures 8 mainnet supplier nodes that use **Hetzner
|
||||
> Inference** (`https://inference.hetzner.com`) as their **compute backend** for
|
||||
> bonded inference work commissioned on Vector. Config only — no code changes.
|
||||
> **Status:** shipped; fleet trimmed 2026-08-21. Hetzner retired
|
||||
> `Kimi-K2.7-Code`, `GLM-5.2-NVFP4`, and `DeepSeek-V4-Flash-0731` from
|
||||
> Inference (gone from `/v1/models`), so the 6 suppliers serving them were
|
||||
> deregistered (adverts retired on-chain, containers removed). `Qwen3.8-27B`
|
||||
> was added in their place, reusing the retired kimi-code wallets. Configures
|
||||
> mainnet supplier nodes that use **Hetzner Inference**
|
||||
> (`https://inference.hetzner.com`) as their **compute backend** for bonded
|
||||
> inference work commissioned on Vector. Config only — no code changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,19 +20,21 @@ upstream client (`supplier/src/openai.ts`) already speaks plain
|
||||
This is the same pattern as the DeepSeek-direct and OpenRouter suppliers, and
|
||||
the direct sibling of `docs/HUGGINGFACE_ROUTER_SETUP.md`.
|
||||
|
||||
One supplier per (model × capability) — 4 models, both the one-off
|
||||
One supplier per (model × capability) — both the one-off
|
||||
`llm.text.generate.v1` and the multi-turn `llm.chat.v1` capability:
|
||||
|
||||
| Supplier name | Model (on-chain + API param, verbatim) | Capability | Advert max_output_tokens |
|
||||
|---|---|---|---|
|
||||
| `kimi-code` | `Kimi-K2.7-Code` | `llm.text.generate.v1` | 262144 |
|
||||
| `kimi-code-chat` | `Kimi-K2.7-Code` | `llm.chat.v1` | 262144 |
|
||||
| `glm` | `GLM-5.2-NVFP4` | `llm.text.generate.v1` | 512000 |
|
||||
| `glm-chat` | `GLM-5.2-NVFP4` | `llm.chat.v1` | 512000 |
|
||||
| `ds-flash-htz` | `DeepSeek-V4-Flash-0731` | `llm.text.generate.v1` | 512000 |
|
||||
| `ds-flash-htz-chat` | `DeepSeek-V4-Flash-0731` | `llm.chat.v1` | 512000 |
|
||||
| `qwen35b` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen35b-chat` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.chat.v1` | 262144 |
|
||||
| `qwen38` | `Qwen3.8-27B` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen38-chat` | `Qwen3.8-27B` | `llm.chat.v1` | 262144 |
|
||||
|
||||
Deregistered 2026-08-21 (models dropped by Hetzner): `kimi-code`,
|
||||
`kimi-code-chat` (`Kimi-K2.7-Code`), `glm`, `glm-chat` (`GLM-5.2-NVFP4`),
|
||||
`ds-flash-htz`, `ds-flash-htz-chat` (`DeepSeek-V4-Flash-0731`). The qwen38
|
||||
pair reuses the kimi-code / kimi-code-chat wallets; the other four wallets
|
||||
still hold funds and their env files remain on the host.
|
||||
|
||||
Shared advert parameters: price **200000 lovelace (0.2 AP3X)** flat per job,
|
||||
bonds **1000000 lovelace (1 AP3X)** both sides, `max_processing_ms` **300000**
|
||||
@@ -36,41 +43,47 @@ for one-off / **1800000** for chat (the session spans the whole conversation).
|
||||
forwarded upstream (`OPENAI_MAX_TOKENS` unset), so the advert value only gates
|
||||
what buyers may request.
|
||||
|
||||
Names follow the brand-by-model convention; `ds-flash-htz` carries a `-htz`
|
||||
suffix only because `deepseek-flash-*` was already taken by the OpenRouter
|
||||
fleet serving `deepseek/deepseek-v4-flash`.
|
||||
Names follow the brand-by-model convention; the retired `ds-flash-htz`
|
||||
carried a `-htz` suffix only because `deepseek-flash-*` was already taken by
|
||||
the OpenRouter fleet serving `deepseek/deepseek-v4-flash`.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
1. A Hetzner account with Inference access and an API key (usage-based billing
|
||||
to you as the node operator — the flat 0.2 AP3X job price is a business
|
||||
choice, not cost-derived). One key is shared by all 8 suppliers; Hetzner
|
||||
choice, not cost-derived). One key is shared by all suppliers; Hetzner
|
||||
rate limits apply to the aggregate.
|
||||
2. Eight funded supplier wallets (50 AP3X each recommended; bonds are 1 AP3X
|
||||
per in-flight job and returned on completion).
|
||||
3. The usual mainnet supplier deploy prerequisites (`deploy/README.md`); the
|
||||
wildcard DNS `*.vector.apexfusion.org` already covers the new hosts.
|
||||
2. One funded supplier wallet per supplier (50 AP3X each recommended; bonds
|
||||
are 1 AP3X per in-flight job and returned on completion).
|
||||
3. The usual mainnet supplier deploy prerequisites (`deploy/README.md`).
|
||||
4. **DNS**: one A record per supplier — `mp-suppliers-<name>.vector.apexfusion.org`
|
||||
→ `91.98.147.172`, **DNS-only/unproxied**. There is NO wildcard for
|
||||
`*.vector.apexfusion.org`; and because traefik issues certs on-box via
|
||||
Let's Encrypt HTTP-01 (port 80), a proxied/CDN record would break issuance.
|
||||
Certs are issued automatically within ~a minute of the record propagating —
|
||||
no restarts needed.
|
||||
|
||||
## 3. Verify the backend before spending on-chain
|
||||
|
||||
The four model ids come from `GET https://inference.hetzner.com/api/v1/models`
|
||||
The model ids come from `GET https://inference.hetzner.com/api/v1/models`
|
||||
(this list changes — Hetzner retires models; re-check before any advert)
|
||||
and are sent **verbatim** as the upstream `model` param (the runtime uses
|
||||
`advert.model`). Before posting adverts, smoke-test each model:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://inference.hetzner.com/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $HETZNER_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"GLM-5.2-NVFP4","messages":[{"role":"user","content":"ping"}]}' \
|
||||
-d '{"model":"Qwen3.8-27B","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
`choices[0].message.content` must be a non-empty string with **no `max_tokens`
|
||||
sent** (matches runtime config) — the supplier throws `openai_malformed`
|
||||
otherwise and forfeits its 1 AP3X bond per failed job. For chat suppliers also
|
||||
verify a `tools`/`tool_choice` round-trip. All four models passed both checks
|
||||
on 2026-08-11.
|
||||
verify a `tools`/`tool_choice` round-trip. The original four models passed
|
||||
both checks on 2026-08-11; `Qwen3.8-27B` passed both on 2026-08-21.
|
||||
|
||||
## 4. Per-supplier bring-up (repeat ×8)
|
||||
## 4. Per-supplier bring-up (repeat per supplier)
|
||||
|
||||
```bash
|
||||
# 1. wallet — prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts",
|
||||
"reseller-demo": "tsx src/cli/reseller-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/buyer": "workspace:*",
|
||||
|
||||
@@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js";
|
||||
|
||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||
|
||||
/** Bound on per-request escrow liveness lookups; rows past the cap count as
|
||||
* locked unverified (a key with this many active escrows has bigger problems). */
|
||||
const LIVENESS_CHECK_CAP = 32;
|
||||
|
||||
/** True when the escrow UTxO behind an indexer row is still unspent. The
|
||||
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
|
||||
* consume the escrow without a new escrow output), so "active" rows can be
|
||||
* long spent. Unparseable refs and failed lookups count as live so errors
|
||||
* inflate the locked figure rather than hide funds. */
|
||||
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
|
||||
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
|
||||
if (!m) return true;
|
||||
try {
|
||||
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
|
||||
return utxo !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function ap3x(lovelace: bigint): string {
|
||||
return (Number(lovelace) / 1e6).toFixed(2);
|
||||
}
|
||||
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as Array<Record<string, unknown>>;
|
||||
if (Array.isArray(rows)) {
|
||||
for (const row of rows) {
|
||||
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
|
||||
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
|
||||
const liveness = await Promise.all(
|
||||
active.map((row, i) =>
|
||||
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
if (!liveness[i]) continue;
|
||||
const row = active[i];
|
||||
lockedInEscrow +=
|
||||
BigInt(String(row.payment_lovelace ?? "0")) +
|
||||
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
|
||||
|
||||
216
gateway/src/cli/reseller-demo.ts
Normal file
216
gateway/src/cli/reseller-demo.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
interface DemoOptions {
|
||||
gatewayUrl: string;
|
||||
dashboardUrl: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface PublicJob {
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
upstream_cost_usd: string | null;
|
||||
ap3x_payout: string;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export function parseDemoArgs(argv: string[]): DemoOptions {
|
||||
const values: Record<string, string> = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!flag?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid demo argument near ${flag ?? "end"}`);
|
||||
}
|
||||
if (
|
||||
flag !== "--gateway-url" &&
|
||||
flag !== "--dashboard-url" &&
|
||||
flag !== "--model" &&
|
||||
flag !== "--prompt" &&
|
||||
flag !== "--max-tokens" &&
|
||||
flag !== "--timeout-ms"
|
||||
) {
|
||||
throw new Error(`unknown demo argument: ${flag}`);
|
||||
}
|
||||
values[flag] = value;
|
||||
}
|
||||
const gatewayUrl = requiredUrl(values["--gateway-url"], "--gateway-url");
|
||||
const dashboardUrl = requiredUrl(values["--dashboard-url"], "--dashboard-url");
|
||||
const model = requiredString(values["--model"], "--model");
|
||||
const prompt = values["--prompt"] ?? "Reply with one sentence that confirms this marketplace job completed.";
|
||||
const maxTokens = positiveInteger(values["--max-tokens"] ?? "64", "--max-tokens");
|
||||
const timeoutMs = positiveInteger(values["--timeout-ms"] ?? "900000", "--timeout-ms");
|
||||
return {
|
||||
gatewayUrl: gatewayUrl.replace(/\/+$/, ""),
|
||||
dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
prompt,
|
||||
maxTokens,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runResellerDemo(
|
||||
options: DemoOptions,
|
||||
gatewayApiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (gatewayApiKey === "") throw new Error("GATEWAY_API_KEY is required");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
try {
|
||||
const sellerResponse = await fetch(`${options.dashboardUrl}/api/status`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const sellerBody = await sellerResponse.json().catch(() => null) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
if (!sellerResponse.ok) {
|
||||
throw new Error(
|
||||
`seller status returned HTTP ${sellerResponse.status}`,
|
||||
);
|
||||
}
|
||||
const supplierPkh = sellerBody?.supplier_pkh;
|
||||
if (typeof supplierPkh !== "string" || !/^[0-9a-fA-F]{56}$/.test(supplierPkh)) {
|
||||
throw new Error("seller status omitted a valid supplier_pkh");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${options.gatewayUrl}/openai/v1/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${gatewayApiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [{ role: "user", content: options.prompt }],
|
||||
max_tokens: options.maxTokens,
|
||||
stream: false,
|
||||
public_preview: true,
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`gateway returned HTTP ${response.status}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
const escrowRef = response.headers.get("x-vector-escrow-ref");
|
||||
if (!escrowRef) throw new Error("gateway response omitted X-Vector-Escrow-Ref");
|
||||
const job = await waitForPublicJob(
|
||||
options.dashboardUrl,
|
||||
escrowRef,
|
||||
options.timeoutMs,
|
||||
controller.signal,
|
||||
);
|
||||
if (job.status === "failed") {
|
||||
throw new Error(`reseller job failed: ${job.failure_reason ?? "unknown"}`);
|
||||
}
|
||||
return {
|
||||
completion_id: body?.id ?? null,
|
||||
escrow_ref: escrowRef,
|
||||
model: body?.model ?? options.model,
|
||||
supplier_pkh: supplierPkh,
|
||||
upstream_cost_usd: job.upstream_cost_usd,
|
||||
ap3x_payout: job.ap3x_payout,
|
||||
dashboard_url: options.dashboardUrl,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPublicJob(
|
||||
dashboardUrl: string,
|
||||
escrowRef: string,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicJob> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(`${dashboardUrl}/api/jobs?limit=100`, {
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`dashboard jobs returned HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.json() as { jobs?: PublicJob[] };
|
||||
const job = body.jobs?.find((candidate) => candidate.escrow_ref === escrowRef);
|
||||
if (job) return job;
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 2_000));
|
||||
}
|
||||
throw new Error(`timed out waiting for dashboard settlement ${escrowRef}`);
|
||||
}
|
||||
|
||||
function requiredString(value: string | undefined, flag: string): string {
|
||||
if (!value || value.trim() === "") throw new Error(`${flag} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredUrl(value: string | undefined, flag: string): string {
|
||||
const raw = requiredString(value, flag);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${flag} must be a valid URL`);
|
||||
}
|
||||
const loopback =
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "[::1]";
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
||||
throw new Error(`${flag} must use HTTPS except on loopback`);
|
||||
}
|
||||
if (
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
throw new Error(`${flag} must not contain credentials, query, or fragment`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string, flag: string): number {
|
||||
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
||||
throw new Error(`${flag} must be a positive safe integer`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await runResellerDemo(
|
||||
parseDemoArgs(argv),
|
||||
env.GATEWAY_API_KEY ?? "",
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`reseller demo failed: ${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
void main(process.argv.slice(2), process.env).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const PKH_RE = /^[0-9a-fA-F]{56}$/;
|
||||
|
||||
export interface GatewayConfig {
|
||||
masterKeyHex: string;
|
||||
@@ -56,6 +57,8 @@ export interface GatewayConfig {
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
/** Seller preferred for matching one-shot models. Other suppliers remain fallback. */
|
||||
preferredSupplierPkh?: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -115,6 +118,17 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
}
|
||||
networkId = networkIdStr === "1" ? 1 : 0;
|
||||
}
|
||||
const preferredSupplierPkhRaw =
|
||||
(env.GATEWAY_PREFERRED_SUPPLIER_PKH ?? "").trim();
|
||||
if (
|
||||
preferredSupplierPkhRaw !== "" &&
|
||||
!PKH_RE.test(preferredSupplierPkhRaw)
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: GATEWAY_PREFERRED_SUPPLIER_PKH must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
masterKeyHex,
|
||||
@@ -142,6 +156,9 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
preferredSupplierPkh: preferredSupplierPkhRaw === ""
|
||||
? undefined
|
||||
: preferredSupplierPkhRaw.toLowerCase(),
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
|
||||
@@ -96,6 +96,8 @@ async function runOneShot(
|
||||
indexerUrl: config.indexerUrl,
|
||||
model: parsed.model,
|
||||
capabilityId: CAPABILITY,
|
||||
supplierPkh: parsed.supplierPkh,
|
||||
preferredSupplierPkh: config.preferredSupplierPkh,
|
||||
fetchFn,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
@@ -152,6 +154,7 @@ async function runOneShot(
|
||||
messages: parsed.messages,
|
||||
payment_lovelace: cand.priceLovelace,
|
||||
max_output_tokens: parsed.maxTokens,
|
||||
public_preview: parsed.publicPreview,
|
||||
});
|
||||
used = cand;
|
||||
break;
|
||||
@@ -167,6 +170,9 @@ async function runOneShot(
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
if (!parsed.stream) {
|
||||
res.setHeader("X-Vector-Escrow-Ref", originalRefStr);
|
||||
}
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
|
||||
@@ -71,6 +71,22 @@ export function toGatewayError(err: unknown): GatewayError {
|
||||
return new GatewayError(502, "server_error", "indexer_error", `indexer error: ${err.reason}`);
|
||||
}
|
||||
if (err instanceof TxConstructionError) {
|
||||
if (err.reason === "input_cap_exceeded") {
|
||||
return new GatewayError(
|
||||
400,
|
||||
"invalid_request_error",
|
||||
"input_cap_exceeded",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
if (err.reason === "supplier_preflight_failed") {
|
||||
return new GatewayError(
|
||||
503,
|
||||
"server_error",
|
||||
"supplier_preflight_failed",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
return new GatewayError(502, "server_error", "escrow_failed", `escrow build failed: ${err.reason}`);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ParsedChatRequest {
|
||||
stream: boolean;
|
||||
tools?: unknown[];
|
||||
toolChoice?: unknown;
|
||||
publicPreview: boolean;
|
||||
supplierPkh?: string;
|
||||
}
|
||||
|
||||
const ROLES = new Set(["system", "user", "assistant"]);
|
||||
@@ -83,6 +85,46 @@ export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean })
|
||||
maxTokens = mt;
|
||||
}
|
||||
|
||||
if (b.public_preview !== undefined && typeof b.public_preview !== "boolean") {
|
||||
throw badRequest(
|
||||
"invalid_public_preview",
|
||||
"`public_preview` must be a boolean when provided",
|
||||
);
|
||||
}
|
||||
const publicPreview = b.public_preview === true;
|
||||
|
||||
let supplierPkh: string | undefined;
|
||||
if (b.x_vector !== undefined) {
|
||||
if (
|
||||
typeof b.x_vector !== "object" ||
|
||||
b.x_vector === null ||
|
||||
Array.isArray(b.x_vector)
|
||||
) {
|
||||
throw badRequest("invalid_x_vector", "`x_vector` must be an object");
|
||||
}
|
||||
const requested = (b.x_vector as Record<string, unknown>).supplier_pkh;
|
||||
if (
|
||||
requested !== undefined &&
|
||||
(typeof requested !== "string" || !/^[0-9a-fA-F]{56}$/.test(requested))
|
||||
) {
|
||||
throw badRequest(
|
||||
"invalid_supplier_pkh",
|
||||
"`x_vector.supplier_pkh` must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
if (typeof requested === "string") supplierPkh = requested.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
const stream = b.stream === true;
|
||||
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
maxTokens,
|
||||
stream,
|
||||
tools,
|
||||
toolChoice,
|
||||
publicPreview,
|
||||
supplierPkh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface SelectSupplierOpts {
|
||||
* already pins the model). */
|
||||
model: string;
|
||||
capabilityId: string;
|
||||
/** Optional exact seller identity pin for deterministic agent routing. */
|
||||
supplierPkh?: string;
|
||||
/** Preferred seller ordered first when it is eligible. */
|
||||
preferredSupplierPkh?: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
/** supplier_pkhs eligible regardless of polled status — used after the demo
|
||||
* executor frees a supplier the indexer poller (~20 s cadence) still reports
|
||||
@@ -86,6 +90,9 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.capability_id !== opts.capabilityId) continue;
|
||||
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||
if (opts.supplierPkh !== undefined && raw.supplier_pkh !== opts.supplierPkh) {
|
||||
continue;
|
||||
}
|
||||
if (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
@@ -106,8 +113,16 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
status: raw.status,
|
||||
});
|
||||
}
|
||||
// Prefer known-free over unknown so we route to confirmed-available first.
|
||||
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
|
||||
// Prefer the configured seller, then known-free over unknown.
|
||||
candidates.sort((left, right) => {
|
||||
const leftRank =
|
||||
(left.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(left.status === "free" ? 0 : 1);
|
||||
const rightRank =
|
||||
(right.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(right.status === "free" ? 0 : 1);
|
||||
return leftRank - rightRank;
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
|
||||
import { deriveWalletKey } from "../wallet.js";
|
||||
import type { ApiKeyRow } from "../db/store.js";
|
||||
|
||||
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
|
||||
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
|
||||
* anything past this is the 2026-08-12 freeze mode (a fetch that never
|
||||
* settles), not a slow success. */
|
||||
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
|
||||
* a queue this deep means the head is stuck and callers should fail fast. */
|
||||
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
|
||||
|
||||
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
|
||||
export class MutexTimeoutError extends Error {
|
||||
constructor(label: string, timeoutMs: number) {
|
||||
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
|
||||
this.name = "MutexTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** run() was refused because the queue is at its bound (head likely stuck). */
|
||||
export class MutexQueueFullError extends Error {
|
||||
constructor(label: string, depth: number) {
|
||||
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
|
||||
this.name = "MutexQueueFullError";
|
||||
}
|
||||
}
|
||||
|
||||
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
|
||||
// Node-only nicety: don't hold the process open for a pending deadline.
|
||||
timer.unref?.();
|
||||
fn().then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
rejectPromise(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
|
||||
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
|
||||
*
|
||||
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
|
||||
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
|
||||
* A zombie that settles later can at worst lose a UTxO-contention race with
|
||||
* the run that replaced it — strictly better than the alternative, where one
|
||||
* stuck chain call convoys every subsequent request on the key forever.
|
||||
*/
|
||||
export class Mutex {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private depth = 0;
|
||||
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(fn, fn);
|
||||
// Keep the chain alive even if fn rejects; swallow here so the next
|
||||
// run() isn't poisoned by a prior rejection.
|
||||
this.tail = result.then(
|
||||
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
|
||||
|
||||
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
|
||||
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
|
||||
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
|
||||
|
||||
if (this.depth >= maxQueue) {
|
||||
return Promise.reject(new MutexQueueFullError(label, this.depth));
|
||||
}
|
||||
this.depth++;
|
||||
|
||||
// Keep the chain alive even if fn rejects or times out; swallow here so
|
||||
// the next run() isn't poisoned by a prior rejection.
|
||||
const result = this.tail.then(
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
);
|
||||
const settled = result.finally(() => {
|
||||
this.depth--;
|
||||
});
|
||||
this.tail = settled.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
return settled;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ function corsMiddleware(allowOrigins: string[]) {
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Expose-Headers", "X-Vector-Escrow-Ref");
|
||||
res.setHeader("Access-Control-Max-Age", "600");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
|
||||
@@ -70,6 +70,28 @@ describe("routing/selectSupplier", () => {
|
||||
expect(got[0].priceLovelace).toBe(2n);
|
||||
});
|
||||
|
||||
it("pins routing to an exact supplier when requested", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
supplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("orders the preferred supplier first and keeps fallback candidates", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
preferredSupplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2", "s1"]);
|
||||
});
|
||||
|
||||
it("routes chat.v1 separately", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
@@ -118,6 +140,20 @@ describe("openai/validate", () => {
|
||||
expect(p.maxTokens).toBe(50);
|
||||
expect(p.stream).toBe(false);
|
||||
});
|
||||
it("accepts and validates a Vector supplier pin", () => {
|
||||
const supplierPkh = "a".repeat(56);
|
||||
const parsed = parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
});
|
||||
expect(parsed.supplierPkh).toBe(supplierPkh);
|
||||
expect(() => parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: "bad" },
|
||||
})).toThrow(GatewayError);
|
||||
});
|
||||
it("rejects tools/functions", () => {
|
||||
expect(() => parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], tools: [] })).toThrow(GatewayError);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PostAdvertParams {
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function buildPostAdvertTx(
|
||||
@@ -75,6 +77,7 @@ export async function buildPostAdvertTx(
|
||||
walletKey,
|
||||
advertDatum,
|
||||
deposit_lovelace,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,13 +105,15 @@ export async function buildPostAdvertTx(
|
||||
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
export interface ReleaseParams {
|
||||
chain: ChainProvider;
|
||||
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
|
||||
}
|
||||
|
||||
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
|
||||
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
|
||||
const tipSlot = await chain.tip();
|
||||
const tipMs = mockSlotToWallclockMs(tipSlot);
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
if (tipMs < threshold) {
|
||||
throw new TxConstructionError(
|
||||
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
|
||||
);
|
||||
}
|
||||
|
||||
// Live path: real Cardano CBOR via lucid-evolution.
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForRelease({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
supplierKey,
|
||||
escrowRef,
|
||||
escrowUtxo: utxo,
|
||||
datum,
|
||||
tipMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock path: synthetic testTxBody (preserves existing test behaviour).
|
||||
const supplierDue =
|
||||
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SubmitParams {
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
receiptHash: string;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
function isHex32(s: string): boolean {
|
||||
@@ -113,6 +115,7 @@ export async function buildSubmitTx(
|
||||
deliverBy: datum.deliver_by,
|
||||
tipMs,
|
||||
receiptHash: receiptHash.toLowerCase(),
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +141,7 @@ export async function buildSubmitTx(
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
await params.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||
export { TxConstructionError } from "./types.js";
|
||||
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
|
||||
export {
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
chatInputTokenUpperBound,
|
||||
} from "./inputBound.js";
|
||||
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
||||
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
||||
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
|
||||
|
||||
26
packages/shared/src/tx/inputBound.ts
Normal file
26
packages/shared/src/tx/inputBound.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ChatMessage } from "./types.js";
|
||||
import { canonicalize } from "../cbor/canonical.js";
|
||||
|
||||
/** Advert detail-URI marker for suppliers that require live input-cap preflight. */
|
||||
export const BOUNDED_INPUT_DETAIL_MARKER = "#vector-bounded-input-v1";
|
||||
|
||||
const CHAT_MESSAGE_OVERHEAD = 8;
|
||||
const CHAT_REPLY_PRIMER = 3;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Conservative, tokenizer-independent upper bound for text chat input.
|
||||
*
|
||||
* Every UTF-8 byte counts as one token unit. The fixed per-message and reply
|
||||
* overhead covers role separators and the provider chat template. Major
|
||||
* byte-fallback tokenizers cannot emit more content tokens than input bytes.
|
||||
*/
|
||||
export function chatInputTokenUpperBound(
|
||||
messages: readonly ChatMessage[],
|
||||
): number {
|
||||
return (
|
||||
CHAT_REPLY_PRIMER +
|
||||
CHAT_MESSAGE_OVERHEAD * messages.length +
|
||||
encoder.encode(canonicalize(messages)).byteLength
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
|
||||
* does not validate address network for inputs (only for outputs).
|
||||
*
|
||||
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
|
||||
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
|
||||
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
|
||||
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
|
||||
*
|
||||
* Catherine M1-F-4-green.
|
||||
*/
|
||||
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
|
||||
const REDEEMER_SUBMIT = 122;
|
||||
const REDEEMER_ACCEPT = 123;
|
||||
const REDEEMER_RECLAIM = 124;
|
||||
const REDEEMER_RELEASE = 125;
|
||||
|
||||
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
|
||||
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
|
||||
@@ -100,6 +101,7 @@ export interface LiveSubmitParams {
|
||||
deliverBy: number;
|
||||
tipMs: number;
|
||||
receiptHash: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveAcceptParams {
|
||||
@@ -121,11 +123,21 @@ export interface LiveReclaimParams {
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReleaseParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
escrowUtxo: Utxo;
|
||||
datum: EscrowDatum;
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LivePostAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveRetireAdvertParams {
|
||||
@@ -489,6 +501,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
validFromMs: number;
|
||||
validToMs: number;
|
||||
signerPkh: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(opts.chain);
|
||||
const { lucid } = await createLucidContext(provider, opts.walletKey, {
|
||||
@@ -575,6 +588,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await opts.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
|
||||
await opts.chain.submitTx(txCborHex);
|
||||
|
||||
@@ -640,6 +654,7 @@ export async function buildLiveTxForSubmit(params: LiveSubmitParams): Promise<Bu
|
||||
validFromMs: tipMs,
|
||||
validToMs: stampMs,
|
||||
signerPkh: params.supplierKey.pubKeyHash,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -778,6 +793,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── Release ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(params.chain);
|
||||
const { lucid } = await createLucidContext(provider, params.supplierKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: escrowAddress } = loadEscrowScript();
|
||||
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
|
||||
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
|
||||
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
|
||||
|
||||
if (params.datum.submitted_at === null) {
|
||||
throw new TxConstructionError(
|
||||
"submitted_at missing",
|
||||
"Submitted-state escrow must carry a submitted_at timestamp",
|
||||
);
|
||||
}
|
||||
|
||||
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
|
||||
const supplierDue =
|
||||
params.datum.payment_lovelace +
|
||||
params.datum.supplier_bond_lovelace +
|
||||
params.datum.buyer_bond_lovelace;
|
||||
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
|
||||
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
|
||||
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
|
||||
// would violate the validator whenever the tip is less than 60s past the window.
|
||||
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = (
|
||||
spendCtx.kind === "ref"
|
||||
? lucid.newTx().readFrom([spendCtx.refUtxo])
|
||||
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
|
||||
)
|
||||
.collectFrom([lucidInput], releaseRedeemerHex)
|
||||
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
|
||||
.addSignerKey(params.supplierKey.pubKeyHash)
|
||||
.validFrom(validFromMs)
|
||||
.validTo(params.tipMs + 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
|
||||
// falsely rejects valid Plutus V3 spends; route to Ogmios.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "release build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await params.chain.submitTx(txCborHex);
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── PostAdvert ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForAdvert(
|
||||
@@ -829,14 +915,16 @@ export async function buildLiveTxForAdvert(
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
// ─── RetireAdvert ────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,3 +45,16 @@ export type {
|
||||
WithdrawWalletBuildResult,
|
||||
} from "./wallet/withdrawWallet.js";
|
||||
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
|
||||
|
||||
// ── Surplus Merkle-batch anchor (live-only) ────────────────────────────
|
||||
export type {
|
||||
AnchorMetadataParams,
|
||||
SurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
export {
|
||||
MAX_TRANSACTION_METADATA_TEXT_BYTES,
|
||||
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
buildAnchorMetadataTx,
|
||||
validateSurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
|
||||
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Builds and submits the live wallet transaction that anchors one Surplus sale
|
||||
* Merkle batch. The caller remains responsible for awaiting confirmation.
|
||||
*/
|
||||
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
|
||||
import { createLucidContext } from "../internal/lucidContext.js";
|
||||
import type { BuildResult, WalletKey } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
|
||||
export const SURPLUS_SALE_PROOF_METADATA_LABEL = 674;
|
||||
export const SURPLUS_SALE_PROOF_PROTOCOL = "surplus-sale-proof-v1";
|
||||
export const MAX_TRANSACTION_METADATA_TEXT_BYTES = 64;
|
||||
|
||||
export interface SurplusSaleProofAnchorMetadata {
|
||||
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
|
||||
root: string;
|
||||
count: number;
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
export interface AnchorMetadataParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
metadata: SurplusSaleProofAnchorMetadata;
|
||||
}
|
||||
|
||||
const ROOT_HEX_RE = /^[0-9a-f]{64}$/;
|
||||
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/;
|
||||
const METADATA_KEYS = ["p", "root", "count", "first", "last"] as const;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function metadataTextFits(value: string): boolean {
|
||||
return textEncoder.encode(value).byteLength <= MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||
}
|
||||
|
||||
function validSaleId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
PRINTABLE_ASCII_RE.test(value) &&
|
||||
metadataTextFits(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate the exact label-674 payload before Lucid or Ogmios is initialized. */
|
||||
export function validateSurplusSaleProofAnchorMetadata(
|
||||
value: unknown,
|
||||
): asserts value is SurplusSaleProofAnchorMetadata {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"Surplus sale proof metadata must be an object",
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = value as Record<string, unknown>;
|
||||
const keys = Object.keys(metadata);
|
||||
if (
|
||||
keys.length !== METADATA_KEYS.length ||
|
||||
METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(metadata, key))
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"Surplus sale proof metadata must contain only p, root, count, first, and last",
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata.p !== SURPLUS_SALE_PROOF_PROTOCOL) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
`metadata protocol must be ${SURPLUS_SALE_PROOF_PROTOCOL}`,
|
||||
);
|
||||
}
|
||||
if (!metadataTextFits(SURPLUS_SALE_PROOF_PROTOCOL)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_oversized",
|
||||
"metadata protocol exceeds the 64-byte transaction metadata text limit",
|
||||
);
|
||||
}
|
||||
if (typeof metadata.root !== "string" || !ROOT_HEX_RE.test(metadata.root)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"metadata root must be 64 lowercase hexadecimal characters",
|
||||
);
|
||||
}
|
||||
if (!metadataTextFits(metadata.root)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_oversized",
|
||||
"metadata root exceeds the 64-byte transaction metadata text limit",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof metadata.count !== "number" ||
|
||||
!Number.isSafeInteger(metadata.count) ||
|
||||
metadata.count <= 0
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"metadata count must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of ["first", "last"] as const) {
|
||||
const id = metadata[field];
|
||||
if (!validSaleId(id)) {
|
||||
const oversized =
|
||||
typeof id === "string" &&
|
||||
textEncoder.encode(id).byteLength > MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||
throw new TxConstructionError(
|
||||
oversized ? "anchor_metadata_oversized" : "anchor_metadata_invalid",
|
||||
`metadata ${field} must be a non-empty printable ASCII sale ID of at most 64 bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAnchorMetadataTx(
|
||||
params: AnchorMetadataParams,
|
||||
): Promise<BuildResult> {
|
||||
const { chain, walletKey, metadata } = params;
|
||||
validateSurplusSaleProofAnchorMetadata(metadata);
|
||||
|
||||
const provider = new OgmiosLucidProvider({
|
||||
ogmiosUrl: chain.url,
|
||||
fetch: chain.fetchImpl,
|
||||
});
|
||||
const { lucid } = await createLucidContext(
|
||||
provider,
|
||||
walletKey,
|
||||
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
|
||||
{ usePresetProtocolParameters: true },
|
||||
);
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
const anchorMetadata = {
|
||||
p: metadata.p,
|
||||
root: metadata.root,
|
||||
count: metadata.count,
|
||||
first: metadata.first,
|
||||
last: metadata.last,
|
||||
};
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.attachMetadata(SURPLUS_SALE_PROOF_METADATA_LABEL, anchorMetadata)
|
||||
.addSignerKey(walletKey.pubKeyHash)
|
||||
.setMinFee(500_000n);
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs: realWalletUtxos,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new TxConstructionError("anchor_metadata_build_failed", message);
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@@ -321,10 +321,16 @@ importers:
|
||||
express:
|
||||
specifier: ^4.21.2
|
||||
version: 4.22.1
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.23.0
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.6
|
||||
'@types/pg':
|
||||
specifier: ^8.15.5
|
||||
version: 8.23.1
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
@@ -1244,6 +1250,9 @@ packages:
|
||||
'@types/pdf-parse@1.1.5':
|
||||
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -2360,6 +2369,40 @@ packages:
|
||||
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
|
||||
engines: {node: '>=6.8.1'}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||
|
||||
pg-connection-string@2.14.0:
|
||||
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.14.0:
|
||||
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.16.0:
|
||||
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.23.0:
|
||||
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2430,6 +2473,22 @@ packages:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postgres-bytea@1.0.1:
|
||||
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-date@1.0.7:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2640,6 +2699,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
|
||||
@@ -3863,6 +3926,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.15.0': {}
|
||||
@@ -4947,6 +5016,41 @@ snapshots:
|
||||
dependencies:
|
||||
node-ensure: 0.0.0
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.14.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.14.0(pg@8.23.0):
|
||||
dependencies:
|
||||
pg: 8.23.0
|
||||
|
||||
pg-protocol@1.16.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.1
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.23.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.14.0
|
||||
pg-pool: 3.14.0(pg@8.23.0)
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
@@ -4997,6 +5101,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
|
||||
postgres-date@1.0.7: {}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -5282,6 +5396,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.1.3: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
@@ -132,3 +132,51 @@ ADVERT_DETAIL_HASH=
|
||||
# MAX_CHAT_SESSIONS=1 # concurrent session slots (default 1)
|
||||
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit) or "ticket" (no supplier
|
||||
# # chain ops; buyer reclaims the Open escrow)
|
||||
|
||||
# ─── Autonomous Surplus seller (surplus:start) ────────────────────────────
|
||||
# This service uses a seller-scoped Surplus key. It does not need a wallet
|
||||
# private key. Keep both API keys in the host-only surplus-seller.env file.
|
||||
SURPLUS_LIVE=
|
||||
SURPLUS_API_URL=https://api.surplusintelligence.ai
|
||||
SURPLUS_SELLER_API_KEY=si_seller_REPLACE_ME
|
||||
SURPLUS_OPENROUTER_API_KEY=sk-or-v1-REPLACE_ME
|
||||
SURPLUS_PROVIDER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_CAPACITY_BASE_URL=https://openrouter.ai/api
|
||||
SURPLUS_SELLER_WALLET=0x0000000000000000000000000000000000000000
|
||||
SURPLUS_PAYOUT_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
# The controller discovers all fixed-price text models and selects one by
|
||||
# current demand. It keeps only one active offer. At the default 490 bps
|
||||
# recovery floor, a $0.05 seller cap bounds upstream spend near $1.02 per day.
|
||||
SURPLUS_PER_OFFER_CAP_USD=0.05
|
||||
SURPLUS_AGGREGATE_CAP_USD=0.05
|
||||
SURPLUS_OPENROUTER_RESERVE_USD=1.00
|
||||
SURPLUS_OPENROUTER_MAX_LIMIT_USD=20.00
|
||||
SURPLUS_MIN_RECOVERY_BPS=490
|
||||
SURPLUS_UNDERCUT_BPS=10
|
||||
SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10
|
||||
SURPLUS_REPRICE_INTERVAL_MS=300000
|
||||
SURPLUS_REQUEST_TIMEOUT_MS=120000
|
||||
# 0 keeps selling. 1 pauses after the first trade or one offer-cap of spend.
|
||||
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0
|
||||
SURPLUS_SETTLED_STATUSES=confirmed
|
||||
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
|
||||
# --- Vector proof publisher ------------------------------------------------
|
||||
# When enabled, every settled Base settlement transaction gets one Vector
|
||||
# counterpart: the sales it settled are Merkle-batched and anchored in a
|
||||
# label-674 metadata transaction signed by the proof wallet below.
|
||||
SURPLUS_PROOF_ENABLED=
|
||||
# Required when enabled. Mainnet Ogmios endpoint and the 64-hex Ed25519 key
|
||||
# of the AP3X proof wallet (the retired Vector supplier wallet).
|
||||
OGMIOS_URL=
|
||||
SURPLUS_PROOF_WALLET_PRIV_KEY_HEX=
|
||||
SURPLUS_PROOF_LEDGER_PATH=/var/lib/surplus-manager/vector-proofs.json
|
||||
# Anchoring pauses when the wallet cannot keep this reserve after one fee.
|
||||
SURPLUS_PROOF_RESERVE_LOVELACE=5000000
|
||||
SURPLUS_PROOF_FEE_BUDGET_LOVELACE=1000000
|
||||
SURPLUS_PROOF_INTERVAL_MS=300000
|
||||
SURPLUS_PROOF_CONFIRM_TIMEOUT_MS=300000
|
||||
# Optional support-export backfill. The CSV must have the exact header:
|
||||
# request_id,settled_at,model,input_tokens,output_tokens,seller_earned_usd,settlement_status,settlement_type,tx_hash
|
||||
# The offer id is stamped into each export leaf because the export omits it.
|
||||
SURPLUS_PROOF_HISTORY_CSV_PATH=
|
||||
SURPLUS_PROOF_HISTORY_OFFER_ID=
|
||||
|
||||
@@ -70,6 +70,9 @@ COPY --from=builder /repo /repo
|
||||
|
||||
# Drop privileges. The official node image ships a non-root `node` user.
|
||||
RUN chown -R node:node /repo
|
||||
RUN mkdir -p /var/lib/surplus-manager \
|
||||
&& chown node:node /var/lib/surplus-manager \
|
||||
&& chmod 700 /var/lib/surplus-manager
|
||||
USER node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
"tx:post-advert": "tsx src/cli/post-advert.ts",
|
||||
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
|
||||
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts",
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts",
|
||||
"reseller:setup": "tsx src/cli/setup-reseller.ts",
|
||||
"surplus:start": "tsx src/cli/surplus-seller.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/shared": "workspace:*",
|
||||
"@noble/ed25519": "^2.1.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"bech32": "^2.0.0",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
detectCborBackend,
|
||||
mockSlotToWallclockMs,
|
||||
TxConstructionError,
|
||||
type PostAdvertBuildResult,
|
||||
type WalletKey,
|
||||
} from "@marketplace/shared/tx";
|
||||
|
||||
@@ -34,6 +35,8 @@ export interface PostAdvertFlowParams {
|
||||
* in that order. Defaults to console.log.
|
||||
*/
|
||||
log?: (line: string) => void;
|
||||
/** Durable journal hook invoked with the signed transaction before broadcast. */
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PostAdvertFlowResult {
|
||||
@@ -110,6 +113,7 @@ export async function runPostAdvert(
|
||||
walletKey,
|
||||
advertDatum: datumToSubmit,
|
||||
deposit_lovelace: datumToSubmit.supplier_bond_lovelace,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
|
||||
writeLog(`submitted tx ${built.expectedTxHash}`);
|
||||
|
||||
486
supplier/src/cli/setup-reseller.ts
Normal file
486
supplier/src/cli/setup-reseller.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { chmod, mkdir, open, rename } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { LiveOgmiosProvider, type OutputReference } from "@marketplace/shared/chain";
|
||||
import { decodeAdvertDatum, type AdvertDatum } from "@marketplace/shared/cbor";
|
||||
import {
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
loadBlueprint,
|
||||
type WalletKey,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { deriveKeypair } from "./gen-keypair.js";
|
||||
import { runPostAdvert } from "./postAdvertFlow.js";
|
||||
import { callOpenAi } from "../openai.js";
|
||||
import { OpenRouterCapacityProvider } from "../reseller/openRouterCapacity.js";
|
||||
import {
|
||||
loadResellerSetupConfig,
|
||||
type ResellerSetupConfig,
|
||||
} from "../reseller/setupConfig.js";
|
||||
|
||||
interface CliOptions {
|
||||
configPath: string;
|
||||
startStack: boolean;
|
||||
}
|
||||
|
||||
interface SetupState extends Record<string, string> {
|
||||
SUPPLIER_PRIV_KEY_HEX: string;
|
||||
SUPPLIER_PUB_KEY_HEX: string;
|
||||
SUPPLIER_PKH: string;
|
||||
SUPPLIER_ADDRESS: string;
|
||||
POSTGRES_PASSWORD: string;
|
||||
OPENAI_API_KEY: string;
|
||||
ADVERT_REF: string;
|
||||
}
|
||||
|
||||
interface SetupResult {
|
||||
supplierAddress: string;
|
||||
supplierPkh: string;
|
||||
advertRef: string;
|
||||
endpointUrl: string;
|
||||
dashboardUrl: string;
|
||||
marketplaceModel: string;
|
||||
providerModel: string;
|
||||
stackStarted: boolean;
|
||||
}
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const SHA256_EMPTY = createHash("sha256").update("").digest("hex");
|
||||
const MAINNET_ZERO_TIME_MS = "1756485600000";
|
||||
const MAINNET_ESCROW_REF =
|
||||
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0";
|
||||
const MAINNET_ADVERT_REF =
|
||||
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1";
|
||||
const POSTGRES_STATE_FILE =
|
||||
"/var/lib/agents-marketplace/openrouter-reseller-postgres.env";
|
||||
const DEFAULT_STATE_FILE = "/var/lib/agents-marketplace/openrouter-reseller.env";
|
||||
const RESELLER_HOSTNAME =
|
||||
"mp-suppliers-openrouter-reseller.vector.apexfusion.org";
|
||||
|
||||
export function parseSetupArgs(argv: string[]): CliOptions {
|
||||
let configPath = "";
|
||||
let startStack = true;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--config") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--config requires a path");
|
||||
configPath = value;
|
||||
index += 1;
|
||||
} else if (arg === "--no-start") {
|
||||
startStack = false;
|
||||
} else {
|
||||
throw new Error(`unknown setup argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (configPath === "") throw new Error("--config is required");
|
||||
return { configPath, startStack };
|
||||
}
|
||||
|
||||
export async function runResellerSetup(
|
||||
options: CliOptions,
|
||||
env: Record<string, string | undefined>,
|
||||
log: (line: string) => void = (line) => console.error(line),
|
||||
): Promise<SetupResult> {
|
||||
const config = await loadResellerSetupConfig(
|
||||
resolve(REPO_ROOT, options.configPath),
|
||||
);
|
||||
if (new URL(config.endpointUrl).hostname !== RESELLER_HOSTNAME) {
|
||||
throw new Error(`endpointUrl host must be ${RESELLER_HOSTNAME}`);
|
||||
}
|
||||
const statePath = resolveStatePath(env.RESELLER_STATE_FILE, options.startStack);
|
||||
process.env.VECTOR_ZERO_TIME_MS = MAINNET_ZERO_TIME_MS;
|
||||
process.env.ESCROW_REF_UTXO = MAINNET_ESCROW_REF;
|
||||
process.env.ADVERT_REF_UTXO = MAINNET_ADVERT_REF;
|
||||
const prior = await readState(statePath);
|
||||
const privateKey = prior.SUPPLIER_PRIV_KEY_HEX ?? randomBytes(32).toString("hex");
|
||||
const derived = deriveKeypair(privateKey, config.networkId);
|
||||
const postgresPassword = prior.POSTGRES_PASSWORD ?? randomBytes(24).toString("hex");
|
||||
const openRouterKey = env.OPENROUTER_API_KEY ?? prior.OPENAI_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is required on the first setup run");
|
||||
}
|
||||
|
||||
const runtimeState = buildRuntimeState(
|
||||
config,
|
||||
{
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
address: derived.address,
|
||||
},
|
||||
postgresPassword,
|
||||
openRouterKey,
|
||||
prior.ADVERT_REF ?? "",
|
||||
);
|
||||
await writeState(statePath, runtimeState);
|
||||
await writeState(POSTGRES_STATE_FILE, {
|
||||
POSTGRES_USER: runtimeState.POSTGRES_USER,
|
||||
POSTGRES_DB: runtimeState.POSTGRES_DB,
|
||||
POSTGRES_PASSWORD: runtimeState.POSTGRES_PASSWORD,
|
||||
});
|
||||
|
||||
log("checking capped OpenRouter allowance and model pricing");
|
||||
const capacity = new OpenRouterCapacityProvider({
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
apiKey: openRouterKey,
|
||||
timeoutMs: config.providerTimeoutMs,
|
||||
});
|
||||
await capacity.readCapacity(config.openRouterModel);
|
||||
|
||||
log("running one-token OpenRouter inference probe");
|
||||
await callOpenAi({
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
apiKey: openRouterKey,
|
||||
model: config.openRouterModel,
|
||||
messages: [{ role: "user", content: "Reply OK" }],
|
||||
maxTokens: 1,
|
||||
disableReasoning: true,
|
||||
timeoutMs: config.inferenceTimeoutMs,
|
||||
});
|
||||
|
||||
const chain = new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
|
||||
log(`seller wallet ${derived.address}`);
|
||||
await waitForFunding(
|
||||
chain,
|
||||
derived.address,
|
||||
config.minimumWalletLovelace,
|
||||
config.fundingTimeoutMs,
|
||||
log,
|
||||
);
|
||||
|
||||
const walletKey: WalletKey = {
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
address: derived.address,
|
||||
};
|
||||
const advert = buildAdvert(config, walletKey);
|
||||
const advertRef = await findOrPostAdvert(
|
||||
chain,
|
||||
walletKey,
|
||||
advert,
|
||||
runtimeState.ADVERT_REF,
|
||||
log,
|
||||
async (pendingRef) => {
|
||||
runtimeState.ADVERT_REF = `${pendingRef.txHash}#${pendingRef.index}`;
|
||||
await writeState(statePath, runtimeState);
|
||||
},
|
||||
);
|
||||
runtimeState.ADVERT_REF = `${advertRef.txHash}#${advertRef.index}`;
|
||||
await writeState(statePath, runtimeState);
|
||||
|
||||
if (options.startStack) {
|
||||
log("starting reseller and Postgres stack");
|
||||
await runCompose(config.composeFile);
|
||||
}
|
||||
|
||||
return {
|
||||
supplierAddress: walletKey.address,
|
||||
supplierPkh: walletKey.pubKeyHash,
|
||||
advertRef: runtimeState.ADVERT_REF,
|
||||
endpointUrl: config.endpointUrl,
|
||||
dashboardUrl: `${config.endpointUrl}/reseller`,
|
||||
marketplaceModel: config.marketplaceModel,
|
||||
providerModel: config.openRouterModel,
|
||||
stackStarted: options.startStack,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeState(
|
||||
config: ResellerSetupConfig,
|
||||
wallet: WalletKey,
|
||||
postgresPassword: string,
|
||||
openRouterKey: string,
|
||||
advertRef: string,
|
||||
): SetupState {
|
||||
const hostname = new URL(config.endpointUrl).hostname;
|
||||
return {
|
||||
SUPPLIER_PRIV_KEY_HEX: wallet.privateKeyHex,
|
||||
SUPPLIER_PUB_KEY_HEX: wallet.pubKeyHex,
|
||||
SUPPLIER_PKH: wallet.pubKeyHash,
|
||||
SUPPLIER_ADDRESS: wallet.address,
|
||||
POSTGRES_USER: "reseller",
|
||||
POSTGRES_DB: "reseller",
|
||||
POSTGRES_PASSWORD: postgresPassword,
|
||||
OPENAI_API_KEY: openRouterKey,
|
||||
ADVERT_REF: advertRef,
|
||||
OGMIOS_URL: config.ogmiosUrl,
|
||||
NETWORK_ID: String(config.networkId),
|
||||
LIVE_CHAIN: "1",
|
||||
CAPABILITY_KIND: "chat",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: OPENROUTER_BASE_URL,
|
||||
OPENAI_MODEL_OVERRIDE: config.openRouterModel,
|
||||
OPENAI_MAX_TOKENS: String(config.maxOutputTokens),
|
||||
OPENAI_REASONING: "off",
|
||||
OPENAI_TIMEOUT_MS: String(config.inferenceTimeoutMs),
|
||||
RESELLER_PROVIDER: "openrouter",
|
||||
RESELLER_DATABASE_URL: `postgresql://reseller:${postgresPassword}@postgres:5432/reseller`,
|
||||
RESELLER_RESERVE_USD: config.reserveUsd,
|
||||
RESELLER_MAX_INPUT_TOKENS: String(config.maxInputTokens),
|
||||
RESELLER_POLL_INTERVAL_MS: String(config.pollIntervalMs),
|
||||
RESELLER_PROVIDER_TIMEOUT_MS: String(config.providerTimeoutMs),
|
||||
RESELLER_PREVIEW_MAX_CHARS: String(config.previewMaxChars),
|
||||
RESELLER_HOSTNAME: hostname,
|
||||
RESELLER_ENDPOINT_URL: config.endpointUrl,
|
||||
RESELLER_MARKETPLACE_MODEL: config.marketplaceModel,
|
||||
RESELLER_PROVIDER_MODEL: config.openRouterModel,
|
||||
PORT: "8080",
|
||||
WALLET_HEALTH_INTERVAL_MS: "600000",
|
||||
VECTOR_ZERO_TIME_MS: MAINNET_ZERO_TIME_MS,
|
||||
ESCROW_REF_UTXO: MAINNET_ESCROW_REF,
|
||||
ADVERT_REF_UTXO: MAINNET_ADVERT_REF,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdvert(config: ResellerSetupConfig, wallet: WalletKey): AdvertDatum {
|
||||
return {
|
||||
supplier_pkh: wallet.pubKeyHash,
|
||||
capability_id: "llm.text.generate.v1",
|
||||
model: config.marketplaceModel,
|
||||
max_output_tokens: config.maxOutputTokens,
|
||||
max_processing_ms: config.maxProcessingMs,
|
||||
price_lovelace: config.priceLovelace,
|
||||
supplier_bond_lovelace: config.supplierBondLovelace,
|
||||
buyer_bond_lovelace: config.buyerBondLovelace,
|
||||
endpoint_url: config.endpointUrl,
|
||||
detail_uri: BOUNDED_INPUT_DETAIL_MARKER,
|
||||
detail_hash: SHA256_EMPTY,
|
||||
advertised_at: 0,
|
||||
status: "Active",
|
||||
};
|
||||
}
|
||||
|
||||
async function findOrPostAdvert(
|
||||
chain: LiveOgmiosProvider,
|
||||
walletKey: WalletKey,
|
||||
expected: AdvertDatum,
|
||||
savedRef: string,
|
||||
log: (line: string) => void,
|
||||
journal: (ref: OutputReference) => Promise<void>,
|
||||
): Promise<OutputReference> {
|
||||
const parsedSaved = parseRef(savedRef);
|
||||
if (parsedSaved) {
|
||||
const utxo = await chain.queryUtxo(parsedSaved);
|
||||
if (utxo?.datumHex) {
|
||||
assertAdvertMatches(decodeAdvertDatum(utxo.datumHex), expected);
|
||||
log(`reusing saved advert ${savedRef}`);
|
||||
return parsedSaved;
|
||||
}
|
||||
try {
|
||||
await chain.awaitTx(parsedSaved.txHash, 120_000);
|
||||
const confirmed = await chain.queryUtxo(parsedSaved);
|
||||
if (confirmed?.datumHex) {
|
||||
assertAdvertMatches(decodeAdvertDatum(confirmed.datumHex), expected);
|
||||
log(`reusing confirmed pending advert ${savedRef}`);
|
||||
return parsedSaved;
|
||||
}
|
||||
} catch {
|
||||
// The pending transaction is no longer viable; authoritative scan follows.
|
||||
}
|
||||
}
|
||||
|
||||
const advertAddress = loadBlueprint().advertScriptAddress(1);
|
||||
const advertUtxos = await chain.queryUtxosByAddress(advertAddress);
|
||||
const sameModel: Array<{ ref: OutputReference; datum: AdvertDatum }> = [];
|
||||
for (const utxo of advertUtxos) {
|
||||
if (!utxo.datumHex) continue;
|
||||
try {
|
||||
const datum = decodeAdvertDatum(utxo.datumHex);
|
||||
if (datum.status === "Active" && datum.model === expected.model) {
|
||||
sameModel.push({ ref: utxo.ref, datum });
|
||||
}
|
||||
} catch {
|
||||
// Ignore unrelated or malformed script outputs.
|
||||
}
|
||||
}
|
||||
const own = sameModel.filter(
|
||||
(candidate) => candidate.datum.supplier_pkh === expected.supplier_pkh,
|
||||
);
|
||||
if (own.length > 1) {
|
||||
throw new Error(`multiple active adverts already exist for ${expected.model}`);
|
||||
}
|
||||
if (own.length === 1) {
|
||||
assertAdvertMatches(own[0].datum, expected);
|
||||
log(`reusing discovered advert ${own[0].ref.txHash}#${own[0].ref.index}`);
|
||||
return own[0].ref;
|
||||
}
|
||||
|
||||
const result = await runPostAdvert({
|
||||
chain,
|
||||
walletKey,
|
||||
advertDatum: expected,
|
||||
log,
|
||||
beforeSubmit: async (built) => journal(built.advertOutputRef),
|
||||
});
|
||||
return result.advertRef;
|
||||
}
|
||||
|
||||
function assertAdvertMatches(actual: AdvertDatum, expected: AdvertDatum): void {
|
||||
const fieldsMatch =
|
||||
actual.status === "Active" &&
|
||||
actual.supplier_pkh === expected.supplier_pkh &&
|
||||
actual.capability_id === expected.capability_id &&
|
||||
actual.model === expected.model &&
|
||||
actual.max_output_tokens === expected.max_output_tokens &&
|
||||
actual.max_processing_ms === expected.max_processing_ms &&
|
||||
actual.price_lovelace === expected.price_lovelace &&
|
||||
actual.supplier_bond_lovelace === expected.supplier_bond_lovelace &&
|
||||
actual.buyer_bond_lovelace === expected.buyer_bond_lovelace &&
|
||||
actual.endpoint_url === expected.endpoint_url &&
|
||||
actual.detail_uri === expected.detail_uri &&
|
||||
actual.detail_hash === expected.detail_hash;
|
||||
if (!fieldsMatch) {
|
||||
throw new Error("saved advert does not match the reseller config");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function waitForFunding(
|
||||
chain: LiveOgmiosProvider,
|
||||
address: string,
|
||||
minimum: bigint,
|
||||
timeoutMs: number,
|
||||
log: (line: string) => void,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastReported = -1n;
|
||||
while (Date.now() < deadline) {
|
||||
const utxos = await chain.queryUtxosByAddress(address);
|
||||
const total = utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
|
||||
if (total >= minimum) return;
|
||||
if (total !== lastReported) {
|
||||
log(`wallet funding ${total}/${minimum} lovelace`);
|
||||
lastReported = total;
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5_000));
|
||||
}
|
||||
throw new Error(`wallet funding timed out for ${address}`);
|
||||
}
|
||||
|
||||
function resolveStatePath(
|
||||
configured: string | undefined,
|
||||
startStack: boolean,
|
||||
): string {
|
||||
const statePath = resolve(configured ?? DEFAULT_STATE_FILE);
|
||||
const repoRoot = REPO_ROOT;
|
||||
const fromRepo = relative(repoRoot, statePath);
|
||||
if (fromRepo === "" || (!fromRepo.startsWith("../") && fromRepo !== "..")) {
|
||||
throw new Error("RESELLER_STATE_FILE must be outside the repository");
|
||||
}
|
||||
if (startStack && statePath !== DEFAULT_STATE_FILE) {
|
||||
throw new Error(
|
||||
`stack startup requires the fixed state file ${DEFAULT_STATE_FILE}`,
|
||||
);
|
||||
}
|
||||
return statePath;
|
||||
}
|
||||
|
||||
async function readState(path: string): Promise<Record<string, string>> {
|
||||
let raw: string;
|
||||
try {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`reseller state must be a regular file: ${path}`);
|
||||
}
|
||||
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
||||
throw new Error(`reseller state is not owned by the current user: ${path}`);
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error(`reseller state permissions must be 0600: ${path}`);
|
||||
}
|
||||
raw = await handle.readFile("utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw error;
|
||||
}
|
||||
const state: Record<string, string> = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (line === "" || line.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) throw new Error(`invalid reseller state line: ${line}`);
|
||||
state[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function writeState(path: string, state: Record<string, string>): Promise<void> {
|
||||
for (const [name, value] of Object.entries(state)) {
|
||||
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
|
||||
throw new Error(`reseller state ${name} must be one line`);
|
||||
}
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
await chmod(dirname(path), 0o700);
|
||||
const temp = `${path}.${randomBytes(12).toString("hex")}.tmp`;
|
||||
const body = Object.entries(state)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("\n") + "\n";
|
||||
const handle = await open(temp, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(body, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, path);
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
function parseRef(value: string): OutputReference | null {
|
||||
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
|
||||
if (!match) return null;
|
||||
return { txHash: match[1], index: Number(match[2]) };
|
||||
}
|
||||
|
||||
async function runCompose(composeFile: string): Promise<void> {
|
||||
const args = [
|
||||
"compose",
|
||||
"-f",
|
||||
resolve(REPO_ROOT, composeFile),
|
||||
"up",
|
||||
"-d",
|
||||
"--build",
|
||||
"--wait",
|
||||
];
|
||||
await new Promise<void>((resolveRun, rejectRun) => {
|
||||
const child = spawn("docker", args, { stdio: "inherit" });
|
||||
child.once("error", rejectRun);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolveRun();
|
||||
else rejectRun(new Error(`docker compose exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await runResellerSetup(parseSetupArgs(argv), env);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`reseller setup failed: ${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
void main(process.argv.slice(2), process.env).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
209
supplier/src/cli/surplus-seller.ts
Normal file
209
supplier/src/cli/surplus-seller.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { loadSurplusManagerConfig } from "../surplus/config.js";
|
||||
import { SurplusClient, type SurplusSale } from "../surplus/client.js";
|
||||
import { SurplusSellerController } from "../surplus/controller.js";
|
||||
import { OpenRouterKeyClient } from "../surplus/openRouterKey.js";
|
||||
import { FileSurplusStateStore } from "../surplus/state.js";
|
||||
import {
|
||||
SurplusDashboardService,
|
||||
type SurplusDashboardSnapshot,
|
||||
} from "../surplus/dashboard.js";
|
||||
import { FileSurplusVectorProofLedger } from "../surplus/vectorProof.js";
|
||||
import { loadHistoricalSalesCsv } from "../surplus/historyExport.js";
|
||||
import { SurplusVectorProofPublisher } from "../surplus/vectorPublisher.js";
|
||||
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
import { buildAnchorMetadataTx } from "@marketplace/shared/tx/server";
|
||||
import { deriveKeypair } from "./gen-keypair.js";
|
||||
|
||||
export async function runSurplusSeller(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<{
|
||||
server: Server;
|
||||
controller: SurplusSellerController;
|
||||
publisher: SurplusVectorProofPublisher | null;
|
||||
}> {
|
||||
const config = loadSurplusManagerConfig(env);
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
sellerApiKey: config.sellerApiKey,
|
||||
timeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
const allowance = new OpenRouterKeyClient({
|
||||
baseUrl: config.capacityBaseUrl,
|
||||
apiKey: config.providerApiKey,
|
||||
timeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
const stateStore = new FileSurplusStateStore(config.statePath);
|
||||
const controller = new SurplusSellerController({
|
||||
config,
|
||||
client,
|
||||
allowance,
|
||||
stateStore,
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
|
||||
let publisher: SurplusVectorProofPublisher | null = null;
|
||||
let proofs: FileSurplusVectorProofLedger | undefined;
|
||||
if (config.proof !== null) {
|
||||
const proofConfig = config.proof;
|
||||
const ledger = await FileSurplusVectorProofLedger.open(proofConfig.ledgerPath);
|
||||
proofs = ledger;
|
||||
const chain = new LiveOgmiosProvider({ ogmiosUrl: proofConfig.ogmiosUrl });
|
||||
const derived = deriveKeypair(proofConfig.walletPrivateKeyHex, 1);
|
||||
const walletKey: WalletKey = {
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
address: derived.address,
|
||||
};
|
||||
|
||||
let historicalSales: (() => Promise<readonly SurplusSale[]>) | undefined;
|
||||
if (proofConfig.historyCsvPath !== null && proofConfig.historyOfferId !== null) {
|
||||
const history = await loadHistoricalSalesCsv(
|
||||
proofConfig.historyCsvPath,
|
||||
proofConfig.historyOfferId,
|
||||
);
|
||||
console.log(
|
||||
`surplus-proof: loaded ${history.sales.length} settled export sale(s) from ` +
|
||||
`${proofConfig.historyCsvPath} (${history.settledWithoutTx} without a Base tx, ` +
|
||||
`${history.skippedUnsettled} unsettled rows skipped)`,
|
||||
);
|
||||
historicalSales = async () => history.sales;
|
||||
}
|
||||
publisher = new SurplusVectorProofPublisher({
|
||||
ledger,
|
||||
earnings: () => client.getEarnings(),
|
||||
historicalSales,
|
||||
anchor: async (metadata) => {
|
||||
const built = await buildAnchorMetadataTx({ chain, walletKey, metadata });
|
||||
return { expectedTxHash: built.expectedTxHash };
|
||||
},
|
||||
awaitTx: (txHash, timeoutMs) => chain.awaitTx(txHash, timeoutMs),
|
||||
balanceLovelace: async () => {
|
||||
const utxos = await chain.queryUtxosByAddress(walletKey.address);
|
||||
return utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
|
||||
},
|
||||
reserveLovelace: proofConfig.reserveLovelace,
|
||||
feeBudgetLovelace: proofConfig.feeBudgetLovelace,
|
||||
settledStatuses: config.settledStatuses,
|
||||
intervalMs: proofConfig.intervalMs,
|
||||
confirmTimeoutMs: proofConfig.confirmTimeoutMs,
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
console.log(
|
||||
`surplus-proof: publisher enabled with wallet ${derived.address} and ledger ${proofConfig.ledgerPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dashboard = new SurplusDashboardService({
|
||||
controller,
|
||||
client,
|
||||
allowance,
|
||||
config,
|
||||
proofs,
|
||||
});
|
||||
const server = createSurplusServer(controller, dashboard);
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(config.port, "0.0.0.0", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
await controller.start();
|
||||
publisher?.start();
|
||||
return { server, controller, publisher };
|
||||
}
|
||||
|
||||
interface DashboardSource {
|
||||
getSnapshot(): Promise<SurplusDashboardSnapshot>;
|
||||
}
|
||||
|
||||
export function createSurplusServer(
|
||||
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
|
||||
dashboard: DashboardSource,
|
||||
): Server {
|
||||
return createServer((request, response) => {
|
||||
void handleRequest(controller, dashboard, request, response);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
|
||||
dashboard: DashboardSource,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
const path = request.url?.split("?")[0] ?? "/";
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.setHeader("x-content-type-options", "nosniff");
|
||||
response.setHeader("x-frame-options", "DENY");
|
||||
response.setHeader("referrer-policy", "no-referrer");
|
||||
const status = controller.snapshot();
|
||||
if (request.method === "GET" && path === "/healthz") {
|
||||
const healthy = controller.healthy();
|
||||
response.statusCode = healthy ? 200 : 503;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ ok: healthy, phase: status.phase }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/status") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify(status));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/internal/resale-dashboard") {
|
||||
try {
|
||||
const snapshot = await dashboard.getSnapshot();
|
||||
response.statusCode = 200;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify(snapshot));
|
||||
} catch {
|
||||
response.statusCode = 503;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ error: "dashboard_data_unavailable" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ error: "not_found" }));
|
||||
}
|
||||
|
||||
export async function main(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
const { server, controller, publisher } = await runSurplusSeller(env);
|
||||
console.log("Surplus seller controller listening");
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`Surplus seller controller shutting down (${signal})`);
|
||||
await publisher?.stop();
|
||||
await controller.stop();
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => error ? rejectClose(error) : resolveClose());
|
||||
});
|
||||
};
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
main().catch((error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Surplus seller controller failed: ${detail}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,15 @@
|
||||
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
|
||||
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
|
||||
* first-token latency is longer than local Ollama)
|
||||
* OPENAI_SESSION_PASSTHROUGH
|
||||
* — "1" switches chat-session turns to the stateful-
|
||||
* upstream contract (OpenClaw): escrow ref as the
|
||||
* OpenAI `user` field + delta-only messages per turn.
|
||||
* Strict "1" only. Default off. Requires
|
||||
* CAPABILITY_KIND="chat-session".
|
||||
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
* advert's buyer-facing model (OpenClaw accepts only
|
||||
* "openclaw" / "openclaw/<agentId>"). Default unset.
|
||||
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
|
||||
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
|
||||
* Any other value (including "true", "yes", "TRUE")
|
||||
@@ -53,6 +62,7 @@ const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/;
|
||||
|
||||
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
|
||||
function hostnameOf(url: string): string {
|
||||
@@ -63,6 +73,24 @@ function hostnameOf(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouterApiBaseUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "openrouter.ai" &&
|
||||
url.port === "" &&
|
||||
url.username === "" &&
|
||||
url.password === "" &&
|
||||
(url.pathname === "/api" || url.pathname === "/api/") &&
|
||||
url.search === "" &&
|
||||
url.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdvertRef {
|
||||
txHash: string;
|
||||
index: number;
|
||||
@@ -103,6 +131,22 @@ export type LlmBackend = "ollama" | "openai";
|
||||
* the buyer's sweeper Reclaims the Open escrow after deliver_by. */
|
||||
export type ChatSettleMode = "full" | "ticket";
|
||||
|
||||
export type ResellerProvider = "openrouter";
|
||||
|
||||
export interface ResellerConfig {
|
||||
provider: ResellerProvider;
|
||||
databaseUrl: string;
|
||||
/** Fixed protected amount in provider USD credits. Parsed exactly at runtime. */
|
||||
reserveUsd: string;
|
||||
/** Conservative off-chain input bound advertised by /capability. */
|
||||
maxInputTokens: number;
|
||||
pollIntervalMs: number;
|
||||
staleAfterMs: number;
|
||||
providerTimeoutMs: number;
|
||||
previewMaxChars: number;
|
||||
}
|
||||
|
||||
|
||||
export interface SupplierConfig {
|
||||
supplierPrivKeyHex: string;
|
||||
ogmiosUrl: string;
|
||||
@@ -129,6 +173,28 @@ export interface SupplierConfig {
|
||||
* the model context or the provider 400s (e.g. kimi context = 262144).
|
||||
*/
|
||||
openaiMaxTokens: number;
|
||||
/**
|
||||
* When true, chat-session turns speak the STATEFUL-upstream contract
|
||||
* (e.g. an OpenClaw gateway): each turn sends the escrow ref as the OpenAI
|
||||
* `user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
* browser/tool state survives across turns) and sends ONLY the current
|
||||
* turn's delta messages — the upstream session carries the history, and
|
||||
* re-sending the full transcript would duplicate it into the upstream
|
||||
* context every turn. The supplier still accumulates the full transcript
|
||||
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
|
||||
* off (stateless upstreams get the full transcript each turn). Requires
|
||||
* capabilityKind="chat-session".
|
||||
*/
|
||||
openaiSessionPassthrough: boolean;
|
||||
/**
|
||||
* When non-empty, sent as the upstream `model` instead of the advert's
|
||||
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
|
||||
* chat-completions endpoint 400s on anything but "openclaw" or
|
||||
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
|
||||
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
|
||||
* sent verbatim).
|
||||
*/
|
||||
openaiModelOverride: string;
|
||||
/** Empty string when capabilityKind="chat". */
|
||||
piperUrl: string;
|
||||
advertRef: AdvertRef;
|
||||
@@ -195,6 +261,8 @@ export interface SupplierConfig {
|
||||
* Default 600_000 (10 min). Only fires while liveChain=true.
|
||||
*/
|
||||
walletHealthIntervalMs: number;
|
||||
/** Optional balance-aware provider resale mode. Null preserves legacy behavior. */
|
||||
reseller: ResellerConfig | null;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -379,6 +447,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiMaxTokens = Number(maxTokStr);
|
||||
}
|
||||
|
||||
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
|
||||
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
|
||||
// field + delta-only messages (the upstream session carries history).
|
||||
// Strict "1" only, mirroring LIVE_CHAIN.
|
||||
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
|
||||
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
|
||||
throw new Error(
|
||||
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
// advert's (buyer-facing) model. Needed when the upstream validates the
|
||||
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
|
||||
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
|
||||
|
||||
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
|
||||
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
|
||||
let chatIdleTimeoutMs = 300_000;
|
||||
@@ -485,6 +569,121 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
walletHealthIntervalMs = Number(whIntervalStr);
|
||||
}
|
||||
|
||||
const resellerFields = [
|
||||
"RESELLER_DATABASE_URL",
|
||||
"RESELLER_RESERVE_USD",
|
||||
"RESELLER_MAX_INPUT_TOKENS",
|
||||
"RESELLER_POLL_INTERVAL_MS",
|
||||
"RESELLER_PROVIDER_TIMEOUT_MS",
|
||||
"RESELLER_PREVIEW_MAX_CHARS",
|
||||
] as const;
|
||||
const resellerProviderRaw = (env.RESELLER_PROVIDER ?? "").trim();
|
||||
const hasResellerFields = resellerFields.some(
|
||||
(name) => env[name] !== undefined && env[name] !== "",
|
||||
);
|
||||
let reseller: ResellerConfig | null = null;
|
||||
if (resellerProviderRaw === "") {
|
||||
if (hasResellerFields) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER is required when reseller fields are set",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (resellerProviderRaw !== "openrouter") {
|
||||
throw new Error('loadConfig: RESELLER_PROVIDER must be "openrouter"');
|
||||
}
|
||||
if (capabilityKind !== "chat" || llmBackend !== "openai") {
|
||||
throw new Error(
|
||||
'loadConfig: RESELLER_PROVIDER requires CAPABILITY_KIND="chat" and LLM_BACKEND="openai"',
|
||||
);
|
||||
}
|
||||
if (!isOpenRouterApiBaseUrl(openaiBaseUrl)) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_BASE_URL=https://openrouter.ai/api",
|
||||
);
|
||||
}
|
||||
if (openaiApiKey === "") {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_API_KEY",
|
||||
);
|
||||
}
|
||||
if (openaiMaxTokens <= 0) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER requires a positive OPENAI_MAX_TOKENS",
|
||||
);
|
||||
}
|
||||
|
||||
const databaseUrl = requireField(env, "RESELLER_DATABASE_URL");
|
||||
let databaseProtocol = "";
|
||||
try {
|
||||
databaseProtocol = new URL(databaseUrl).protocol;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_DATABASE_URL must be a valid PostgreSQL URL",
|
||||
);
|
||||
}
|
||||
if (databaseProtocol !== "postgres:" && databaseProtocol !== "postgresql:") {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_DATABASE_URL must use postgres:// or postgresql://",
|
||||
);
|
||||
}
|
||||
|
||||
const reserveUsd = requireField(env, "RESELLER_RESERVE_USD").trim();
|
||||
if (!USD_RE.test(reserveUsd)) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_RESERVE_USD must be a non-negative decimal with at most 9 places",
|
||||
);
|
||||
}
|
||||
|
||||
const maxInputRaw = requireField(env, "RESELLER_MAX_INPUT_TOKENS");
|
||||
if (!POS_INT_RE.test(maxInputRaw) || !Number.isSafeInteger(Number(maxInputRaw))) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_MAX_INPUT_TOKENS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
const pollRaw = env.RESELLER_POLL_INTERVAL_MS ?? "30000";
|
||||
if (!POS_INT_RE.test(pollRaw) || !Number.isSafeInteger(Number(pollRaw))) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_POLL_INTERVAL_MS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
const pollIntervalMs = Number(pollRaw);
|
||||
|
||||
const providerTimeoutRaw = env.RESELLER_PROVIDER_TIMEOUT_MS ?? "10000";
|
||||
if (
|
||||
!POS_INT_RE.test(providerTimeoutRaw) ||
|
||||
!Number.isSafeInteger(Number(providerTimeoutRaw))
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER_TIMEOUT_MS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
const previewMaxRaw = env.RESELLER_PREVIEW_MAX_CHARS ?? "160";
|
||||
if (
|
||||
!POS_INT_RE.test(previewMaxRaw) ||
|
||||
!Number.isSafeInteger(Number(previewMaxRaw)) ||
|
||||
Number(previewMaxRaw) > 1000
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PREVIEW_MAX_CHARS must be an integer from 1 to 1000",
|
||||
);
|
||||
}
|
||||
|
||||
reseller = {
|
||||
provider: "openrouter",
|
||||
databaseUrl,
|
||||
reserveUsd,
|
||||
maxInputTokens: Number(maxInputRaw),
|
||||
pollIntervalMs,
|
||||
staleAfterMs: Math.max(pollIntervalMs * 2, pollIntervalMs + 5000),
|
||||
providerTimeoutMs: Number(providerTimeoutRaw),
|
||||
previewMaxChars: Number(previewMaxRaw),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
supplierPrivKeyHex,
|
||||
ogmiosUrl,
|
||||
@@ -493,6 +692,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiApiKey,
|
||||
openaiReasoningDisabled,
|
||||
openaiMaxTokens,
|
||||
openaiSessionPassthrough,
|
||||
openaiModelOverride,
|
||||
piperUrl,
|
||||
advertRef,
|
||||
networkId,
|
||||
@@ -516,5 +717,6 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
revisionProbeMode,
|
||||
liveChain,
|
||||
walletHealthIntervalMs,
|
||||
reseller,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ import { JobStore } from "./jobs.js";
|
||||
import { createApp, type SupplierDeps } from "./server.js";
|
||||
import { buildChainProvider } from "./chain.js";
|
||||
import { startWalletHealthTicker } from "./walletHealth.js";
|
||||
import { decodeAdvertDatum } from "@marketplace/shared/cbor";
|
||||
import { BOUNDED_INPUT_DETAIL_MARKER } from "@marketplace/shared/tx";
|
||||
import { OpenRouterCapacityProvider } from "./reseller/openRouterCapacity.js";
|
||||
import { CapacityGate } from "./reseller/capacityGate.js";
|
||||
import { ResellerEvidenceStore } from "./reseller/evidenceStore.js";
|
||||
import { ResellerRuntime } from "./reseller/runtime.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cfg = loadConfig(process.env);
|
||||
@@ -45,8 +51,74 @@ async function main(): Promise<void> {
|
||||
// chat-session kind serves N concurrent sessions; one-shot kinds stay 1.
|
||||
const state = new SupplierState(cfg.capabilityKind === "chat-session" ? cfg.maxChatSessions : 1);
|
||||
const jobs = new JobStore();
|
||||
let reseller: ResellerRuntime | undefined;
|
||||
if (cfg.reseller) {
|
||||
const advertUtxo = await chain.queryUtxo(cfg.advertRef);
|
||||
if (!advertUtxo?.datumHex) {
|
||||
throw new Error("reseller boot: configured advert UTxO is unavailable");
|
||||
}
|
||||
const advert = decodeAdvertDatum(advertUtxo.datumHex);
|
||||
if (advert.status !== "Active") {
|
||||
throw new Error(`reseller boot: advert status is ${advert.status}`);
|
||||
}
|
||||
if (advert.capability_id !== "llm.text.generate.v1") {
|
||||
throw new Error(
|
||||
`reseller boot: advert capability must be llm.text.generate.v1, got ${advert.capability_id}`,
|
||||
);
|
||||
}
|
||||
if (!advert.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
|
||||
throw new Error("reseller boot: advert is missing bounded-input metadata");
|
||||
}
|
||||
if (advert.max_output_tokens !== cfg.openaiMaxTokens) {
|
||||
throw new Error(
|
||||
"reseller boot: OPENAI_MAX_TOKENS must equal advert max_output_tokens",
|
||||
);
|
||||
}
|
||||
if (advert.supplier_pkh !== supplierKey.pubKeyHash) {
|
||||
throw new Error("reseller boot: advert supplier does not match SUPPLIER_PKH");
|
||||
}
|
||||
|
||||
const deps: SupplierDeps = { chain, state, config: cfg, supplierKey, jobs };
|
||||
const providerModel = cfg.openaiModelOverride || advert.model;
|
||||
const provider = new OpenRouterCapacityProvider({
|
||||
baseUrl: cfg.openaiBaseUrl,
|
||||
apiKey: cfg.openaiApiKey,
|
||||
timeoutMs: cfg.reseller.providerTimeoutMs,
|
||||
});
|
||||
const gate = new CapacityGate({
|
||||
provider,
|
||||
model: providerModel,
|
||||
reserveUsd: cfg.reseller.reserveUsd,
|
||||
maxInputTokens: cfg.reseller.maxInputTokens,
|
||||
maxOutputTokens: advert.max_output_tokens,
|
||||
staleAfterMs: cfg.reseller.staleAfterMs,
|
||||
});
|
||||
const store = new ResellerEvidenceStore(
|
||||
cfg.reseller.databaseUrl,
|
||||
cfg.reseller.previewMaxChars,
|
||||
);
|
||||
reseller = new ResellerRuntime({
|
||||
chain,
|
||||
state,
|
||||
gate,
|
||||
store,
|
||||
advert,
|
||||
advertRef: cfg.advertRef,
|
||||
providerModel,
|
||||
pollIntervalMs: cfg.reseller.pollIntervalMs,
|
||||
maxInputTokens: cfg.reseller.maxInputTokens,
|
||||
});
|
||||
await reseller.initialize();
|
||||
}
|
||||
|
||||
|
||||
const deps: SupplierDeps = {
|
||||
chain,
|
||||
state,
|
||||
config: cfg,
|
||||
supplierKey,
|
||||
jobs,
|
||||
reseller,
|
||||
};
|
||||
const app = createApp(deps);
|
||||
|
||||
const server = app.listen(cfg.port, () => {
|
||||
@@ -80,7 +152,9 @@ async function main(): Promise<void> {
|
||||
clearInterval(evictInterval);
|
||||
walletHealthTicker?.stop();
|
||||
state.markOffline();
|
||||
server.close(() => process.exit(0));
|
||||
server.close(() => {
|
||||
void (reseller?.close() ?? Promise.resolve()).finally(() => process.exit(0));
|
||||
});
|
||||
setTimeout(() => process.exit(1), 5_000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
TtsJobResponsePayload,
|
||||
OcrJobResponsePayload,
|
||||
} from "./jobs.js";
|
||||
import type { ResellerRuntime } from "./reseller/runtime.js";
|
||||
import { parseUsdNumberCeil } from "./reseller/money.js";
|
||||
|
||||
export interface RunChatJobDeps {
|
||||
chain: ChainProvider;
|
||||
@@ -55,6 +57,7 @@ export interface RunChatJobDeps {
|
||||
config: SupplierConfig;
|
||||
supplierKey: WalletKey;
|
||||
jobs: JobStore;
|
||||
reseller?: ResellerRuntime;
|
||||
}
|
||||
|
||||
export interface RunChatJobParams {
|
||||
@@ -71,6 +74,15 @@ function sha256Hex(s: string): string {
|
||||
return createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function recordResellerFailure(
|
||||
reseller: ResellerRuntime | undefined,
|
||||
escrowRef: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!reseller) return;
|
||||
await reseller.recordFailed(escrowRef, reason).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Ollama → receipt → Submit in the background.
|
||||
* Always resolves (never rejects). Terminal state written to jobs store.
|
||||
@@ -83,16 +95,36 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
deps.jobs.setRunning(jobId);
|
||||
|
||||
try {
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordInferenceStarted(escrowRef);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Call upstream LLM ───────────────────────────────────────────
|
||||
// Both backends return the same { content, prompt_tokens,
|
||||
// completion_tokens, wallclock_ms } shape so receipt construction
|
||||
// downstream is identical.
|
||||
let inference: { content: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number };
|
||||
let inference: {
|
||||
content: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
cost_usd?: number;
|
||||
};
|
||||
try {
|
||||
if (deps.config.llmBackend === "openai") {
|
||||
inference = await openai.callOpenAi({
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: advert.model,
|
||||
model: deps.config.openaiModelOverride || advert.model,
|
||||
messages: requestBody.messages,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
@@ -127,6 +159,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
reason: collapsedReason,
|
||||
message,
|
||||
});
|
||||
await recordResellerFailure(deps.reseller, escrowRef, collapsedReason);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,8 +179,39 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
});
|
||||
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
|
||||
const resultHash = receiptResultHash(signed);
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordOutput(escrowRef, {
|
||||
response: assistantMessage,
|
||||
receipt: {
|
||||
receipt: signed.receipt,
|
||||
signature: signed.signature,
|
||||
},
|
||||
promptTokens: inference.prompt_tokens,
|
||||
completionTokens: inference.completion_tokens,
|
||||
outputText: inference.content,
|
||||
actualCostUsdNanos: inference.cost_usd === undefined
|
||||
? null
|
||||
: parseUsdNumberCeil(inference.cost_usd, "OpenRouter usage cost"),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
await recordResellerFailure(
|
||||
deps.reseller,
|
||||
escrowRef,
|
||||
"output_persistence_failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
|
||||
|
||||
// ── 4+5. Broadcast, journal, then confirm Submit under one wallet lock ─
|
||||
const submitOutcome = await deps.state.walletMutex.run(async () => {
|
||||
let built: { txCborHex: string; expectedTxHash: string };
|
||||
try {
|
||||
@@ -156,19 +220,30 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
supplierKey: deps.supplierKey,
|
||||
escrowRef: claimedRef,
|
||||
receiptHash: resultHash,
|
||||
beforeSubmit: deps.reseller
|
||||
? async (candidate) => {
|
||||
await deps.reseller!.recordSubmitting(
|
||||
escrowRef,
|
||||
`${candidate.expectedTxHash}#0`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
return { kind: "build_failed" as const, err };
|
||||
}
|
||||
const submittedRef = `${built.expectedTxHash}#0`;
|
||||
try {
|
||||
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
|
||||
} catch (err) {
|
||||
return { kind: "await_failed" as const, err };
|
||||
return { kind: "await_failed" as const, err, built, submittedRef };
|
||||
}
|
||||
return { kind: "ok" as const };
|
||||
return { kind: "ok" as const, built, submittedRef };
|
||||
});
|
||||
if (submitOutcome.kind === "build_failed") {
|
||||
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
const message = submitOutcome.err instanceof Error
|
||||
? submitOutcome.err.message
|
||||
: String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
|
||||
);
|
||||
@@ -177,20 +252,42 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
reason: "submit_failed",
|
||||
message: `Submit tx failed: ${message}`,
|
||||
});
|
||||
await recordResellerFailure(deps.reseller, escrowRef, "submit_failed");
|
||||
return;
|
||||
}
|
||||
if (submitOutcome.kind === "await_failed") {
|
||||
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
const message = submitOutcome.err instanceof Error
|
||||
? submitOutcome.err.message
|
||||
: String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_timeout",
|
||||
message: `Submit awaitTx failed: ${msg}`,
|
||||
message: `Submit awaitTx failed: ${message}`,
|
||||
});
|
||||
deps.reseller?.watchSubmitting(escrowRef, submitOutcome.submittedRef);
|
||||
return;
|
||||
}
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordSubmitConfirmed(
|
||||
escrowRef,
|
||||
submitOutcome.submittedRef,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
deps.reseller.watchSubmitting(escrowRef, submitOutcome.submittedRef);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 6. Mark complete ────────────────────────────────────────────────
|
||||
const payload: ChatJobResponsePayload = {
|
||||
@@ -212,6 +309,9 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
};
|
||||
deps.jobs.complete(jobId, payload);
|
||||
} finally {
|
||||
if (deps.reseller) {
|
||||
await deps.reseller.refreshCapacity().catch(() => undefined);
|
||||
}
|
||||
// ── 7. Always release this job's slot ───────────────────────────────
|
||||
try {
|
||||
deps.state.release(escrowRef);
|
||||
|
||||
@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
|
||||
* context window (some providers 400 when it exceeds context).
|
||||
*/
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
|
||||
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
|
||||
* escrow ref pins one upstream session per marketplace chat session.
|
||||
* Stateless providers ignore it.
|
||||
*/
|
||||
user?: string;
|
||||
/**
|
||||
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
|
||||
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
|
||||
@@ -67,6 +74,8 @@ export interface OpenAiResult {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
/** Provider-reported request cost in USD when present (OpenRouter extension). */
|
||||
cost_usd?: number;
|
||||
/** Present when the model requested tool calls (streaming path only). */
|
||||
tool_calls?: ToolCall[];
|
||||
/** Upstream finish_reason when reported (e.g. "stop", "tool_calls"). */
|
||||
@@ -93,11 +102,12 @@ function isAbortError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = { model, messages, stream: false };
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
@@ -180,10 +190,18 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
|
||||
const usageRaw = obj.usage;
|
||||
let promptTokens = 0;
|
||||
let completionTokens = 0;
|
||||
let costUsd: number | undefined;
|
||||
if (usageRaw && typeof usageRaw === "object") {
|
||||
const usage = usageRaw as Record<string, unknown>;
|
||||
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
|
||||
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
|
||||
if (
|
||||
typeof usage.cost === "number" &&
|
||||
Number.isFinite(usage.cost) &&
|
||||
usage.cost >= 0
|
||||
) {
|
||||
costUsd = usage.cost;
|
||||
}
|
||||
}
|
||||
|
||||
const wallclockMs = Date.now() - startedAt;
|
||||
@@ -193,6 +211,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
wallclock_ms: wallclockMs,
|
||||
...(costUsd === undefined ? {} : { cost_usd: costUsd }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +234,7 @@ export async function callOpenAiStream(
|
||||
params: CallOpenAiParams,
|
||||
onToken: (delta: string) => void,
|
||||
): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -225,6 +244,7 @@ export async function callOpenAiStream(
|
||||
};
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
if (Array.isArray(tools) && tools.length > 0) {
|
||||
payload.tools = tools;
|
||||
if (toolChoice !== undefined) payload.tool_choice = toolChoice;
|
||||
|
||||
179
supplier/src/reseller/capacityGate.ts
Normal file
179
supplier/src/reseller/capacityGate.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { CapacityProvider } from "./provider.js";
|
||||
import { parseUsdDecimal } from "./money.js";
|
||||
|
||||
export type CapacityReason =
|
||||
| "starting"
|
||||
| "ready"
|
||||
| "below_reserve"
|
||||
| "insufficient_for_job"
|
||||
| "provider_unavailable"
|
||||
| "database_unavailable"
|
||||
| "stale";
|
||||
|
||||
export interface CapacitySnapshot {
|
||||
provider: string;
|
||||
model: string;
|
||||
canServe: boolean;
|
||||
reason: CapacityReason;
|
||||
checkedAtMs: number | null;
|
||||
lastSuccessAtMs: number | null;
|
||||
keyLimitUsdNanos: bigint;
|
||||
remainingAllowanceUsdNanos: bigint;
|
||||
reserveUsdNanos: bigint;
|
||||
sellableUsdNanos: bigint;
|
||||
worstCaseJobUsdNanos: bigint;
|
||||
promptPriceUsdNanosPerToken: bigint;
|
||||
completionPriceUsdNanosPerToken: bigint;
|
||||
requestPriceUsdNanos: bigint;
|
||||
availableJobs: bigint | null;
|
||||
limitReset: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface CapacityGateOptions {
|
||||
provider: CapacityProvider;
|
||||
model: string;
|
||||
reserveUsd: string;
|
||||
maxInputTokens: number;
|
||||
maxOutputTokens: number;
|
||||
staleAfterMs: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class CapacityGate {
|
||||
private readonly provider: CapacityProvider;
|
||||
private readonly model: string;
|
||||
private readonly reserveUsdNanos: bigint;
|
||||
private readonly maxInputTokens: number;
|
||||
private readonly maxOutputTokens: number;
|
||||
private readonly staleAfterMs: number;
|
||||
private readonly now: () => number;
|
||||
private current: CapacitySnapshot;
|
||||
private inFlight: Promise<CapacitySnapshot> | null = null;
|
||||
|
||||
constructor(options: CapacityGateOptions) {
|
||||
this.provider = options.provider;
|
||||
this.model = options.model;
|
||||
this.reserveUsdNanos = parseUsdDecimal(options.reserveUsd, "ceil");
|
||||
this.maxInputTokens = options.maxInputTokens;
|
||||
this.maxOutputTokens = options.maxOutputTokens;
|
||||
this.staleAfterMs = options.staleAfterMs;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.current = {
|
||||
provider: options.provider.id,
|
||||
model: options.model,
|
||||
canServe: false,
|
||||
reason: "starting",
|
||||
checkedAtMs: null,
|
||||
lastSuccessAtMs: null,
|
||||
keyLimitUsdNanos: 0n,
|
||||
remainingAllowanceUsdNanos: 0n,
|
||||
reserveUsdNanos: this.reserveUsdNanos,
|
||||
sellableUsdNanos: 0n,
|
||||
worstCaseJobUsdNanos: 0n,
|
||||
promptPriceUsdNanosPerToken: 0n,
|
||||
completionPriceUsdNanosPerToken: 0n,
|
||||
requestPriceUsdNanos: 0n,
|
||||
availableJobs: 0n,
|
||||
limitReset: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): CapacitySnapshot {
|
||||
const snapshot = this.current;
|
||||
if (
|
||||
snapshot.lastSuccessAtMs !== null &&
|
||||
this.now() - snapshot.lastSuccessAtMs > this.staleAfterMs
|
||||
) {
|
||||
return {
|
||||
...snapshot,
|
||||
canServe: false,
|
||||
reason: "stale",
|
||||
error: "provider capacity data is stale",
|
||||
};
|
||||
}
|
||||
return { ...snapshot };
|
||||
}
|
||||
|
||||
async refresh(): Promise<CapacitySnapshot> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = this.performRefresh();
|
||||
try {
|
||||
return await this.inFlight;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
forceUnavailable(
|
||||
reason: Extract<CapacityReason, "database_unavailable" | "provider_unavailable">,
|
||||
error: string,
|
||||
): CapacitySnapshot {
|
||||
this.current = {
|
||||
...this.current,
|
||||
canServe: false,
|
||||
reason,
|
||||
checkedAtMs: this.now(),
|
||||
error,
|
||||
};
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<CapacitySnapshot> {
|
||||
try {
|
||||
const reading = await this.provider.readCapacity(this.model);
|
||||
const pricing = reading.pricing;
|
||||
const worstCaseJobUsdNanos =
|
||||
pricing.requestUsdNanos +
|
||||
pricing.promptUsdNanosPerToken * BigInt(this.maxInputTokens) +
|
||||
pricing.completionUsdNanosPerToken * BigInt(this.maxOutputTokens);
|
||||
const remaining = reading.keyRemainingUsdNanos;
|
||||
const sellable = remaining > this.reserveUsdNanos
|
||||
? remaining - this.reserveUsdNanos
|
||||
: 0n;
|
||||
const enoughForOne =
|
||||
worstCaseJobUsdNanos === 0n || sellable >= worstCaseJobUsdNanos;
|
||||
const aboveReserve = remaining >= this.reserveUsdNanos;
|
||||
const canServe = aboveReserve && enoughForOne;
|
||||
const reason: CapacityReason = canServe
|
||||
? "ready"
|
||||
: !aboveReserve
|
||||
? "below_reserve"
|
||||
: "insufficient_for_job";
|
||||
const availableJobs = worstCaseJobUsdNanos === 0n
|
||||
? null
|
||||
: sellable / worstCaseJobUsdNanos;
|
||||
|
||||
this.current = {
|
||||
provider: reading.provider,
|
||||
model: this.model,
|
||||
canServe,
|
||||
reason,
|
||||
checkedAtMs: reading.checkedAtMs,
|
||||
lastSuccessAtMs: reading.checkedAtMs,
|
||||
keyLimitUsdNanos: reading.keyLimitUsdNanos,
|
||||
remainingAllowanceUsdNanos: remaining,
|
||||
reserveUsdNanos: this.reserveUsdNanos,
|
||||
sellableUsdNanos: sellable,
|
||||
worstCaseJobUsdNanos,
|
||||
promptPriceUsdNanosPerToken: pricing.promptUsdNanosPerToken,
|
||||
completionPriceUsdNanosPerToken: pricing.completionUsdNanosPerToken,
|
||||
requestPriceUsdNanos: pricing.requestUsdNanos,
|
||||
availableJobs,
|
||||
limitReset: reading.limitReset,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.current = {
|
||||
...this.current,
|
||||
canServe: false,
|
||||
reason: "provider_unavailable",
|
||||
checkedAtMs: this.now(),
|
||||
error: detail,
|
||||
};
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
}
|
||||
101
supplier/src/reseller/dashboardPage.ts
Normal file
101
supplier/src/reseller/dashboardPage.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export function renderResellerDashboardPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Reseller capacity</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#071014;--panel:#0d1a20;--line:#24363d;--text:#eef5f5;--muted:#98aeb5;--good:#50d890;--warn:#ffcb66;--bad:#ff7b72;--accent:#75bfff}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#12303a 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:24px}h1{font-size:clamp(28px,5vw,48px);line-height:1.05;margin:0;letter-spacing:-.04em}header p{margin:8px 0 0;color:var(--muted)}.stamp{color:var(--muted);font-variant-numeric:tabular-nums}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:16px;box-shadow:0 16px 48px #0005}.identity{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:1px;overflow:hidden;margin-bottom:16px}.identity>div{padding:18px 20px;background:var(--panel)}.label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em;margin-bottom:6px}.value{font-size:17px;font-weight:650;overflow-wrap:anywhere}.status{display:inline-flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 4px #fff1}.status.free .dot{background:var(--good)}.status.working .dot{background:var(--warn)}.status.offline .dot{background:var(--bad)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:0 0 24px}.metric{padding:18px}.metric strong{display:block;font-size:24px;letter-spacing:-.03em;font-variant-numeric:tabular-nums}.metric small{color:var(--muted)}.section-head{display:flex;justify-content:space-between;align-items:center;margin:28px 0 12px}h2{font-size:18px;margin:0}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:940px}th,td{text-align:left;padding:13px 14px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:600}tbody tr:last-child td{border-bottom:0}.job-status{font-weight:700}.job-status.settled{color:var(--good)}.job-status.failed{color:var(--bad)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.preview{max-width:250px;color:#c9d8dc;white-space:normal}.empty,.error{padding:30px;color:var(--muted)}.error{color:var(--bad)}
|
||||
a.explorer-link{color:var(--accent);text-decoration:none;text-underline-offset:3px}a.explorer-link:hover,a.explorer-link:focus-visible{text-decoration:underline}
|
||||
@media(max-width:820px){header{align-items:flex-start;flex-direction:column}.identity{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:480px){main{width:min(100% - 20px,1180px);padding-top:24px}.metrics{grid-template-columns:1fr}.metric strong{font-size:21px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header><div><h1>Provider capacity resale</h1><p>Bounded upstream capacity settled through Vector escrow.</p></div><div class="stamp" id="updated">Loading current state</div></header>
|
||||
<section class="panel identity" aria-label="Agent identity">
|
||||
<div><span class="label">Route</span><span class="value" id="route">Loading</span></div>
|
||||
<div><span class="label">Marketplace model</span><span class="value" id="market-model">Loading</span></div>
|
||||
<div><span class="label">Supplier status</span><span class="status" id="status"><span class="dot"></span><span id="status-text">Loading</span></span><div class="stamp" id="reason"></div></div>
|
||||
</section>
|
||||
<section class="metrics" aria-label="Capacity and settlement metrics">
|
||||
<div class="panel metric"><span class="label">Sellable allowance</span><strong id="sellable">--</strong><small id="allowance">Provider allowance --</small></div>
|
||||
<div class="panel metric"><span class="label">Worst-case job</span><strong id="worst-job">--</strong><small id="available-jobs">-- jobs available</small></div>
|
||||
<div class="panel metric"><span class="label">AP3X earned</span><strong id="earned">--</strong><small id="settled-count">-- settled jobs</small></div>
|
||||
<div class="panel metric"><span class="label">Upstream spend</span><strong id="spend">--</strong><small id="failed-count">-- failed jobs</small></div>
|
||||
</section>
|
||||
<div class="section-head"><h2>Conversion timeline</h2><span class="stamp">Public previews require buyer consent</span></div>
|
||||
<section class="panel table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Model</th><th>Prompt preview</th><th>Output preview</th><th>Upstream</th><th>Payout</th><th>Escrow</th></tr></thead><tbody id="jobs"><tr><td colspan="8" class="empty">Loading jobs</td></tr></tbody></table></section>
|
||||
</main>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
var byId=function(id){return document.getElementById(id)};
|
||||
var text=function(id,value){byId(id).textContent=value==null?'--':String(value)};
|
||||
var money=function(value){return '$'+String(value==null?'0':value)};
|
||||
function renderStatus(state){
|
||||
text('route',state.provider+' / '+state.provider_model);
|
||||
text('market-model',state.marketplace_model);
|
||||
var status=byId('status');status.className='status '+state.status;
|
||||
text('status-text',state.status);text('reason',state.reason);
|
||||
text('sellable',money(state.capacity.sellable_usd));
|
||||
text('allowance','Allowance '+money(state.capacity.remaining_allowance_usd)+' · reserve '+money(state.capacity.protected_reserve_usd)+' · committed '+money(state.capacity.committed_usd));
|
||||
text('worst-job',money(state.capacity.worst_case_job_usd));
|
||||
text('available-jobs',state.capacity.available_jobs==null?'No balance limit':state.capacity.available_jobs+' jobs available');
|
||||
text('earned',state.totals.ap3x_earned+' AP3X');
|
||||
text('settled-count',state.totals.settled_jobs+' settled jobs');
|
||||
text('spend',money(state.totals.upstream_spend_usd));
|
||||
text('failed-count',state.totals.failed_jobs+' failed jobs');
|
||||
text('updated','Updated '+new Date().toLocaleTimeString());
|
||||
}
|
||||
function cell(row,value,className){var td=document.createElement('td');if(className)td.className=className;td.textContent=value==null?'--':String(value);row.appendChild(td)}
|
||||
function escrowCell(row,value){
|
||||
var td=document.createElement('td');td.className='mono';
|
||||
var ref=typeof value==='string'?value:'';
|
||||
var txHash=ref.split('#')[0];
|
||||
if(/^[0-9a-fA-F]{64}$/.test(txHash)){
|
||||
var link=document.createElement('a');
|
||||
link.className='explorer-link';
|
||||
link.href='https://vector.apexscan.org/en/transaction/'+txHash+'/summary/';
|
||||
link.target='_blank';link.rel='noopener noreferrer';
|
||||
link.textContent=ref;
|
||||
link.setAttribute('aria-label','View escrow transaction '+txHash+' on ApexScan');
|
||||
td.appendChild(link);
|
||||
}else{
|
||||
td.textContent=ref||'--';
|
||||
}
|
||||
row.appendChild(td);
|
||||
}
|
||||
function renderJobs(rows){
|
||||
var body=byId('jobs');body.replaceChildren();
|
||||
if(!rows.length){var row=document.createElement('tr');cell(row,'No terminal jobs yet','empty');row.firstChild.colSpan=8;body.appendChild(row);return}
|
||||
rows.forEach(function(job){
|
||||
var row=document.createElement('tr');
|
||||
cell(row,new Date(job.created_at).toLocaleString());
|
||||
cell(row,job.status,'job-status '+job.status);
|
||||
cell(row,job.marketplace_model,'mono');
|
||||
cell(row,job.prompt_preview,'preview');
|
||||
cell(row,job.output_preview,'preview');
|
||||
cell(row,job.upstream_cost_usd==null?'--':money(job.upstream_cost_usd));
|
||||
cell(row,job.ap3x_payout+' AP3X');
|
||||
escrowCell(row,job.escrow_ref);
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
function showError(message){var body=byId('jobs');body.replaceChildren();var row=document.createElement('tr');cell(row,message,'error');row.firstChild.colSpan=8;body.appendChild(row);text('updated','Data unavailable')}
|
||||
async function refresh(){
|
||||
try{
|
||||
var results=await Promise.all([fetch('/reseller/api/status',{cache:'no-store'}),fetch('/reseller/api/jobs?limit=50',{cache:'no-store'})]);
|
||||
if(!results[0].ok||!results[1].ok)throw new Error('Seller evidence is unavailable');
|
||||
var data=await Promise.all([results[0].json(),results[1].json()]);renderStatus(data[0]);renderJobs(data[1].jobs||[]);
|
||||
}catch(error){showError(error instanceof Error?error.message:'Seller evidence is unavailable')}
|
||||
}
|
||||
refresh();setInterval(refresh,5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
446
supplier/src/reseller/evidenceStore.ts
Normal file
446
supplier/src/reseller/evidenceStore.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { Pool } from "pg";
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import type { CapacitySnapshot } from "./capacityGate.js";
|
||||
import { formatUsdNanos } from "./money.js";
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS reseller_jobs (
|
||||
escrow_ref text PRIMARY KEY,
|
||||
local_job_id text,
|
||||
status text NOT NULL CHECK (status IN ('received','claimed','running','submitting','submitted','settled','failed')),
|
||||
provider text NOT NULL,
|
||||
provider_model text NOT NULL,
|
||||
marketplace_model text NOT NULL,
|
||||
request_json jsonb NOT NULL,
|
||||
response_json jsonb,
|
||||
receipt_json jsonb,
|
||||
public_preview boolean NOT NULL DEFAULT false,
|
||||
prompt_preview text,
|
||||
output_preview text,
|
||||
balance_before_nanos bigint NOT NULL,
|
||||
balance_after_nanos bigint,
|
||||
worst_case_cost_nanos bigint NOT NULL,
|
||||
actual_cost_nanos bigint,
|
||||
price_lovelace bigint NOT NULL,
|
||||
prompt_tokens integer,
|
||||
completion_tokens integer,
|
||||
claim_tx_hash text,
|
||||
submitted_ref text,
|
||||
failure_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
settled_at timestamptz
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reseller_jobs_status_idx
|
||||
ON reseller_jobs (status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS reseller_jobs_created_idx
|
||||
ON reseller_jobs (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reseller_capacity (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
provider text NOT NULL,
|
||||
model text NOT NULL,
|
||||
can_serve boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
checked_at timestamptz,
|
||||
remaining_allowance_nanos bigint NOT NULL,
|
||||
reserve_nanos bigint NOT NULL,
|
||||
sellable_nanos bigint NOT NULL,
|
||||
worst_case_job_nanos bigint NOT NULL,
|
||||
available_jobs bigint,
|
||||
limit_reset text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`;
|
||||
|
||||
export interface ReceivedJob {
|
||||
escrowRef: string;
|
||||
provider: string;
|
||||
providerModel: string;
|
||||
marketplaceModel: string;
|
||||
messages: ChatMessage[];
|
||||
publicPreview: boolean;
|
||||
balanceBeforeUsdNanos: bigint;
|
||||
worstCaseCostUsdNanos: bigint;
|
||||
priceLovelace: bigint;
|
||||
}
|
||||
|
||||
export interface RecordedOutput {
|
||||
response: Record<string, unknown>;
|
||||
receipt: Record<string, unknown>;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
actualCostUsdNanos: bigint | null;
|
||||
outputText: string;
|
||||
}
|
||||
|
||||
export interface SubmittedJobRow {
|
||||
escrowRef: string;
|
||||
submittedRef: string;
|
||||
}
|
||||
|
||||
export interface PublicResellerJob {
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
provider: string;
|
||||
provider_model: string;
|
||||
marketplace_model: string;
|
||||
prompt_preview: string | null;
|
||||
output_preview: string | null;
|
||||
upstream_cost_usd: string | null;
|
||||
ap3x_payout: string;
|
||||
claim_tx_hash: string | null;
|
||||
submitted_ref: string | null;
|
||||
failure_reason: string | null;
|
||||
created_at: string;
|
||||
settled_at: string | null;
|
||||
}
|
||||
|
||||
export interface PublicResellerTotals {
|
||||
settled_jobs: number;
|
||||
failed_jobs: number;
|
||||
upstream_spend_usd: string;
|
||||
ap3x_earned: string;
|
||||
}
|
||||
|
||||
export class ResellerEvidenceStore {
|
||||
private readonly pool: Pool;
|
||||
private readonly previewMaxChars: number;
|
||||
|
||||
constructor(databaseUrl: string, previewMaxChars: number) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl, max: 4 });
|
||||
this.previewMaxChars = previewMaxChars;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock(9042713861245)");
|
||||
await client.query(SCHEMA_SQL);
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock(9042713861245)").catch(() => undefined);
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.pool.query("SELECT 1");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
async markInterruptedJobs(): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE reseller_jobs
|
||||
SET status = 'failed', failure_reason = 'interrupted_on_restart', updated_at = now()
|
||||
WHERE status IN ('received','claimed','running')`,
|
||||
);
|
||||
}
|
||||
|
||||
async saveCapacity(snapshot: CapacitySnapshot): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO reseller_capacity (
|
||||
singleton, provider, model, can_serve, reason, checked_at,
|
||||
remaining_allowance_nanos, reserve_nanos, sellable_nanos,
|
||||
worst_case_job_nanos, available_jobs, limit_reset, updated_at
|
||||
) VALUES (true,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
||||
ON CONFLICT (singleton) DO UPDATE SET
|
||||
provider=EXCLUDED.provider, model=EXCLUDED.model,
|
||||
can_serve=EXCLUDED.can_serve, reason=EXCLUDED.reason,
|
||||
checked_at=EXCLUDED.checked_at,
|
||||
remaining_allowance_nanos=EXCLUDED.remaining_allowance_nanos,
|
||||
reserve_nanos=EXCLUDED.reserve_nanos,
|
||||
sellable_nanos=EXCLUDED.sellable_nanos,
|
||||
worst_case_job_nanos=EXCLUDED.worst_case_job_nanos,
|
||||
available_jobs=EXCLUDED.available_jobs,
|
||||
limit_reset=EXCLUDED.limit_reset, updated_at=now()`,
|
||||
[
|
||||
snapshot.provider,
|
||||
snapshot.model,
|
||||
snapshot.canServe,
|
||||
snapshot.reason,
|
||||
snapshot.checkedAtMs === null ? null : new Date(snapshot.checkedAtMs),
|
||||
snapshot.remainingAllowanceUsdNanos.toString(),
|
||||
snapshot.reserveUsdNanos.toString(),
|
||||
snapshot.sellableUsdNanos.toString(),
|
||||
snapshot.worstCaseJobUsdNanos.toString(),
|
||||
snapshot.availableJobs?.toString() ?? null,
|
||||
snapshot.limitReset,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async recordReceived(job: ReceivedJob): Promise<void> {
|
||||
const promptPreview = job.publicPreview
|
||||
? redactPublicPreview(
|
||||
job.messages.map((message) => `${message.role}: ${String(message.content)}`).join(" "),
|
||||
this.previewMaxChars,
|
||||
)
|
||||
: null;
|
||||
await this.pool.query(
|
||||
`INSERT INTO reseller_jobs (
|
||||
escrow_ref, status, provider, provider_model, marketplace_model,
|
||||
request_json, public_preview, prompt_preview, balance_before_nanos,
|
||||
worst_case_cost_nanos, price_lovelace
|
||||
) VALUES ($1,'received',$2,$3,$4,$5::jsonb,$6,$7,$8,$9,$10)`,
|
||||
[
|
||||
job.escrowRef,
|
||||
job.provider,
|
||||
job.providerModel,
|
||||
job.marketplaceModel,
|
||||
JSON.stringify(job.messages),
|
||||
job.publicPreview,
|
||||
promptPreview,
|
||||
job.balanceBeforeUsdNanos.toString(),
|
||||
job.worstCaseCostUsdNanos.toString(),
|
||||
job.priceLovelace.toString(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async recordClaimed(
|
||||
escrowRef: string,
|
||||
claimTxHash: string,
|
||||
localJobId: string,
|
||||
): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["received"],
|
||||
`status='claimed', claim_tx_hash=$2, local_job_id=$3`,
|
||||
[claimTxHash, localJobId],
|
||||
);
|
||||
}
|
||||
|
||||
async recordInferenceStarted(escrowRef: string): Promise<void> {
|
||||
await this.transition(escrowRef, ["claimed"], `status='running'`, []);
|
||||
}
|
||||
|
||||
async recordOutput(escrowRef: string, output: RecordedOutput): Promise<void> {
|
||||
const outputPreview = await this.publicPreviewEnabled(escrowRef)
|
||||
? redactPublicPreview(output.outputText, this.previewMaxChars)
|
||||
: null;
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["running"],
|
||||
`response_json=$2::jsonb, receipt_json=$3::jsonb,
|
||||
prompt_tokens=$4, completion_tokens=$5, actual_cost_nanos=$6,
|
||||
output_preview=$7`,
|
||||
[
|
||||
JSON.stringify(output.response),
|
||||
JSON.stringify(output.receipt),
|
||||
output.promptTokens,
|
||||
output.completionTokens,
|
||||
output.actualCostUsdNanos?.toString() ?? null,
|
||||
outputPreview,
|
||||
],
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["running"],
|
||||
`status='submitting', submitted_ref=$2`,
|
||||
[submittedRef],
|
||||
);
|
||||
}
|
||||
|
||||
async recordSubmitConfirmed(escrowRef: string): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["submitting"],
|
||||
`status='submitted'`,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
async recordSettled(
|
||||
escrowRef: string,
|
||||
balanceAfterUsdNanos: bigint | null,
|
||||
): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["submitted"],
|
||||
`status='settled', balance_after_nanos=$2, settled_at=now()`,
|
||||
[balanceAfterUsdNanos?.toString() ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
async recordFailed(escrowRef: string, reason: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE reseller_jobs
|
||||
SET status='failed', failure_reason=$2, updated_at=now()
|
||||
WHERE escrow_ref=$1 AND status <> 'settled'`,
|
||||
[escrowRef, reason.slice(0, 1000)],
|
||||
);
|
||||
}
|
||||
|
||||
async listSubmitting(): Promise<SubmittedJobRow[]> {
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
submitted_ref: string;
|
||||
}>(
|
||||
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
|
||||
WHERE status='submitting' AND submitted_ref IS NOT NULL`,
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrowRef: row.escrow_ref,
|
||||
submittedRef: row.submitted_ref,
|
||||
}));
|
||||
}
|
||||
|
||||
async listSubmitted(): Promise<SubmittedJobRow[]> {
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
submitted_ref: string;
|
||||
}>(
|
||||
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
|
||||
WHERE status='submitted' AND submitted_ref IS NOT NULL`,
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrowRef: row.escrow_ref,
|
||||
submittedRef: row.submitted_ref,
|
||||
}));
|
||||
}
|
||||
|
||||
async listPublicJobs(limit: number): Promise<PublicResellerJob[]> {
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
provider: string;
|
||||
provider_model: string;
|
||||
marketplace_model: string;
|
||||
prompt_preview: string | null;
|
||||
output_preview: string | null;
|
||||
actual_cost_nanos: string | null;
|
||||
price_lovelace: string;
|
||||
claim_tx_hash: string | null;
|
||||
submitted_ref: string | null;
|
||||
failure_reason: string | null;
|
||||
created_at: Date;
|
||||
settled_at: Date | null;
|
||||
}>(
|
||||
`SELECT escrow_ref,status,provider,provider_model,marketplace_model,
|
||||
prompt_preview,output_preview,actual_cost_nanos,price_lovelace,
|
||||
claim_tx_hash,submitted_ref,failure_reason,created_at,settled_at
|
||||
FROM reseller_jobs WHERE status IN ('settled','failed')
|
||||
ORDER BY created_at DESC LIMIT $1`,
|
||||
[safeLimit],
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrow_ref: row.escrow_ref,
|
||||
status: row.status,
|
||||
provider: row.provider,
|
||||
provider_model: row.provider_model,
|
||||
marketplace_model: row.marketplace_model,
|
||||
prompt_preview: row.prompt_preview,
|
||||
output_preview: row.output_preview,
|
||||
upstream_cost_usd: row.actual_cost_nanos === null
|
||||
? null
|
||||
: formatUsdNanos(BigInt(row.actual_cost_nanos)),
|
||||
ap3x_payout: row.status === "settled"
|
||||
? formatAp3x(BigInt(row.price_lovelace))
|
||||
: "0.000000",
|
||||
claim_tx_hash: row.claim_tx_hash,
|
||||
submitted_ref: row.submitted_ref,
|
||||
failure_reason: row.failure_reason,
|
||||
created_at: row.created_at.toISOString(),
|
||||
settled_at: row.settled_at?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async publicTotals(): Promise<PublicResellerTotals> {
|
||||
const result = await this.pool.query<{
|
||||
settled_jobs: string;
|
||||
failed_jobs: string;
|
||||
spend_nanos: string;
|
||||
earned_lovelace: string;
|
||||
}>(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE status='settled')::text AS settled_jobs,
|
||||
count(*) FILTER (WHERE status='failed')::text AS failed_jobs,
|
||||
coalesce(sum(actual_cost_nanos),0)::text AS spend_nanos,
|
||||
coalesce(sum(price_lovelace) FILTER (WHERE status='settled'),0)::text AS earned_lovelace
|
||||
FROM reseller_jobs`,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
settled_jobs: Number(row?.settled_jobs ?? 0),
|
||||
failed_jobs: Number(row?.failed_jobs ?? 0),
|
||||
upstream_spend_usd: formatUsdNanos(BigInt(row?.spend_nanos ?? 0)),
|
||||
ap3x_earned: formatAp3x(BigInt(row?.earned_lovelace ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
private async publicPreviewEnabled(escrowRef: string): Promise<boolean> {
|
||||
const result = await this.pool.query<{ public_preview: boolean }>(
|
||||
"SELECT public_preview FROM reseller_jobs WHERE escrow_ref=$1",
|
||||
[escrowRef],
|
||||
);
|
||||
return result.rows[0]?.public_preview === true;
|
||||
}
|
||||
|
||||
private async transition(
|
||||
escrowRef: string,
|
||||
expected: string[],
|
||||
assignment: string,
|
||||
values: unknown[],
|
||||
requireStatusChange = true,
|
||||
): Promise<void> {
|
||||
const parameters = [escrowRef, ...values];
|
||||
const expectedPlaceholders = expected
|
||||
.map((_, index) => `$${parameters.length + index + 1}`)
|
||||
.join(",");
|
||||
parameters.push(...expected);
|
||||
const result = await this.pool.query(
|
||||
`UPDATE reseller_jobs SET ${assignment}, updated_at=now()
|
||||
WHERE escrow_ref=$1 AND status IN (${expectedPlaceholders})`,
|
||||
parameters,
|
||||
);
|
||||
if (result.rowCount !== 1 && requireStatusChange) {
|
||||
throw new Error(
|
||||
`invalid reseller job transition for ${escrowRef}; expected ${expected.join("|")}`,
|
||||
);
|
||||
}
|
||||
if (result.rowCount !== 1 && !requireStatusChange) {
|
||||
throw new Error(`reseller job not running: ${escrowRef}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function redactPublicPreview(value: string, maxChars: number): string {
|
||||
const redacted = value
|
||||
.replace(
|
||||
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
|
||||
"[private material]",
|
||||
)
|
||||
.replace(
|
||||
/["']?\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PRIV[_-]?KEY)\b["']?\s*[:=]\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s,}]+)/gi,
|
||||
"[secret assignment]",
|
||||
)
|
||||
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt]")
|
||||
.replace(/\b(?:gh[opsu]_|github_pat_)[A-Za-z0-9_]{16,}\b/g, "[secret]")
|
||||
.replace(/\b[0-9a-fA-F]{64}\b/g, "[64-hex secret]")
|
||||
.replace(/\b(?:sk|key)-[A-Za-z0-9_-]{8,}\b/g, "[secret]")
|
||||
.replace(/\bBearer\s+\S+/gi, "Bearer [secret]")
|
||||
.replace(/\bBasic\s+[A-Za-z0-9+/=]+/gi, "Basic [secret]")
|
||||
.replace(/\b(?:xox[baprs]-|AKIA)[A-Za-z0-9_-]{12,}\b/g, "[secret]")
|
||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return redacted.length <= maxChars
|
||||
? redacted
|
||||
: `${redacted.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
function formatAp3x(lovelace: bigint): string {
|
||||
const whole = lovelace / 1_000_000n;
|
||||
const fraction = (lovelace % 1_000_000n).toString().padStart(6, "0");
|
||||
return `${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
54
supplier/src/reseller/money.ts
Normal file
54
supplier/src/reseller/money.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const USD_NANOS = 1_000_000_000n;
|
||||
|
||||
export type DecimalRounding = "floor" | "ceil";
|
||||
|
||||
/** Parse a non-negative decimal USD string into integer nanodollars. */
|
||||
export function parseUsdDecimal(
|
||||
raw: string,
|
||||
rounding: DecimalRounding,
|
||||
): bigint {
|
||||
const match = /^(?:0|[1-9]\d*)(?:\.(\d+))?$/.exec(raw);
|
||||
if (!match) throw new Error(`invalid USD decimal: ${raw}`);
|
||||
|
||||
const dot = raw.indexOf(".");
|
||||
const wholeRaw = dot === -1 ? raw : raw.slice(0, dot);
|
||||
const fractionRaw = dot === -1 ? "" : raw.slice(dot + 1);
|
||||
const kept = fractionRaw.slice(0, 9).padEnd(9, "0");
|
||||
let nanos = BigInt(wholeRaw) * USD_NANOS + BigInt(kept || "0");
|
||||
if (rounding === "ceil" && /[1-9]/.test(fractionRaw.slice(9))) {
|
||||
nanos += 1n;
|
||||
}
|
||||
return nanos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a provider JSON number to a conservative remaining-balance value.
|
||||
* One nanodollar is removed to protect against binary floating-point rounding.
|
||||
*/
|
||||
export function parseUsdNumberFloor(value: unknown, field: string): bigint {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
const scaled = BigInt(Math.floor(value * Number(USD_NANOS)));
|
||||
return scaled > 0n ? scaled - 1n : 0n;
|
||||
}
|
||||
|
||||
/** Convert a provider JSON cost to nanodollars without rounding down. */
|
||||
export function parseUsdNumberCeil(value: unknown, field: string): bigint {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
if (value === 0) return 0n;
|
||||
return BigInt(Math.ceil(value * Number(USD_NANOS))) + 1n;
|
||||
}
|
||||
|
||||
export function formatUsdNanos(value: bigint, decimals = 6): string {
|
||||
if (value < 0n) throw new Error("USD value must be non-negative");
|
||||
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
|
||||
throw new Error("USD display decimals must be from 0 to 9");
|
||||
}
|
||||
const whole = value / USD_NANOS;
|
||||
if (decimals === 0) return whole.toString();
|
||||
const fraction = (value % USD_NANOS).toString().padStart(9, "0");
|
||||
return `${whole}.${fraction.slice(0, decimals)}`;
|
||||
}
|
||||
189
supplier/src/reseller/openRouterCapacity.ts
Normal file
189
supplier/src/reseller/openRouterCapacity.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
CapacityProvider,
|
||||
ProviderCapacityReading,
|
||||
ProviderModelPricing,
|
||||
} from "./provider.js";
|
||||
import { parseUsdDecimal, parseUsdNumberFloor } from "./money.js";
|
||||
|
||||
interface OpenRouterCapacityOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
pricingCacheMs?: number;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class OpenRouterCapacityProvider implements CapacityProvider {
|
||||
readonly id = "openrouter";
|
||||
|
||||
private readonly apiRoot: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly pricingCacheMs: number;
|
||||
private readonly fetchFn: typeof globalThis.fetch;
|
||||
private readonly now: () => number;
|
||||
private pricingCache: ProviderModelPricing | null = null;
|
||||
|
||||
constructor(options: OpenRouterCapacityOptions) {
|
||||
this.apiRoot = `${options.baseUrl.replace(/\/+$/, "")}/v1`;
|
||||
this.apiKey = options.apiKey;
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.pricingCacheMs = options.pricingCacheMs ?? 300_000;
|
||||
this.fetchFn = options.fetchFn ?? globalThis.fetch;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async readCapacity(model: string): Promise<ProviderCapacityReading> {
|
||||
const [key, pricing] = await Promise.all([
|
||||
this.readKeyLimit(),
|
||||
this.readModelPricing(model),
|
||||
]);
|
||||
return {
|
||||
provider: this.id,
|
||||
keyLimitUsdNanos: key.limit,
|
||||
keyRemainingUsdNanos: key.remaining,
|
||||
limitReset: key.reset,
|
||||
pricing,
|
||||
checkedAtMs: this.now(),
|
||||
};
|
||||
}
|
||||
|
||||
private async readKeyLimit(): Promise<{
|
||||
limit: bigint;
|
||||
remaining: bigint;
|
||||
reset: string | null;
|
||||
}> {
|
||||
const body = await this.fetchJson(`${this.apiRoot}/key`);
|
||||
const data = readRecord(body, "OpenRouter key response").data;
|
||||
const key = readRecord(data, "OpenRouter key response.data");
|
||||
if (key.limit === null || key.limit_remaining === null) {
|
||||
throw new Error("OpenRouter resale key must have a finite spending limit");
|
||||
}
|
||||
const limit = parseUsdNumberFloor(key.limit, "OpenRouter key limit");
|
||||
const remaining = parseUsdNumberFloor(
|
||||
key.limit_remaining,
|
||||
"OpenRouter key limit_remaining",
|
||||
);
|
||||
const reset = key.limit_reset;
|
||||
if (reset !== null && typeof reset !== "string") {
|
||||
throw new Error("OpenRouter key limit_reset must be a string or null");
|
||||
}
|
||||
return { limit, remaining, reset };
|
||||
}
|
||||
|
||||
private async readModelPricing(model: string): Promise<ProviderModelPricing> {
|
||||
const cached = this.pricingCache;
|
||||
const now = this.now();
|
||||
if (
|
||||
cached &&
|
||||
cached.model === model &&
|
||||
now - cached.fetchedAtMs < this.pricingCacheMs
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const body = await this.fetchJson(`${this.apiRoot}/models`);
|
||||
const root = readRecord(body, "OpenRouter models response");
|
||||
if (!Array.isArray(root.data)) {
|
||||
throw new Error("OpenRouter models response.data must be an array");
|
||||
}
|
||||
const rawModel = root.data.find(
|
||||
(entry) =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).id === model,
|
||||
);
|
||||
if (!rawModel) throw new Error(`OpenRouter model not found: ${model}`);
|
||||
const modelRecord = rawModel as Record<string, unknown>;
|
||||
const pricing = readRecord(modelRecord.pricing, `OpenRouter pricing for ${model}`);
|
||||
const prompt = readPrice(pricing.prompt, `${model} prompt price`);
|
||||
const completion = readPrice(
|
||||
pricing.completion,
|
||||
`${model} completion price`,
|
||||
);
|
||||
const request = pricing.request === undefined
|
||||
? 0n
|
||||
: readPrice(pricing.request, `${model} request price`);
|
||||
|
||||
const result: ProviderModelPricing = {
|
||||
model,
|
||||
promptUsdNanosPerToken: prompt,
|
||||
completionUsdNanosPerToken: completion,
|
||||
requestUsdNanos: request,
|
||||
fetchedAtMs: now,
|
||||
};
|
||||
this.pricingCache = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const response = await this.fetchFn(url, {
|
||||
headers: { authorization: `Bearer ${this.apiKey}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await readBoundedBody(response, 8 * 1024 * 1024, controller);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenRouter capacity request returned HTTP ${response.status}${
|
||||
text ? `: ${text.slice(0, 200)}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OpenRouter capacity response was not JSON: ${detail}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OpenRouter capacity request failed: ${detail}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBody(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
controller: AbortController,
|
||||
): Promise<string> {
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parts: string[] = [];
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
controller.abort();
|
||||
throw new Error(`OpenRouter capacity response exceeded ${maxBytes} bytes`);
|
||||
}
|
||||
parts.push(decoder.decode(chunk.value, { stream: true }));
|
||||
}
|
||||
parts.push(decoder.decode());
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function readRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readPrice(value: unknown, label: string): bigint {
|
||||
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
||||
try {
|
||||
return parseUsdDecimal(value, "ceil");
|
||||
} catch {
|
||||
throw new Error(`${label} must be a non-negative decimal`);
|
||||
}
|
||||
}
|
||||
22
supplier/src/reseller/provider.ts
Normal file
22
supplier/src/reseller/provider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface ProviderModelPricing {
|
||||
model: string;
|
||||
promptUsdNanosPerToken: bigint;
|
||||
completionUsdNanosPerToken: bigint;
|
||||
requestUsdNanos: bigint;
|
||||
fetchedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ProviderCapacityReading {
|
||||
provider: string;
|
||||
keyLimitUsdNanos: bigint;
|
||||
keyRemainingUsdNanos: bigint;
|
||||
limitReset: string | null;
|
||||
pricing: ProviderModelPricing;
|
||||
checkedAtMs: number;
|
||||
}
|
||||
|
||||
/** Provider-specific balance and price discovery. Inference stays OpenAI-compatible. */
|
||||
export interface CapacityProvider {
|
||||
readonly id: string;
|
||||
readCapacity(model: string): Promise<ProviderCapacityReading>;
|
||||
}
|
||||
331
supplier/src/reseller/runtime.ts
Normal file
331
supplier/src/reseller/runtime.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
||||
import type { SupplierState } from "../state.js";
|
||||
import { CapacityGate, type CapacitySnapshot } from "./capacityGate.js";
|
||||
import {
|
||||
ResellerEvidenceStore,
|
||||
type PublicResellerJob,
|
||||
type RecordedOutput,
|
||||
} from "./evidenceStore.js";
|
||||
import { formatUsdNanos } from "./money.js";
|
||||
|
||||
interface ResellerRuntimeOptions {
|
||||
chain: ChainProvider;
|
||||
state: SupplierState;
|
||||
gate: CapacityGate;
|
||||
store: ResellerEvidenceStore;
|
||||
advert: AdvertDatum;
|
||||
advertRef: OutputReference;
|
||||
providerModel: string;
|
||||
pollIntervalMs: number;
|
||||
settlementPollMs?: number;
|
||||
}
|
||||
|
||||
export interface PublicResellerState {
|
||||
provider: string;
|
||||
provider_model: string;
|
||||
marketplace_model: string;
|
||||
advert_ref: string;
|
||||
supplier_pkh: string;
|
||||
status: "free" | "working" | "offline";
|
||||
reason: string;
|
||||
capacity: {
|
||||
remaining_allowance_usd: string;
|
||||
protected_reserve_usd: string;
|
||||
sellable_usd: string;
|
||||
committed_usd: string;
|
||||
worst_case_job_usd: string;
|
||||
available_jobs: string | null;
|
||||
checked_at: string | null;
|
||||
limit_reset: string | null;
|
||||
};
|
||||
totals: {
|
||||
settled_jobs: number;
|
||||
failed_jobs: number;
|
||||
upstream_spend_usd: string;
|
||||
ap3x_earned: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ResellerRuntime {
|
||||
readonly maxInputTokens: number;
|
||||
|
||||
private readonly chain: ChainProvider;
|
||||
private readonly state: SupplierState;
|
||||
private readonly gate: CapacityGate;
|
||||
private readonly store: ResellerEvidenceStore;
|
||||
private readonly advert: AdvertDatum;
|
||||
private readonly advertRef: OutputReference;
|
||||
private readonly providerModel: string;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly settlementPollMs: number;
|
||||
private capacityTimer: NodeJS.Timeout | null = null;
|
||||
private readonly settlementTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly submissionTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
constructor(options: ResellerRuntimeOptions & { maxInputTokens: number }) {
|
||||
this.chain = options.chain;
|
||||
this.state = options.state;
|
||||
this.gate = options.gate;
|
||||
this.store = options.store;
|
||||
this.advert = options.advert;
|
||||
this.advertRef = options.advertRef;
|
||||
this.providerModel = options.providerModel;
|
||||
this.pollIntervalMs = options.pollIntervalMs;
|
||||
this.settlementPollMs = options.settlementPollMs ?? 5_000;
|
||||
this.maxInputTokens = options.maxInputTokens;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.store.initialize();
|
||||
await this.store.markInterruptedJobs();
|
||||
await this.refreshCapacity();
|
||||
this.capacityTimer = setInterval(() => {
|
||||
void this.refreshCapacity().catch(() => undefined);
|
||||
}, this.pollIntervalMs);
|
||||
this.capacityTimer.unref();
|
||||
|
||||
for (const row of await this.store.listSubmitting()) {
|
||||
this.trackSubmissionConfirmation(row.escrowRef, row.submittedRef);
|
||||
}
|
||||
for (const row of await this.store.listSubmitted()) {
|
||||
this.trackSettlement(row.escrowRef, row.submittedRef);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
clearInterval(this.capacityTimer ?? undefined);
|
||||
this.capacityTimer = null;
|
||||
for (const timer of this.settlementTimers.values()) clearInterval(timer);
|
||||
this.settlementTimers.clear();
|
||||
for (const timer of this.submissionTimers.values()) clearInterval(timer);
|
||||
this.submissionTimers.clear();
|
||||
await this.store.close();
|
||||
}
|
||||
|
||||
capacitySnapshot(): CapacitySnapshot {
|
||||
return this.gate.snapshot();
|
||||
}
|
||||
|
||||
effectiveStatus(): "free" | "working" | "offline" {
|
||||
const base = this.state.snapshot().status;
|
||||
if (base === "working") return "working";
|
||||
if (base === "offline") return "offline";
|
||||
return this.gate.snapshot().canServe ? "free" : "offline";
|
||||
}
|
||||
|
||||
async refreshCapacity(): Promise<CapacitySnapshot> {
|
||||
try {
|
||||
await this.store.ping();
|
||||
const snapshot = await this.gate.refresh();
|
||||
await this.store.saveCapacity(snapshot);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.gate.forceUnavailable("database_unavailable", detail);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async recordReceived(
|
||||
escrowRef: string,
|
||||
messages: ChatMessage[],
|
||||
publicPreview: boolean,
|
||||
): Promise<void> {
|
||||
const snapshot = this.gate.snapshot();
|
||||
await this.databaseOperation(() => this.store.recordReceived({
|
||||
escrowRef,
|
||||
provider: snapshot.provider,
|
||||
providerModel: this.providerModel,
|
||||
marketplaceModel: this.advert.model,
|
||||
messages,
|
||||
publicPreview,
|
||||
balanceBeforeUsdNanos: snapshot.remainingAllowanceUsdNanos,
|
||||
worstCaseCostUsdNanos: snapshot.worstCaseJobUsdNanos,
|
||||
priceLovelace: this.advert.price_lovelace,
|
||||
}));
|
||||
}
|
||||
|
||||
async recordClaimed(
|
||||
escrowRef: string,
|
||||
claimTxHash: string,
|
||||
localJobId: string,
|
||||
): Promise<void> {
|
||||
await this.databaseOperation(
|
||||
() => this.store.recordClaimed(escrowRef, claimTxHash, localJobId),
|
||||
);
|
||||
}
|
||||
|
||||
async recordInferenceStarted(escrowRef: string): Promise<void> {
|
||||
await this.databaseOperation(() => this.store.recordInferenceStarted(escrowRef));
|
||||
}
|
||||
|
||||
async recordOutput(
|
||||
escrowRef: string,
|
||||
output: Omit<RecordedOutput, "actualCostUsdNanos"> & {
|
||||
actualCostUsdNanos?: bigint | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const snapshot = this.gate.snapshot();
|
||||
const calculatedCost =
|
||||
snapshot.requestPriceUsdNanos +
|
||||
snapshot.promptPriceUsdNanosPerToken * BigInt(output.promptTokens) +
|
||||
snapshot.completionPriceUsdNanosPerToken * BigInt(output.completionTokens);
|
||||
await this.databaseOperation(() => this.store.recordOutput(escrowRef, {
|
||||
...output,
|
||||
actualCostUsdNanos: output.actualCostUsdNanos ?? calculatedCost,
|
||||
}));
|
||||
}
|
||||
|
||||
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
|
||||
await this.databaseOperation(
|
||||
() => this.store.recordSubmitting(escrowRef, submittedRef),
|
||||
);
|
||||
}
|
||||
|
||||
async recordSubmitConfirmed(
|
||||
escrowRef: string,
|
||||
submittedRef: string,
|
||||
): Promise<void> {
|
||||
await this.databaseOperation(
|
||||
() => this.store.recordSubmitConfirmed(escrowRef),
|
||||
);
|
||||
this.trackSettlement(escrowRef, submittedRef);
|
||||
}
|
||||
|
||||
watchSubmitting(escrowRef: string, submittedRef: string): void {
|
||||
this.trackSubmissionConfirmation(escrowRef, submittedRef);
|
||||
}
|
||||
|
||||
async recordFailed(escrowRef: string, reason: string): Promise<void> {
|
||||
await this.databaseOperation(() => this.store.recordFailed(escrowRef, reason));
|
||||
}
|
||||
|
||||
async publicState(): Promise<PublicResellerState> {
|
||||
try {
|
||||
await this.store.ping();
|
||||
const totals = await this.store.publicTotals();
|
||||
const capacity = this.gate.snapshot();
|
||||
const base = this.state.snapshot();
|
||||
const status = this.effectiveStatus();
|
||||
const committed = base.status === "working"
|
||||
? capacity.worstCaseJobUsdNanos
|
||||
: 0n;
|
||||
return {
|
||||
provider: capacity.provider,
|
||||
provider_model: this.providerModel,
|
||||
marketplace_model: this.advert.model,
|
||||
advert_ref: `${this.advertRef.txHash}#${this.advertRef.index}`,
|
||||
supplier_pkh: this.advert.supplier_pkh,
|
||||
status,
|
||||
reason: status === "working" ? "working" : capacity.reason,
|
||||
capacity: {
|
||||
remaining_allowance_usd: formatUsdNanos(capacity.remainingAllowanceUsdNanos),
|
||||
protected_reserve_usd: formatUsdNanos(capacity.reserveUsdNanos),
|
||||
sellable_usd: formatUsdNanos(capacity.sellableUsdNanos),
|
||||
committed_usd: formatUsdNanos(committed),
|
||||
worst_case_job_usd: formatUsdNanos(capacity.worstCaseJobUsdNanos),
|
||||
available_jobs: capacity.availableJobs?.toString() ?? null,
|
||||
checked_at: capacity.checkedAtMs === null
|
||||
? null
|
||||
: new Date(capacity.checkedAtMs).toISOString(),
|
||||
limit_reset: capacity.limitReset,
|
||||
},
|
||||
totals,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.gate.forceUnavailable("database_unavailable", detail);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async publicJobs(limit: number): Promise<PublicResellerJob[]> {
|
||||
try {
|
||||
return await this.store.listPublicJobs(limit);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.gate.forceUnavailable("database_unavailable", detail);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async databaseOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.gate.forceUnavailable("database_unavailable", detail);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private trackSubmissionConfirmation(
|
||||
escrowRef: string,
|
||||
submittedRef: string,
|
||||
): void {
|
||||
if (this.submissionTimers.has(escrowRef)) return;
|
||||
const ref = parseOutputReference(submittedRef);
|
||||
if (!ref) return;
|
||||
let running = false;
|
||||
const check = async (): Promise<void> => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const utxo = await this.chain.queryUtxo(ref);
|
||||
if (utxo === null) return;
|
||||
await this.recordSubmitConfirmed(escrowRef, submittedRef);
|
||||
clearInterval(this.submissionTimers.get(escrowRef));
|
||||
this.submissionTimers.delete(escrowRef);
|
||||
} catch {
|
||||
// A later poll retries. Provider inference is never replayed.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => void check(), this.settlementPollMs);
|
||||
timer.unref();
|
||||
this.submissionTimers.set(escrowRef, timer);
|
||||
void check();
|
||||
}
|
||||
|
||||
private trackSettlement(escrowRef: string, submittedRef: string): void {
|
||||
if (this.settlementTimers.has(escrowRef)) return;
|
||||
const ref = parseOutputReference(submittedRef);
|
||||
if (!ref) return;
|
||||
let running = false;
|
||||
const check = async (): Promise<void> => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const utxo = await this.chain.queryUtxo(ref);
|
||||
if (utxo !== null) return;
|
||||
await this.refreshCapacity().catch(() => this.gate.snapshot());
|
||||
const balance = this.gate.snapshot().error === null
|
||||
? this.gate.snapshot().remainingAllowanceUsdNanos
|
||||
: null;
|
||||
await this.databaseOperation(
|
||||
() => this.store.recordSettled(escrowRef, balance),
|
||||
);
|
||||
clearInterval(this.settlementTimers.get(escrowRef));
|
||||
this.settlementTimers.delete(escrowRef);
|
||||
} catch {
|
||||
// A later poll retries chain or database recovery. Inference is never replayed.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => void check(), this.settlementPollMs);
|
||||
timer.unref();
|
||||
this.settlementTimers.set(escrowRef, timer);
|
||||
void check();
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutputReference(value: string): OutputReference | null {
|
||||
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
|
||||
if (!match) return null;
|
||||
return { txHash: match[1], index: Number(match[2]) };
|
||||
}
|
||||
176
supplier/src/reseller/setupConfig.ts
Normal file
176
supplier/src/reseller/setupConfig.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export interface ResellerSetupConfig {
|
||||
networkId: 1;
|
||||
ogmiosUrl: string;
|
||||
endpointUrl: string;
|
||||
marketplaceModel: string;
|
||||
openRouterModel: string;
|
||||
maxInputTokens: number;
|
||||
maxOutputTokens: number;
|
||||
maxProcessingMs: number;
|
||||
inferenceTimeoutMs: number;
|
||||
priceLovelace: bigint;
|
||||
supplierBondLovelace: bigint;
|
||||
buyerBondLovelace: bigint;
|
||||
reserveUsd: string;
|
||||
pollIntervalMs: number;
|
||||
providerTimeoutMs: number;
|
||||
previewMaxChars: number;
|
||||
minimumWalletLovelace: bigint;
|
||||
fundingTimeoutMs: number;
|
||||
composeFile: string;
|
||||
}
|
||||
|
||||
export async function loadResellerSetupConfig(
|
||||
path: string,
|
||||
): Promise<ResellerSetupConfig> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`unable to read reseller config ${path}: ${detail}`);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("reseller config must be a JSON object");
|
||||
}
|
||||
const value = parsed as Record<string, unknown>;
|
||||
const networkId = integerField(value, "networkId");
|
||||
if (networkId !== 1) throw new Error("reseller config networkId must be 1");
|
||||
|
||||
const endpoint = new URL(urlField(value, "endpointUrl", ["https:"]));
|
||||
if (
|
||||
endpoint.username !== "" ||
|
||||
endpoint.password !== "" ||
|
||||
endpoint.search !== "" ||
|
||||
endpoint.hash !== "" ||
|
||||
endpoint.pathname.replace(/\/+$/, "") !== ""
|
||||
) {
|
||||
throw new Error(
|
||||
"reseller config endpointUrl must be a plain HTTPS origin without credentials, path, query, or fragment",
|
||||
);
|
||||
}
|
||||
const endpointUrl = endpoint.origin;
|
||||
|
||||
const previewMaxChars = optionalInteger(value, "previewMaxChars", 160);
|
||||
if (previewMaxChars > 1000) {
|
||||
throw new Error("reseller config previewMaxChars must not exceed 1000");
|
||||
}
|
||||
const providerTimeoutMs = optionalInteger(value, "providerTimeoutMs", 10_000);
|
||||
const maxProcessingMs = integerField(value, "maxProcessingMs");
|
||||
const settlementMarginMs = 150_000 + providerTimeoutMs;
|
||||
const defaultInferenceTimeoutMs = maxProcessingMs - settlementMarginMs;
|
||||
if (defaultInferenceTimeoutMs <= 0) {
|
||||
throw new Error(
|
||||
"reseller config maxProcessingMs does not leave time for provider checks and chain settlement",
|
||||
);
|
||||
}
|
||||
const inferenceTimeoutMs = value.inferenceTimeoutMs === undefined
|
||||
? defaultInferenceTimeoutMs
|
||||
: integerField(value, "inferenceTimeoutMs");
|
||||
if (inferenceTimeoutMs + settlementMarginMs > maxProcessingMs) {
|
||||
throw new Error(
|
||||
"reseller config inferenceTimeoutMs does not leave time for provider checks and chain settlement",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
networkId: 1,
|
||||
ogmiosUrl: urlField(value, "ogmiosUrl", ["http:", "https:"]),
|
||||
endpointUrl,
|
||||
marketplaceModel: stringField(value, "marketplaceModel"),
|
||||
openRouterModel: stringField(value, "openRouterModel"),
|
||||
maxInputTokens: integerField(value, "maxInputTokens"),
|
||||
maxOutputTokens: integerField(value, "maxOutputTokens"),
|
||||
maxProcessingMs,
|
||||
inferenceTimeoutMs,
|
||||
priceLovelace: bigintField(value, "priceLovelace"),
|
||||
supplierBondLovelace: bigintField(value, "supplierBondLovelace"),
|
||||
buyerBondLovelace: bigintField(value, "buyerBondLovelace"),
|
||||
reserveUsd: usdField(value, "reserveUsd"),
|
||||
pollIntervalMs: optionalInteger(value, "pollIntervalMs", 30_000),
|
||||
providerTimeoutMs,
|
||||
previewMaxChars,
|
||||
minimumWalletLovelace: optionalBigint(value, "minimumWalletLovelace", 20_000_000n),
|
||||
fundingTimeoutMs: optionalInteger(value, "fundingTimeoutMs", 3_600_000),
|
||||
composeFile: stringField(value, "composeFile"),
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, name: string): string {
|
||||
const field = value[name];
|
||||
if (typeof field !== "string" || field.trim() === "") {
|
||||
throw new Error(`reseller config ${name} must be a non-empty string`);
|
||||
}
|
||||
if (/[\r\n]/.test(field)) {
|
||||
throw new Error(`reseller config ${name} must be one line`);
|
||||
}
|
||||
return field.trim();
|
||||
}
|
||||
|
||||
function urlField(
|
||||
value: Record<string, unknown>,
|
||||
name: string,
|
||||
protocols: string[],
|
||||
): string {
|
||||
const raw = stringField(value, name);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`reseller config ${name} must be a valid URL`);
|
||||
}
|
||||
if (!protocols.includes(url.protocol)) {
|
||||
throw new Error(
|
||||
`reseller config ${name} must use ${protocols.join(" or ")}`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function integerField(value: Record<string, unknown>, name: string): number {
|
||||
const field = value[name];
|
||||
if (!Number.isSafeInteger(field) || (field as number) <= 0) {
|
||||
throw new Error(`reseller config ${name} must be a positive safe integer`);
|
||||
}
|
||||
return field as number;
|
||||
}
|
||||
|
||||
function optionalInteger(
|
||||
value: Record<string, unknown>,
|
||||
name: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
return value[name] === undefined ? fallback : integerField(value, name);
|
||||
}
|
||||
|
||||
function bigintField(value: Record<string, unknown>, name: string): bigint {
|
||||
const field = value[name];
|
||||
if (typeof field !== "string" || !/^[1-9]\d*$/.test(field)) {
|
||||
throw new Error(`reseller config ${name} must be a positive integer string`);
|
||||
}
|
||||
return BigInt(field);
|
||||
}
|
||||
|
||||
function optionalBigint(
|
||||
value: Record<string, unknown>,
|
||||
name: string,
|
||||
fallback: bigint,
|
||||
): bigint {
|
||||
return value[name] === undefined ? fallback : bigintField(value, name);
|
||||
}
|
||||
|
||||
function usdField(value: Record<string, unknown>, name: string): string {
|
||||
const field = value[name];
|
||||
if (
|
||||
typeof field !== "string" ||
|
||||
!/^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/.test(field)
|
||||
) {
|
||||
throw new Error(
|
||||
`reseller config ${name} must be a non-negative decimal string`,
|
||||
);
|
||||
}
|
||||
return field;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
|
||||
import type { ChatMessage, WalletKey } from "@marketplace/shared/tx";
|
||||
import { chatInputTokenUpperBound } from "@marketplace/shared/tx";
|
||||
import {
|
||||
buildClaimTx,
|
||||
chatSessionPromptHash,
|
||||
@@ -62,6 +63,9 @@ import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js"
|
||||
import { callOpenAiStream } from "./openai.js";
|
||||
import { healthzRouter } from "./routes/healthz.js";
|
||||
import { triggerOnFailureConsolidate } from "./walletHealth.js";
|
||||
import type { ResellerRuntime } from "./reseller/runtime.js";
|
||||
import { formatUsdNanos } from "./reseller/money.js";
|
||||
import { renderResellerDashboardPage } from "./reseller/dashboardPage.js";
|
||||
|
||||
export interface SupplierDeps {
|
||||
chain: ChainProvider;
|
||||
@@ -72,6 +76,8 @@ export interface SupplierDeps {
|
||||
jobs?: JobStore;
|
||||
/** Optional: defaults to a fresh ChatSessionStore (chat-session capability). */
|
||||
chatSessions?: ChatSessionStore;
|
||||
/** Optional balance-aware resale runtime. Absent for every legacy supplier. */
|
||||
reseller?: ResellerRuntime;
|
||||
}
|
||||
|
||||
const ESCROW_REF_RE = /^[0-9a-fA-F]{64}#(?:0|[1-9]\d*)$/;
|
||||
@@ -102,6 +108,7 @@ interface ResolvedDeps {
|
||||
supplierKey: WalletKey;
|
||||
jobs: JobStore;
|
||||
chatSessions: ChatSessionStore;
|
||||
reseller?: ResellerRuntime;
|
||||
}
|
||||
|
||||
function makeCapabilityHandler(deps: ResolvedDeps) {
|
||||
@@ -138,6 +145,9 @@ function makeCapabilityHandler(deps: ResolvedDeps) {
|
||||
supplier_pkh: datum.supplier_pkh,
|
||||
// SPEC FIX 2026-04-25: pub_key_hex required for buyer-side receipt verification
|
||||
pub_key_hex: deps.supplierKey.pubKeyHex,
|
||||
...(deps.reseller
|
||||
? { max_input_tokens: deps.reseller.maxInputTokens }
|
||||
: {}),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -151,8 +161,9 @@ function makeCapabilityHandler(deps: ResolvedDeps) {
|
||||
function makeStatusHandler(deps: ResolvedDeps) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const snap = deps.state.snapshot();
|
||||
const effectiveStatus = deps.reseller?.effectiveStatus() ?? snap.status;
|
||||
const payload: Record<string, unknown> = {
|
||||
status: snap.status,
|
||||
status: effectiveStatus,
|
||||
last_seen: snap.lastSeenIso,
|
||||
active_sessions: snap.activeSessions,
|
||||
max_sessions: snap.maxSessions,
|
||||
@@ -160,6 +171,17 @@ function makeStatusHandler(deps: ResolvedDeps) {
|
||||
if (snap.status === "working" && snap.currentEscrowRef) {
|
||||
payload.current_escrow_ref = snap.currentEscrowRef;
|
||||
}
|
||||
if (deps.reseller) {
|
||||
const capacity = deps.reseller.capacitySnapshot();
|
||||
payload.capacity = {
|
||||
provider: capacity.provider,
|
||||
reason: effectiveStatus === "working" ? "working" : capacity.reason,
|
||||
sellable_usd: formatUsdNanos(capacity.sellableUsdNanos),
|
||||
checked_at: capacity.checkedAtMs === null
|
||||
? null
|
||||
: new Date(capacity.checkedAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
return res.status(200).json(payload);
|
||||
};
|
||||
}
|
||||
@@ -202,6 +224,8 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
// Slot held by THIS request (null until acquired / after hand-off to the
|
||||
// job runner) — the catch-all must only release its own acquisition.
|
||||
let acquiredRef: string | null = null;
|
||||
let evidenceRecorded = false;
|
||||
let evidenceEscrowRef: string | null = null;
|
||||
try {
|
||||
// ── 1. Header validation ──────────────────────────────────────────
|
||||
const headerVal = req.header("X-Escrow-Ref");
|
||||
@@ -214,6 +238,7 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
'X-Escrow-Ref must match "<64-hex>#<int>"');
|
||||
}
|
||||
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
|
||||
evidenceEscrowRef = escrowRefStr;
|
||||
|
||||
// ── 2. Body shape validation ─────────────────────────────────────
|
||||
const body = (req.body ?? {}) as ChatBody;
|
||||
@@ -240,6 +265,18 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
}
|
||||
}
|
||||
const validatedMessages = messages as Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
if (deps.reseller) {
|
||||
const inputUnits = chatInputTokenUpperBound(validatedMessages);
|
||||
if (inputUnits > deps.reseller.maxInputTokens) {
|
||||
return jsonError(
|
||||
res,
|
||||
413,
|
||||
"input_cap_exceeded",
|
||||
`input bound ${inputUnits} exceeds supplier cap ${deps.reseller.maxInputTokens}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 3. Resolve advert ────────────────────────────────────────────
|
||||
const advertResult = await fetchActiveAdvert(deps);
|
||||
@@ -314,6 +351,28 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
return jsonError(res, 408, "past_deliver_by",
|
||||
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
|
||||
}
|
||||
if (deps.reseller) {
|
||||
let capacity;
|
||||
try {
|
||||
capacity = await deps.reseller.refreshCapacity();
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"capacity_unavailable",
|
||||
"provider capacity or evidence database is unavailable",
|
||||
);
|
||||
}
|
||||
if (!capacity.canServe) {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"capacity_unavailable",
|
||||
`provider capacity is unavailable (${capacity.reason})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 8. Acquire session slot ──────────────────────────────────────
|
||||
if (!deps.state.tryAcquire(escrowRefStr)) {
|
||||
@@ -322,6 +381,26 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
acquiredRef = escrowRefStr;
|
||||
// From here on, every error path MUST release the slot until the
|
||||
// runner takes ownership of it.
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordReceived(
|
||||
escrowRefStr,
|
||||
validatedMessages,
|
||||
req.header("X-Vector-Public-Preview") === "1",
|
||||
);
|
||||
evidenceRecorded = true;
|
||||
} catch {
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"evidence_unavailable",
|
||||
"reseller evidence database is unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 9+10. Claim tx: build + confirm as ONE wallet critical section ─
|
||||
// (splitting them would let another wallet spend coin-select mid-flight)
|
||||
@@ -343,7 +422,12 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
}
|
||||
return { kind: "ok" as const, built };
|
||||
});
|
||||
const failEvidence = async (reason: string): Promise<void> => {
|
||||
if (!evidenceRecorded || !deps.reseller) return;
|
||||
await deps.reseller.recordFailed(escrowRefStr, reason).catch(() => undefined);
|
||||
};
|
||||
if (claimOutcome.kind === "build_failed") {
|
||||
await failEvidence("claim_submit_failed");
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
// Backstop the periodic wallet-health ticker: most Claim build failures
|
||||
@@ -359,6 +443,7 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
|
||||
}
|
||||
if (claimOutcome.kind === "await_failed") {
|
||||
await failEvidence("claim_confirmation_timeout");
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
return jsonError(res, 504, "claim_timeout",
|
||||
@@ -372,6 +457,32 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
index: 0,
|
||||
};
|
||||
const jobId = deps.jobs.create(escrowRefStr);
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordClaimed(
|
||||
escrowRefStr,
|
||||
claimResult.expectedTxHash,
|
||||
jobId,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
deps.state.release(escrowRefStr);
|
||||
acquiredRef = null;
|
||||
await failEvidence("claim_record_failed");
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"evidence_unavailable",
|
||||
"Claim confirmed but evidence persistence failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Slot release now happens inside runChatJob's finally.
|
||||
acquiredRef = null;
|
||||
@@ -382,6 +493,7 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
config: deps.config,
|
||||
supplierKey: deps.supplierKey,
|
||||
jobs: deps.jobs,
|
||||
reseller: deps.reseller,
|
||||
},
|
||||
jobId,
|
||||
escrowRef: escrowRefStr,
|
||||
@@ -401,6 +513,11 @@ function makeChatHandler(deps: ResolvedDeps) {
|
||||
if (acquiredRef) {
|
||||
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
|
||||
}
|
||||
if (evidenceRecorded && deps.reseller && evidenceEscrowRef) {
|
||||
await deps.reseller
|
||||
.recordFailed(evidenceEscrowRef, err instanceof Error ? err.message : String(err))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
@@ -1272,17 +1389,23 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
|
||||
};
|
||||
|
||||
try {
|
||||
// Stateful upstreams (OpenClaw) key a persistent agent session off the
|
||||
// OpenAI `user` field and carry the history themselves — send only this
|
||||
// turn's delta or the transcript duplicates into the upstream context
|
||||
// every turn. Stateless upstreams get the full transcript as before.
|
||||
const stateful = deps.config.openaiSessionPassthrough;
|
||||
const result = await callOpenAiStream(
|
||||
{
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: record.advert.model,
|
||||
messages: record.transcript,
|
||||
model: deps.config.openaiModelOverride || record.advert.model,
|
||||
messages: stateful ? delta : record.transcript,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
maxTokens: deps.config.openaiMaxTokens,
|
||||
disableReasoning: deps.config.openaiReasoningDisabled,
|
||||
tools,
|
||||
toolChoice,
|
||||
user: stateful ? escrowRefStr : undefined,
|
||||
},
|
||||
(tok) => sse({ type: "token", value: tok }),
|
||||
);
|
||||
@@ -1377,6 +1500,7 @@ export function createApp(deps: SupplierDeps): Application {
|
||||
supplierKey: deps.supplierKey,
|
||||
jobs: deps.jobs ?? new JobStore(),
|
||||
chatSessions: deps.chatSessions ?? new ChatSessionStore(),
|
||||
reseller: deps.reseller,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
@@ -1390,6 +1514,41 @@ export function createApp(deps: SupplierDeps): Application {
|
||||
// /healthz is mounted FIRST and uses no deps — it must succeed regardless
|
||||
// of chain/state/config (independent of /status free/working/offline).
|
||||
app.use(healthzRouter());
|
||||
if (resolved.reseller) {
|
||||
app.get("/reseller", (_req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type("html").send(renderResellerDashboardPage());
|
||||
});
|
||||
app.get("/reseller/api/status", async (_req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
try {
|
||||
res.status(200).json(await resolved.reseller!.publicState());
|
||||
} catch {
|
||||
jsonError(
|
||||
res,
|
||||
503,
|
||||
"evidence_unavailable",
|
||||
"reseller evidence database is unavailable",
|
||||
);
|
||||
}
|
||||
});
|
||||
app.get("/reseller/api/jobs", async (req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const parsed = Number(req.query.limit ?? 50);
|
||||
const limit = Number.isSafeInteger(parsed) ? parsed : 50;
|
||||
try {
|
||||
res.status(200).json({ jobs: await resolved.reseller!.publicJobs(limit) });
|
||||
} catch {
|
||||
jsonError(
|
||||
res,
|
||||
503,
|
||||
"evidence_unavailable",
|
||||
"reseller evidence database is unavailable",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
app.get("/capability", makeCapabilityHandler(resolved));
|
||||
app.get("/status", makeStatusHandler(resolved));
|
||||
|
||||
789
supplier/src/surplus/client.ts
Normal file
789
supplier/src/surplus/client.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
export interface SurplusOffer {
|
||||
id: string;
|
||||
model: string;
|
||||
sellerBaseUrl: string;
|
||||
status: "active" | "inactive";
|
||||
capDailyUsd: number | null;
|
||||
costMultiplierPpm: number | null;
|
||||
inputMicroUsdPer1m: number | null;
|
||||
outputMicroUsdPer1m: number | null;
|
||||
}
|
||||
|
||||
export interface SurplusDiscoveredModel {
|
||||
model: string;
|
||||
supported: boolean;
|
||||
inputUsdPer1m: number;
|
||||
outputUsdPer1m: number;
|
||||
priceUnit: string;
|
||||
priceVariable: boolean;
|
||||
providerModelId: string;
|
||||
modelType: string;
|
||||
availabilityStatus: string;
|
||||
}
|
||||
|
||||
export interface SurplusMarketSummary {
|
||||
model: string;
|
||||
requests24h: number;
|
||||
volume24h: number;
|
||||
bestInputMicroUsdPer1m: number;
|
||||
bestOutputMicroUsdPer1m: number;
|
||||
healthySellerCount: number;
|
||||
}
|
||||
|
||||
export interface SurplusOrderBookOffer {
|
||||
id: string;
|
||||
seller: string;
|
||||
sellerBaseUrl: string;
|
||||
inputMicroUsdPer1m: number;
|
||||
outputMicroUsdPer1m: number;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
rank: number;
|
||||
capRemainingMicroUsd: number | null;
|
||||
volume24hMicroUsd: number;
|
||||
}
|
||||
|
||||
export interface SurplusOrderBook {
|
||||
model: string;
|
||||
offers: SurplusOrderBookOffer[];
|
||||
}
|
||||
|
||||
export interface SurplusOfferWrite {
|
||||
model: string;
|
||||
apiKey: string;
|
||||
sellerBaseUrl: string;
|
||||
costMultiplier: number;
|
||||
dailyCapUsd: number;
|
||||
payoutAddress: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface SurplusOfferPatch {
|
||||
costMultiplier: number;
|
||||
dailyCapUsd: number;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface SurplusSale {
|
||||
id: string;
|
||||
model: string;
|
||||
offerId: string;
|
||||
settlementStatus: string;
|
||||
createdAt: string;
|
||||
createdAtMs: number;
|
||||
sellerCostMicroUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
effectiveInputUsdPer1m: number;
|
||||
effectiveOutputUsdPer1m: number;
|
||||
transactionHash: string | null;
|
||||
}
|
||||
|
||||
export interface SurplusDailyEarnings {
|
||||
day: string;
|
||||
earnedUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface SurplusModelEarnings {
|
||||
model: string;
|
||||
earnedUsd: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
lastSaleAt: string | null;
|
||||
}
|
||||
|
||||
export interface SurplusEarnings {
|
||||
totalEarnedMicroUsd: number;
|
||||
pendingMicroUsd: number;
|
||||
paidMicroUsd: number;
|
||||
daily: SurplusDailyEarnings[];
|
||||
byModel: SurplusModelEarnings[];
|
||||
requestCount: number;
|
||||
tokenCount: number;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
recentSales: SurplusSale[];
|
||||
}
|
||||
|
||||
interface SurplusClientOptions {
|
||||
apiBaseUrl: string;
|
||||
sellerApiKey: string;
|
||||
timeoutMs: number;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export class SurplusHttpError extends Error {
|
||||
readonly status: number | null;
|
||||
readonly retryAfter: string | null;
|
||||
|
||||
constructor(message: string, status: number | null, retryAfter: string | null = null) {
|
||||
super(message);
|
||||
this.name = "SurplusHttpError";
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
|
||||
get ambiguousMutation(): boolean {
|
||||
return this.status === null || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
export class SurplusHistoricalCompletenessError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Surplus does not provide historical sale pagination; only the observed recent-sales window is available",
|
||||
);
|
||||
this.name = "SurplusHistoricalCompletenessError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SurplusClient {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly sellerApiKey: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchFn: typeof globalThis.fetch;
|
||||
|
||||
constructor(options: SurplusClientOptions) {
|
||||
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, "");
|
||||
this.sellerApiKey = options.sellerApiKey;
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.fetchFn = options.fetchFn ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
async listAllOffers(): Promise<SurplusOffer[]> {
|
||||
const items: SurplusOffer[] = [];
|
||||
const seenTokens = new Set<string>();
|
||||
let nextToken: string | null = null;
|
||||
for (;;) {
|
||||
const params = new URLSearchParams();
|
||||
if (nextToken !== null) params.set("next_token", nextToken);
|
||||
const query = params.toString();
|
||||
const body = await this.requestJson(`/v1/seller/offers${query ? `?${query}` : ""}`);
|
||||
const root = record(body, "Surplus offers response");
|
||||
if (!Array.isArray(root.items)) {
|
||||
throw new Error("Surplus offers response.items must be an array");
|
||||
}
|
||||
items.push(...root.items.map((item, index) => parseOffer(item, items.length + index)));
|
||||
const rawNext = root.next_token;
|
||||
if (rawNext === undefined || rawNext === null || rawNext === "") break;
|
||||
if (typeof rawNext !== "string") {
|
||||
throw new Error("Surplus offers response.next_token must be a string or null");
|
||||
}
|
||||
if (seenTokens.has(rawNext)) {
|
||||
throw new Error("Surplus offers pagination repeated a next_token");
|
||||
}
|
||||
seenTokens.add(rawNext);
|
||||
nextToken = rawNext;
|
||||
if (seenTokens.size > 10_000) {
|
||||
throw new Error("Surplus offers pagination exceeded 10000 pages");
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async discoverModels(apiKey: string, baseUrl: string): Promise<SurplusDiscoveredModel[]> {
|
||||
const response = await this.requestText("/v1/seller/discover", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ api_key: apiKey, base_url: baseUrl }),
|
||||
}, true, [apiKey]);
|
||||
if (response.text.trim() === "") {
|
||||
throw new Error("Surplus discovery returned an empty body");
|
||||
}
|
||||
return response.text
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() !== "")
|
||||
.map((line, index) => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`Surplus discovery line ${index + 1} was not complete JSON`);
|
||||
}
|
||||
return parseDiscoveredModel(value, index);
|
||||
});
|
||||
}
|
||||
|
||||
async getMarkets(): Promise<SurplusMarketSummary[]> {
|
||||
const body = await this.requestJson("/api/markets", {}, false);
|
||||
const root = record(body, "Surplus markets response");
|
||||
if (!Array.isArray(root.markets)) {
|
||||
throw new Error("Surplus markets response.markets must be an array");
|
||||
}
|
||||
return root.markets.map((item, index) => parseMarketSummary(item, index));
|
||||
}
|
||||
|
||||
async getOrderBook(model: string): Promise<SurplusOrderBook> {
|
||||
const body = await this.requestJson(
|
||||
`/api/markets/${encodeURIComponent(model)}`,
|
||||
{},
|
||||
false,
|
||||
);
|
||||
const root = record(body, "Surplus order book response");
|
||||
const returnedModel = string(root.model, "Surplus order book model");
|
||||
if (returnedModel !== model) {
|
||||
throw new Error(`Surplus order book returned model ${returnedModel} for ${model}`);
|
||||
}
|
||||
if (!Array.isArray(root.offers)) {
|
||||
throw new Error("Surplus order book response.offers must be an array");
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const offers = root.offers.map((item, index) => {
|
||||
const offer = parseOrderBookOffer(item, index);
|
||||
if (ids.has(offer.id)) throw new Error(`Surplus order book repeated offer ${offer.id}`);
|
||||
ids.add(offer.id);
|
||||
return offer;
|
||||
});
|
||||
return { model, offers };
|
||||
}
|
||||
|
||||
async testConnection(apiKey: string, baseUrl: string, model: string): Promise<number> {
|
||||
const body = await this.requestJson("/v1/seller/test-connection", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ api_key: apiKey, base_url: baseUrl, model }),
|
||||
}, true, [apiKey]);
|
||||
const root = record(body, "Surplus connection response");
|
||||
if (root.ok !== true) throw new Error("Surplus provider connection test failed");
|
||||
return nonNegativeNumber(root.latency_ms, "Surplus connection latency_ms");
|
||||
}
|
||||
|
||||
async createOffer(input: SurplusOfferWrite): Promise<string> {
|
||||
const body = await this.requestJson("/v1/seller/offers", {
|
||||
method: "POST",
|
||||
headers: mutationHeaders(input.idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
api_key: input.apiKey,
|
||||
seller_base_url: input.sellerBaseUrl,
|
||||
pricing_mode: "cost_multiplier",
|
||||
cost_multiplier: input.costMultiplier,
|
||||
cap_daily_usd: input.dailyCapUsd,
|
||||
payout_address: input.payoutAddress,
|
||||
}),
|
||||
}, true, [input.apiKey]);
|
||||
const root = record(body, "Surplus create offer response");
|
||||
return offerId(root, "Surplus create offer response");
|
||||
}
|
||||
|
||||
async updateOffer(id: string, patch: SurplusOfferPatch): Promise<void> {
|
||||
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: mutationHeaders(patch.idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
pricing_mode: "cost_multiplier",
|
||||
cost_multiplier: patch.costMultiplier,
|
||||
cap_daily_usd: patch.dailyCapUsd,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async pauseOffer(id: string, idempotencyKey: string): Promise<void> {
|
||||
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: mutationHeaders(idempotencyKey),
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
});
|
||||
}
|
||||
|
||||
async resumeOffer(id: string, idempotencyKey: string): Promise<void> {
|
||||
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: mutationHeaders(idempotencyKey),
|
||||
body: JSON.stringify({ status: "active" }),
|
||||
});
|
||||
}
|
||||
|
||||
async getEarnings(): Promise<SurplusEarnings> {
|
||||
const body = await this.requestJson("/v1/seller/earnings?range=lifetime");
|
||||
const root = record(body, "Surplus earnings response");
|
||||
if (!Array.isArray(root.recent_sales)) {
|
||||
throw new Error("Surplus earnings response.recent_sales must be an array");
|
||||
}
|
||||
if (!Array.isArray(root.daily)) {
|
||||
throw new Error("Surplus earnings response.daily must be an array");
|
||||
}
|
||||
if (!Array.isArray(root.by_model)) {
|
||||
throw new Error("Surplus earnings response.by_model must be an array");
|
||||
}
|
||||
const share = record(root.share, "Surplus earnings response.share");
|
||||
const payoutHold = optionalRecord(root.payout_hold);
|
||||
return {
|
||||
totalEarnedMicroUsd: microUsd(root.total_earned_usdc, "total_earned_usdc"),
|
||||
pendingMicroUsd: microUsd(root.pending_usdc, "pending_usdc"),
|
||||
paidMicroUsd: microUsd(root.paid_usdc, "paid_usdc"),
|
||||
daily: root.daily.map((day, index) => parseDailyEarnings(day, index)),
|
||||
byModel: root.by_model.map((model, index) =>
|
||||
parseModelEarnings(model, index)
|
||||
),
|
||||
requestCount: nonNegativeSafeInteger(
|
||||
share.requests,
|
||||
"Surplus earnings share.requests",
|
||||
),
|
||||
tokenCount: nonNegativeSafeInteger(
|
||||
share.tokens,
|
||||
"Surplus earnings share.tokens",
|
||||
),
|
||||
topModel: optionalString(share.top_model),
|
||||
payoutHoldReason: payoutHold
|
||||
? optionalString(payoutHold.reason)
|
||||
: null,
|
||||
payoutHoldReleasesAt: payoutHold
|
||||
? optionalTimestamp(payoutHold.releases_at, "payout_hold.releases_at")
|
||||
: null,
|
||||
recentSales: root.recent_sales.map((sale, index) =>
|
||||
parseSale(sale, index)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async listObservedSales(): Promise<SurplusSale[]> {
|
||||
const salesById = new Map<string, SurplusSale>();
|
||||
for (const sale of (await this.getEarnings()).recentSales) {
|
||||
const existing = salesById.get(sale.id);
|
||||
if (existing === undefined) {
|
||||
salesById.set(sale.id, sale);
|
||||
} else if (!sameImmutableSale(existing, sale)) {
|
||||
throw new Error(
|
||||
`Surplus recent-sales window contains conflicting immutable fields for sale ${sale.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return [...salesById.values()].sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
async listAllSales(): Promise<SurplusSale[]> {
|
||||
throw new SurplusHistoricalCompletenessError();
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
authenticated = true,
|
||||
additionalSecrets: readonly string[] = [],
|
||||
): Promise<unknown> {
|
||||
const response = await this.requestText(
|
||||
path,
|
||||
init,
|
||||
authenticated,
|
||||
additionalSecrets,
|
||||
);
|
||||
if (response.text === "") return {};
|
||||
try {
|
||||
return JSON.parse(response.text);
|
||||
} catch {
|
||||
throw new Error(`Surplus ${path} response was not JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestText(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
authenticated = true,
|
||||
additionalSecrets: readonly string[] = [],
|
||||
): Promise<{ status: number; text: string }> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
const headers = new Headers(init.headers);
|
||||
if (authenticated) headers.set("authorization", `Bearer ${this.sellerApiKey}`);
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.apiBaseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await readBoundedText(response, controller);
|
||||
if (!response.ok) {
|
||||
throw new SurplusHttpError(
|
||||
`Surplus ${path} returned HTTP ${response.status}${
|
||||
text
|
||||
? `: ${sanitizedErrorSnippet(
|
||||
text,
|
||||
[this.sellerApiKey, ...additionalSecrets],
|
||||
)}`
|
||||
: ""
|
||||
}`,
|
||||
response.status,
|
||||
response.headers.get("retry-after"),
|
||||
);
|
||||
}
|
||||
return { status: response.status, text };
|
||||
} catch (error) {
|
||||
if (error instanceof SurplusHttpError) throw error;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new SurplusHttpError(`Surplus ${path} request failed: ${detail}`, null);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedText(
|
||||
response: Response,
|
||||
controller: AbortController,
|
||||
): Promise<string> {
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_RESPONSE_BYTES) {
|
||||
controller.abort();
|
||||
throw new Error(`response exceeded ${MAX_RESPONSE_BYTES} bytes`);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function mutationHeaders(idempotencyKey: string): Headers {
|
||||
const headers = new Headers({ "content-type": "application/json" });
|
||||
headers.set("idempotency-key", idempotencyKey);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseOffer(value: unknown, index: number): SurplusOffer {
|
||||
const item = record(value, `Surplus offer ${index}`);
|
||||
const statusRaw = item.status;
|
||||
const status = statusRaw === "active" || item.active === true
|
||||
? "active"
|
||||
: statusRaw === "inactive" || statusRaw === "paused" || item.active === false
|
||||
? "inactive"
|
||||
: (() => { throw new Error(`Surplus offer ${index}.status was invalid`); })();
|
||||
return {
|
||||
id: offerId(item, `Surplus offer ${index}`),
|
||||
model: string(item.model, `Surplus offer ${index}.model`),
|
||||
sellerBaseUrl: string(
|
||||
item.seller_base_url ?? item.base_url,
|
||||
`Surplus offer ${index}.base_url`,
|
||||
),
|
||||
status,
|
||||
capDailyUsd: optionalNonNegativeNumber(item.cap_daily_usd, `Surplus offer ${index}.cap_daily_usd`),
|
||||
costMultiplierPpm: optionalMultiplierPpm(
|
||||
item.cost_multiplier,
|
||||
`Surplus offer ${index}.cost_multiplier`,
|
||||
),
|
||||
inputMicroUsdPer1m: optionalMicroUsd(item.price_input_per_1m, `Surplus offer ${index}.price_input_per_1m`),
|
||||
outputMicroUsdPer1m: optionalMicroUsd(item.price_output_per_1m, `Surplus offer ${index}.price_output_per_1m`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscoveredModel(value: unknown, index: number): SurplusDiscoveredModel {
|
||||
const item = record(value, `Surplus discovery item ${index}`);
|
||||
const model = string(item.model, `Surplus discovery item ${index}.model`);
|
||||
const supported = boolean(
|
||||
item.supported,
|
||||
`Surplus discovery item ${index}.supported`,
|
||||
);
|
||||
const pricing = optionalRecord(item.pricing);
|
||||
const metadata = optionalRecord(item.metadata) ?? {};
|
||||
return {
|
||||
model,
|
||||
supported,
|
||||
inputUsdPer1m: pricing
|
||||
? optionalNonNegativeNumber(
|
||||
pricing.input_per_1m,
|
||||
`Surplus discovery item ${index}.pricing.input_per_1m`,
|
||||
) ?? 0
|
||||
: 0,
|
||||
outputUsdPer1m: pricing
|
||||
? optionalNonNegativeNumber(
|
||||
pricing.output_per_1m,
|
||||
`Surplus discovery item ${index}.pricing.output_per_1m`,
|
||||
) ?? 0
|
||||
: 0,
|
||||
priceUnit: pricing
|
||||
? optionalString(pricing.price_unit) ?? "M"
|
||||
: "unknown",
|
||||
priceVariable: pricing?.price_variable === undefined
|
||||
? false
|
||||
: boolean(
|
||||
pricing.price_variable,
|
||||
`Surplus discovery item ${index}.pricing.price_variable`,
|
||||
),
|
||||
providerModelId: optionalString(metadata.provider_model_id) ?? "",
|
||||
modelType: optionalString(metadata.model_type) ?? "unknown",
|
||||
availabilityStatus:
|
||||
optionalString(metadata.availability_status) ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function parseMarketSummary(value: unknown, index: number): SurplusMarketSummary {
|
||||
const item = record(value, `Surplus market ${index}`);
|
||||
return {
|
||||
model: string(item.model, `Surplus market ${index}.model`),
|
||||
requests24h: nonNegativeSafeInteger(item.requests_24h, `Surplus market ${index}.requests_24h`),
|
||||
volume24h: nonNegativeSafeInteger(item.volume_24h, `Surplus market ${index}.volume_24h`),
|
||||
bestInputMicroUsdPer1m: nonNegativeSafeInteger(item.best_input_per_1m, `Surplus market ${index}.best_input_per_1m`),
|
||||
bestOutputMicroUsdPer1m: nonNegativeSafeInteger(item.best_output_per_1m, `Surplus market ${index}.best_output_per_1m`),
|
||||
healthySellerCount: nonNegativeSafeInteger(item.healthy_seller_count, `Surplus market ${index}.healthy_seller_count`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOrderBookOffer(value: unknown, index: number): SurplusOrderBookOffer {
|
||||
const item = record(value, `Surplus order book offer ${index}`);
|
||||
return {
|
||||
id: offerId(item, `Surplus order book offer ${index}`),
|
||||
seller: string(item.seller, `Surplus order book offer ${index}.seller`).toLowerCase(),
|
||||
sellerBaseUrl: string(item.seller_base_url, `Surplus order book offer ${index}.seller_base_url`),
|
||||
inputMicroUsdPer1m: positiveSafeInteger(item.effective_input_per_1m, `Surplus order book offer ${index}.effective_input_per_1m`),
|
||||
outputMicroUsdPer1m: positiveSafeInteger(item.effective_output_per_1m, `Surplus order book offer ${index}.effective_output_per_1m`),
|
||||
available: boolean(item.available, `Surplus order book offer ${index}.available`),
|
||||
healthy: boolean(item.healthy, `Surplus order book offer ${index}.healthy`),
|
||||
trusted: boolean(item.trusted, `Surplus order book offer ${index}.trusted`),
|
||||
trades24h: nonNegativeSafeInteger(item.trades_24h, `Surplus order book offer ${index}.trades_24h`),
|
||||
rank: positiveSafeInteger(item.rank, `Surplus order book offer ${index}.rank`),
|
||||
capRemainingMicroUsd: optionalMicroUsd(
|
||||
item.cap_remaining,
|
||||
`Surplus order book offer ${index}.cap_remaining`,
|
||||
),
|
||||
volume24hMicroUsd: microUsd(
|
||||
item.volume_24h,
|
||||
`Surplus order book offer ${index}.volume_24h`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSale(value: unknown, index: number): SurplusSale {
|
||||
const item = record(value, `Surplus sale ${index}`);
|
||||
const createdAtMs = timestampMs(
|
||||
item.created_at,
|
||||
`Surplus sale ${index}.created_at`,
|
||||
);
|
||||
return {
|
||||
id: string(item.id, `Surplus sale ${index}.id`),
|
||||
model: string(item.model, `Surplus sale ${index}.model`),
|
||||
offerId: string(item.offer_id, `Surplus sale ${index}.offer_id`),
|
||||
settlementStatus: string(item.settlement_status, `Surplus sale ${index}.settlement_status`),
|
||||
createdAt: new Date(createdAtMs).toISOString(),
|
||||
createdAtMs,
|
||||
sellerCostMicroUsd: microUsd(item.seller_cost_usdc, `Surplus sale ${index}.seller_cost_usdc`),
|
||||
inputTokens: nonNegativeSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
|
||||
outputTokens: nonNegativeSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
|
||||
cacheReadTokens: nonNegativeSafeInteger(
|
||||
item.cache_read_tokens ?? 0,
|
||||
`Surplus sale ${index}.cache_read_tokens`,
|
||||
),
|
||||
effectiveInputUsdPer1m: nonNegativeNumber(
|
||||
item.effective_input_per_1m,
|
||||
`Surplus sale ${index}.effective_input_per_1m`,
|
||||
),
|
||||
effectiveOutputUsdPer1m: nonNegativeNumber(
|
||||
item.effective_output_per_1m,
|
||||
`Surplus sale ${index}.effective_output_per_1m`,
|
||||
),
|
||||
transactionHash: optionalString(item.tx_hash),
|
||||
};
|
||||
}
|
||||
|
||||
function sameImmutableSale(left: SurplusSale, right: SurplusSale): boolean {
|
||||
return left.id === right.id &&
|
||||
left.offerId === right.offerId &&
|
||||
left.model === right.model &&
|
||||
left.createdAtMs === right.createdAtMs &&
|
||||
left.inputTokens === right.inputTokens &&
|
||||
left.outputTokens === right.outputTokens &&
|
||||
left.cacheReadTokens === right.cacheReadTokens &&
|
||||
left.sellerCostMicroUsd === right.sellerCostMicroUsd &&
|
||||
left.effectiveInputUsdPer1m === right.effectiveInputUsdPer1m &&
|
||||
left.effectiveOutputUsdPer1m === right.effectiveOutputUsdPer1m;
|
||||
}
|
||||
|
||||
function parseDailyEarnings(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): SurplusDailyEarnings {
|
||||
const item = record(value, `Surplus daily earnings ${index}`);
|
||||
return {
|
||||
day: string(item.day, `Surplus daily earnings ${index}.day`),
|
||||
earnedUsd: nonNegativeNumber(
|
||||
item.earned_usd,
|
||||
`Surplus daily earnings ${index}.earned_usd`,
|
||||
),
|
||||
inputTokens: nonNegativeSafeInteger(
|
||||
item.input_tokens,
|
||||
`Surplus daily earnings ${index}.input_tokens`,
|
||||
),
|
||||
outputTokens: nonNegativeSafeInteger(
|
||||
item.output_tokens,
|
||||
`Surplus daily earnings ${index}.output_tokens`,
|
||||
),
|
||||
requests: nonNegativeSafeInteger(
|
||||
item.requests,
|
||||
`Surplus daily earnings ${index}.requests`,
|
||||
),
|
||||
totalTokens: nonNegativeSafeInteger(
|
||||
item.total_tokens,
|
||||
`Surplus daily earnings ${index}.total_tokens`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseModelEarnings(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): SurplusModelEarnings {
|
||||
const item = record(value, `Surplus model earnings ${index}`);
|
||||
return {
|
||||
model: string(item.model, `Surplus model earnings ${index}.model`),
|
||||
earnedUsd: nonNegativeNumber(
|
||||
item.earned_usd,
|
||||
`Surplus model earnings ${index}.earned_usd`,
|
||||
),
|
||||
requests: nonNegativeSafeInteger(
|
||||
item.requests,
|
||||
`Surplus model earnings ${index}.requests`,
|
||||
),
|
||||
totalTokens: nonNegativeSafeInteger(
|
||||
item.total_tokens,
|
||||
`Surplus model earnings ${index}.total_tokens`,
|
||||
),
|
||||
lastSaleAt: optionalTimestamp(
|
||||
item.last_sale_at,
|
||||
`Surplus model earnings ${index}.last_sale_at`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function offerId(item: Record<string, unknown>, field: string): string {
|
||||
const value = item.offer_id ?? item.id;
|
||||
return string(value, `${field}.id`);
|
||||
}
|
||||
|
||||
function record(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Optional Surplus object has the wrong type");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function string(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value !== "" ? value : null;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return new Date(timestampMs(value, field)).toISOString();
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown, field: string): number {
|
||||
const timestamp = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Date.parse(value)
|
||||
: Number.NaN;
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
||||
throw new Error(`${field} must be a timestamp with integer milliseconds`);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNonNegativeNumber(value: unknown, field: string): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return nonNegativeNumber(value, field);
|
||||
}
|
||||
|
||||
function optionalMultiplierPpm(value: unknown, field: string): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive finite number`);
|
||||
}
|
||||
const ppm = Math.round(value * 1_000_000);
|
||||
if (!Number.isSafeInteger(ppm) || ppm <= 0) {
|
||||
throw new Error(`${field} cannot be represented at six decimal places`);
|
||||
}
|
||||
return ppm;
|
||||
}
|
||||
|
||||
function sanitizedErrorSnippet(
|
||||
value: string,
|
||||
secrets: readonly string[],
|
||||
): string {
|
||||
let sanitized = value;
|
||||
for (const secret of secrets) {
|
||||
if (secret !== "") sanitized = sanitized.replaceAll(secret, "[redacted]");
|
||||
}
|
||||
return sanitized
|
||||
.replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeSafeInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalSafeInteger(value: unknown, field: string): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return nonNegativeSafeInteger(value, field);
|
||||
}
|
||||
|
||||
function microUsd(value: unknown, field: string): number {
|
||||
if (typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
return nonNegativeSafeInteger(value, field);
|
||||
}
|
||||
|
||||
function optionalMicroUsd(value: unknown, field: string): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return microUsd(value, field);
|
||||
}
|
||||
|
||||
297
supplier/src/surplus/config.ts
Normal file
297
supplier/src/surplus/config.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/;
|
||||
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
||||
const POSITIVE_INT_RE = /^[1-9]\d*$/;
|
||||
const NON_NEGATIVE_INT_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
export interface SurplusManagerConfig {
|
||||
live: boolean;
|
||||
apiBaseUrl: string;
|
||||
sellerApiKey: string;
|
||||
providerApiKey: string;
|
||||
providerBaseUrl: string;
|
||||
capacityBaseUrl: string;
|
||||
perOfferCapUsd: string;
|
||||
aggregateCapUsd: string;
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
maxCandidateOrderBooks: number;
|
||||
reserveUsd: string;
|
||||
maxProviderLimitUsd: string;
|
||||
recoveryBps: number;
|
||||
undercutBps: number;
|
||||
pollIntervalMs: number;
|
||||
requestTimeoutMs: number;
|
||||
stopAfterTrades: number;
|
||||
settledStatuses: string[];
|
||||
statePath: string;
|
||||
port: number;
|
||||
proof: SurplusProofConfig | null;
|
||||
}
|
||||
|
||||
export interface SurplusProofConfig {
|
||||
ogmiosUrl: string;
|
||||
walletPrivateKeyHex: string;
|
||||
ledgerPath: string;
|
||||
reserveLovelace: bigint;
|
||||
feeBudgetLovelace: bigint;
|
||||
intervalMs: number;
|
||||
confirmTimeoutMs: number;
|
||||
historyCsvPath: string | null;
|
||||
historyOfferId: string | null;
|
||||
}
|
||||
export function loadSurplusManagerConfig(
|
||||
env: Record<string, string | undefined>,
|
||||
): SurplusManagerConfig {
|
||||
const liveRaw = env.SURPLUS_LIVE;
|
||||
if (liveRaw !== undefined && liveRaw !== "" && liveRaw !== "1") {
|
||||
throw new Error("SURPLUS_LIVE must be literal 1 or unset");
|
||||
}
|
||||
|
||||
const apiBaseUrl = httpsUrl(
|
||||
env.SURPLUS_API_URL ?? "https://api.surplusintelligence.ai",
|
||||
"SURPLUS_API_URL",
|
||||
"api.surplusintelligence.ai",
|
||||
);
|
||||
const providerBaseUrl = httpsUrl(
|
||||
env.SURPLUS_PROVIDER_BASE_URL ?? "https://openrouter.ai/api/v1",
|
||||
"SURPLUS_PROVIDER_BASE_URL",
|
||||
"openrouter.ai",
|
||||
);
|
||||
const capacityBaseUrl = httpsUrl(
|
||||
env.OPENROUTER_CAPACITY_BASE_URL ?? "https://openrouter.ai/api",
|
||||
"OPENROUTER_CAPACITY_BASE_URL",
|
||||
"openrouter.ai",
|
||||
);
|
||||
|
||||
const sellerWallet = evmAddress(
|
||||
required(env, "SURPLUS_SELLER_WALLET"),
|
||||
"SURPLUS_SELLER_WALLET",
|
||||
);
|
||||
const payoutAddress = evmAddress(
|
||||
env.SURPLUS_PAYOUT_ADDRESS ?? sellerWallet,
|
||||
"SURPLUS_PAYOUT_ADDRESS",
|
||||
);
|
||||
|
||||
return {
|
||||
live: liveRaw === "1",
|
||||
apiBaseUrl,
|
||||
sellerApiKey: prefixedSecret(
|
||||
required(env, "SURPLUS_SELLER_API_KEY"),
|
||||
"SURPLUS_SELLER_API_KEY",
|
||||
"si_seller_",
|
||||
),
|
||||
providerApiKey: required(env, "SURPLUS_OPENROUTER_API_KEY"),
|
||||
providerBaseUrl,
|
||||
capacityBaseUrl,
|
||||
perOfferCapUsd: usd(
|
||||
env.SURPLUS_PER_OFFER_CAP_USD ?? "0.05",
|
||||
"SURPLUS_PER_OFFER_CAP_USD",
|
||||
),
|
||||
aggregateCapUsd: usd(
|
||||
env.SURPLUS_AGGREGATE_CAP_USD ?? "0.05",
|
||||
"SURPLUS_AGGREGATE_CAP_USD",
|
||||
),
|
||||
sellerWallet,
|
||||
payoutAddress,
|
||||
maxCandidateOrderBooks: boundedInteger(
|
||||
env.SURPLUS_MAX_CANDIDATE_ORDER_BOOKS ?? "10",
|
||||
"SURPLUS_MAX_CANDIDATE_ORDER_BOOKS",
|
||||
1,
|
||||
100,
|
||||
),
|
||||
reserveUsd: usd(
|
||||
env.SURPLUS_OPENROUTER_RESERVE_USD ?? "1.00",
|
||||
"SURPLUS_OPENROUTER_RESERVE_USD",
|
||||
),
|
||||
maxProviderLimitUsd: usd(
|
||||
env.SURPLUS_OPENROUTER_MAX_LIMIT_USD ?? "20.00",
|
||||
"SURPLUS_OPENROUTER_MAX_LIMIT_USD",
|
||||
),
|
||||
recoveryBps: boundedInteger(
|
||||
env.SURPLUS_MIN_RECOVERY_BPS ?? "490",
|
||||
"SURPLUS_MIN_RECOVERY_BPS",
|
||||
1,
|
||||
10_000,
|
||||
),
|
||||
undercutBps: boundedInteger(
|
||||
env.SURPLUS_UNDERCUT_BPS ?? "10",
|
||||
"SURPLUS_UNDERCUT_BPS",
|
||||
0,
|
||||
9_999,
|
||||
),
|
||||
pollIntervalMs: boundedInteger(
|
||||
env.SURPLUS_REPRICE_INTERVAL_MS ?? "300000",
|
||||
"SURPLUS_REPRICE_INTERVAL_MS",
|
||||
60_000,
|
||||
86_400_000,
|
||||
),
|
||||
requestTimeoutMs: boundedInteger(
|
||||
env.SURPLUS_REQUEST_TIMEOUT_MS ?? "120000",
|
||||
"SURPLUS_REQUEST_TIMEOUT_MS",
|
||||
1_000,
|
||||
300_000,
|
||||
),
|
||||
stopAfterTrades: boundedInteger(
|
||||
env.SURPLUS_STOP_AFTER_SETTLED_REQUESTS ?? "0",
|
||||
"SURPLUS_STOP_AFTER_SETTLED_REQUESTS",
|
||||
0,
|
||||
1,
|
||||
),
|
||||
settledStatuses: statusList(
|
||||
env.SURPLUS_SETTLED_STATUSES ?? "",
|
||||
"SURPLUS_SETTLED_STATUSES",
|
||||
),
|
||||
statePath: requiredWithDefault(
|
||||
env.SURPLUS_STATE_PATH,
|
||||
"/var/lib/surplus-manager/state.json",
|
||||
"SURPLUS_STATE_PATH",
|
||||
),
|
||||
port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535),
|
||||
proof: loadProofConfig(env),
|
||||
};
|
||||
}
|
||||
|
||||
function loadProofConfig(
|
||||
env: Record<string, string | undefined>,
|
||||
): SurplusProofConfig | null {
|
||||
const enabledRaw = env.SURPLUS_PROOF_ENABLED;
|
||||
if (enabledRaw === undefined || enabledRaw === "") return null;
|
||||
if (enabledRaw !== "1") {
|
||||
throw new Error("SURPLUS_PROOF_ENABLED must be literal 1 or unset");
|
||||
}
|
||||
|
||||
const ogmiosUrl = required(env, "OGMIOS_URL");
|
||||
const walletPrivateKeyHex = required(
|
||||
env,
|
||||
"SURPLUS_PROOF_WALLET_PRIV_KEY_HEX",
|
||||
);
|
||||
if (!HEX64_RE.test(walletPrivateKeyHex)) {
|
||||
throw new Error(
|
||||
"SURPLUS_PROOF_WALLET_PRIV_KEY_HEX must be 64 hexadecimal characters",
|
||||
);
|
||||
}
|
||||
|
||||
const historyCsvPath = env.SURPLUS_PROOF_HISTORY_CSV_PATH?.trim() || null;
|
||||
const historyOfferId = historyCsvPath === null
|
||||
? null
|
||||
: required(env, "SURPLUS_PROOF_HISTORY_OFFER_ID");
|
||||
|
||||
return {
|
||||
ogmiosUrl,
|
||||
walletPrivateKeyHex: walletPrivateKeyHex.toLowerCase(),
|
||||
ledgerPath: requiredWithDefault(
|
||||
env.SURPLUS_PROOF_LEDGER_PATH,
|
||||
"/var/lib/surplus-manager/vector-proofs.json",
|
||||
"SURPLUS_PROOF_LEDGER_PATH",
|
||||
),
|
||||
reserveLovelace: BigInt(boundedInteger(
|
||||
env.SURPLUS_PROOF_RESERVE_LOVELACE ?? "5000000",
|
||||
"SURPLUS_PROOF_RESERVE_LOVELACE",
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
)),
|
||||
feeBudgetLovelace: BigInt(boundedInteger(
|
||||
env.SURPLUS_PROOF_FEE_BUDGET_LOVELACE ?? "1000000",
|
||||
"SURPLUS_PROOF_FEE_BUDGET_LOVELACE",
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
)),
|
||||
intervalMs: boundedInteger(
|
||||
env.SURPLUS_PROOF_INTERVAL_MS ?? "300000",
|
||||
"SURPLUS_PROOF_INTERVAL_MS",
|
||||
60_000,
|
||||
86_400_000,
|
||||
),
|
||||
confirmTimeoutMs: boundedInteger(
|
||||
env.SURPLUS_PROOF_CONFIRM_TIMEOUT_MS ?? "300000",
|
||||
"SURPLUS_PROOF_CONFIRM_TIMEOUT_MS",
|
||||
10_000,
|
||||
3_600_000,
|
||||
),
|
||||
historyCsvPath,
|
||||
historyOfferId,
|
||||
};
|
||||
}
|
||||
|
||||
function required(
|
||||
env: Record<string, string | undefined>,
|
||||
name: string,
|
||||
): string {
|
||||
const value = env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredWithDefault(
|
||||
value: string | undefined,
|
||||
fallback: string,
|
||||
name: string,
|
||||
): string {
|
||||
const result = (value ?? fallback).trim();
|
||||
if (!result) throw new Error(`${name} must not be empty`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function prefixedSecret(value: string, name: string, prefix: string): string {
|
||||
if (!value.startsWith(prefix) || value.length <= prefix.length) {
|
||||
throw new Error(`${name} must start with ${prefix}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function usd(value: string, name: string): string {
|
||||
if (!USD_RE.test(value)) {
|
||||
throw new Error(`${name} must be a non-negative USD decimal with at most 6 places`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function evmAddress(value: string, name: string): string {
|
||||
if (!EVM_ADDRESS_RE.test(value)) {
|
||||
throw new Error(`${name} must be a 20-byte 0x address`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: string,
|
||||
name: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const pattern = minimum === 0 ? NON_NEGATIVE_INT_RE : POSITIVE_INT_RE;
|
||||
if (!pattern.test(value)) throw new Error(`${name} must be an integer`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new Error(`${name} must be from ${minimum} to ${maximum}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function statusList(value: string, name: string): string[] {
|
||||
if (value.trim() === "") return [];
|
||||
const statuses = value.split(",").map((status) => status.trim().toLowerCase());
|
||||
const seen = new Set<string>();
|
||||
for (const status of statuses) {
|
||||
if (!/^[a-z][a-z0-9_-]*$/.test(status)) {
|
||||
throw new Error(`${name} contains an invalid status`);
|
||||
}
|
||||
if (seen.has(status)) throw new Error(`${name} contains a duplicate status`);
|
||||
seen.add(status);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function httpsUrl(value: string, name: string, hostname: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${name} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== hostname) {
|
||||
throw new Error(`${name} must use https://${hostname}`);
|
||||
}
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
788
supplier/src/surplus/controller.ts
Normal file
788
supplier/src/surplus/controller.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parseUsdDecimal, formatUsdNanos } from "../reseller/money.js";
|
||||
import type { SurplusManagerConfig } from "./config.js";
|
||||
import {
|
||||
SurplusClient,
|
||||
SurplusHttpError,
|
||||
type SurplusDiscoveredModel,
|
||||
type SurplusEarnings,
|
||||
type SurplusOffer,
|
||||
} from "./client.js";
|
||||
import type {
|
||||
OpenRouterKeyAllowance,
|
||||
OpenRouterKeyClient,
|
||||
} from "./openRouterKey.js";
|
||||
import {
|
||||
formatMicroUsd,
|
||||
quoteSurplusMultiplier,
|
||||
} from "./policy.js";
|
||||
import {
|
||||
eligibleCompetitors,
|
||||
selectSurplusModelCandidates,
|
||||
} from "./selection.js";
|
||||
import type {
|
||||
SurplusControllerState,
|
||||
SurplusOfferIntent,
|
||||
SurplusStateStore,
|
||||
} from "./state.js";
|
||||
|
||||
export type SurplusControllerClient = Pick<
|
||||
SurplusClient,
|
||||
| "listAllOffers"
|
||||
| "discoverModels"
|
||||
| "getMarkets"
|
||||
| "getOrderBook"
|
||||
| "testConnection"
|
||||
| "createOffer"
|
||||
| "updateOffer"
|
||||
| "pauseOffer"
|
||||
| "resumeOffer"
|
||||
| "getEarnings"
|
||||
>;
|
||||
|
||||
export type OpenRouterAllowanceReader = Pick<OpenRouterKeyClient, "readAllowance">;
|
||||
|
||||
export interface SurplusControllerStatus {
|
||||
ok: boolean;
|
||||
phase: SurplusControllerState["phase"] | "error" | "planned" | "suspended";
|
||||
live: boolean;
|
||||
model: string | null;
|
||||
providerModelId: string | null;
|
||||
offerId: string | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
competitorId: string | null;
|
||||
remainingAllowanceUsd: string | null;
|
||||
reserveThresholdUsd: string;
|
||||
totalEarnedUsd: string | null;
|
||||
lastCycleAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
interface SurplusControllerOptions {
|
||||
config: SurplusManagerConfig;
|
||||
client: SurplusControllerClient;
|
||||
allowance: OpenRouterAllowanceReader;
|
||||
stateStore: SurplusStateStore;
|
||||
now?: () => number;
|
||||
mutationId?: () => string;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
|
||||
export class SurplusSellerController {
|
||||
private readonly config: SurplusManagerConfig;
|
||||
private readonly client: SurplusControllerClient;
|
||||
private readonly allowance: OpenRouterAllowanceReader;
|
||||
private readonly stateStore: SurplusStateStore;
|
||||
private readonly now: () => number;
|
||||
private readonly mutationId: () => string;
|
||||
private readonly log: (message: string) => void;
|
||||
private state: SurplusControllerState | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private stopped = false;
|
||||
private status: SurplusControllerStatus;
|
||||
|
||||
constructor(options: SurplusControllerOptions) {
|
||||
this.config = options.config;
|
||||
this.client = options.client;
|
||||
this.allowance = options.allowance;
|
||||
this.stateStore = options.stateStore;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.mutationId = options.mutationId ?? randomUUID;
|
||||
this.log = options.log ?? (() => undefined);
|
||||
const perOfferCap = parseUsdDecimal(this.config.perOfferCapUsd, "ceil");
|
||||
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
|
||||
if (perOfferCap > aggregateCap) {
|
||||
throw new Error("Surplus per-offer cap must not exceed the aggregate cap");
|
||||
}
|
||||
this.status = {
|
||||
ok: false,
|
||||
phase: "selecting",
|
||||
live: this.config.live,
|
||||
model: null,
|
||||
providerModelId: null,
|
||||
offerId: null,
|
||||
inputUsdPer1m: null,
|
||||
outputUsdPer1m: null,
|
||||
competitorId: null,
|
||||
remainingAllowanceUsd: null,
|
||||
reserveThresholdUsd: this.config.reserveUsd,
|
||||
totalEarnedUsd: null,
|
||||
lastCycleAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): SurplusControllerStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
healthy(): boolean {
|
||||
if (this.state?.phase === "completed") return true;
|
||||
if (!this.status.ok || this.status.lastSuccessAt === null) return false;
|
||||
const freshnessLimit =
|
||||
this.config.pollIntervalMs + this.config.requestTimeoutMs + 60_000;
|
||||
return this.now() - Date.parse(this.status.lastSuccessAt) <= freshnessLimit;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.state !== null) return;
|
||||
this.state = await this.stateStore.load();
|
||||
await this.runOnce();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
clearTimeout(this.timer ?? undefined);
|
||||
this.timer = null;
|
||||
if (this.inFlight) await this.inFlight;
|
||||
}
|
||||
|
||||
async runOnce(): Promise<void> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const cycle = this.performCycle();
|
||||
this.inFlight = cycle;
|
||||
try {
|
||||
await cycle;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.stopped || this.state?.phase === "completed") return;
|
||||
this.timer = setTimeout(() => {
|
||||
void this.runOnce().finally(() => this.schedule());
|
||||
}, this.config.pollIntervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private async performCycle(): Promise<void> {
|
||||
if (this.state === null) this.state = await this.stateStore.load();
|
||||
const cycleAt = new Date(this.now()).toISOString();
|
||||
this.status = { ...this.status, lastCycleAt: cycleAt };
|
||||
if (this.state.phase === "completed") {
|
||||
this.markSuccess("completed", null, null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (this.state.phase === "selecting") await this.selectAndCreate();
|
||||
else if (this.state.phase === "create_pending") await this.adoptPendingCreate();
|
||||
else if (this.state.phase === "active") await this.manageActiveOffer();
|
||||
else if (this.state.phase === "stopping") await this.finishStopping();
|
||||
else await this.awaitSettlement();
|
||||
} catch (error) {
|
||||
const stateAtFailure = this.state;
|
||||
let paused = false;
|
||||
if (
|
||||
this.config.live &&
|
||||
stateAtFailure !== null &&
|
||||
"offerId" in stateAtFailure
|
||||
) {
|
||||
try {
|
||||
await this.client.pauseOffer(stateAtFailure.offerId, this.mutationId());
|
||||
paused = true;
|
||||
} catch {
|
||||
paused = false;
|
||||
}
|
||||
}
|
||||
const detail = this.safeError(error);
|
||||
this.status = {
|
||||
...this.status,
|
||||
ok: false,
|
||||
phase: "error",
|
||||
lastError: paused ? `${detail}; managed offer paused` : detail,
|
||||
};
|
||||
this.log(`Surplus cycle failed: ${this.status.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async selectAndCreate(): Promise<void> {
|
||||
const offers = await this.client.listAllOffers();
|
||||
const active = offers.filter((offer) => offer.status === "active");
|
||||
if (active.length > 0) {
|
||||
await this.pauseOffers(active);
|
||||
this.markSuccess("suspended", null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowance = await this.allowance.readAllowance();
|
||||
this.assertAllowance(
|
||||
allowance,
|
||||
this.providerExposureUsdNanos(
|
||||
parseUsdDecimal(this.config.aggregateCapUsd, "ceil"),
|
||||
),
|
||||
);
|
||||
const [discovered, markets] = await Promise.all([
|
||||
this.client.discoverModels(
|
||||
this.config.providerApiKey,
|
||||
this.config.providerBaseUrl,
|
||||
),
|
||||
this.client.getMarkets(),
|
||||
]);
|
||||
const candidates = selectSurplusModelCandidates(
|
||||
discovered,
|
||||
markets,
|
||||
this.config.recoveryBps,
|
||||
this.config.undercutBps,
|
||||
);
|
||||
const ownOfferIds = new Set(offers.map((offer) => offer.id));
|
||||
let intent: SurplusOfferIntent | null = null;
|
||||
let competitorId: string | null = null;
|
||||
for (const candidate of candidates.slice(0, this.config.maxCandidateOrderBooks)) {
|
||||
const book = await this.client.getOrderBook(candidate.model);
|
||||
const competitors = eligibleCompetitors(
|
||||
book.offers,
|
||||
this.config.sellerWallet,
|
||||
ownOfferIds,
|
||||
);
|
||||
const quote = quoteSurplusMultiplier({
|
||||
upstreamInputMicroUsdPer1m: candidate.upstreamInputMicroUsdPer1m,
|
||||
upstreamOutputMicroUsdPer1m: candidate.upstreamOutputMicroUsdPer1m,
|
||||
recoveryBps: this.config.recoveryBps,
|
||||
undercutBps: this.config.undercutBps,
|
||||
competitors,
|
||||
});
|
||||
if (!quote.competitive) continue;
|
||||
try {
|
||||
await this.client.testConnection(
|
||||
this.config.providerApiKey,
|
||||
this.config.providerBaseUrl,
|
||||
candidate.model,
|
||||
);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`Surplus candidate probe failed model=${candidate.model}: ${this.safeError(error)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
intent = {
|
||||
model: candidate.model,
|
||||
providerModelId: candidate.providerModelId,
|
||||
costMultiplierPpm: quote.costMultiplierPpm,
|
||||
inputMicroUsdPer1m: quote.inputMicroUsdPer1m,
|
||||
outputMicroUsdPer1m: quote.outputMicroUsdPer1m,
|
||||
baselineRemainingUsdNanos: allowance.remainingUsdNanos.toString(),
|
||||
dailyCapUsd: Number(this.config.perOfferCapUsd),
|
||||
idempotencyKey: this.mutationId(),
|
||||
createdAt: new Date(this.now()).toISOString(),
|
||||
baselineTrades24h: 0,
|
||||
};
|
||||
competitorId = quote.competitorId;
|
||||
break;
|
||||
}
|
||||
if (intent === null) {
|
||||
throw new Error("No supported OpenRouter model has a safe competitive quote");
|
||||
}
|
||||
if (!this.config.live) {
|
||||
this.markSuccess("planned", allowance, {
|
||||
intent,
|
||||
offerId: null,
|
||||
competitorId,
|
||||
totalEarnedMicroUsd: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pending: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "create_pending",
|
||||
intent,
|
||||
};
|
||||
await this.stateStore.save(pending);
|
||||
this.state = pending;
|
||||
let createdOfferId: string | null = null;
|
||||
let postCreateActive: SurplusOffer[] = [];
|
||||
try {
|
||||
createdOfferId = await this.client.createOffer({
|
||||
model: intent.model,
|
||||
apiKey: this.config.providerApiKey,
|
||||
sellerBaseUrl: this.config.providerBaseUrl,
|
||||
costMultiplier: intent.costMultiplierPpm / 1_000_000,
|
||||
dailyCapUsd: intent.dailyCapUsd,
|
||||
payoutAddress: this.config.payoutAddress,
|
||||
idempotencyKey: intent.idempotencyKey,
|
||||
});
|
||||
const postCreateOffers = await this.client.listAllOffers();
|
||||
postCreateActive = postCreateOffers.filter(
|
||||
(offer) => offer.status === "active",
|
||||
);
|
||||
const created = postCreateActive.find(
|
||||
(offer) => offer.id === createdOfferId,
|
||||
);
|
||||
if (
|
||||
!created ||
|
||||
created.model !== intent.model ||
|
||||
postCreateActive.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Surplus create verification did not find exactly one expected active offer",
|
||||
);
|
||||
}
|
||||
this.assertAggregateCap(postCreateOffers);
|
||||
const activeState: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "active",
|
||||
offerId: createdOfferId,
|
||||
intent,
|
||||
highestTrades24h: 0,
|
||||
};
|
||||
await this.stateStore.save(activeState);
|
||||
this.state = activeState;
|
||||
this.markSuccess("active", allowance, {
|
||||
intent,
|
||||
offerId: createdOfferId,
|
||||
competitorId,
|
||||
totalEarnedMicroUsd: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (createdOfferId !== null) {
|
||||
const ids = new Set(postCreateActive.map((offer) => offer.id));
|
||||
ids.add(createdOfferId);
|
||||
for (const id of ids) {
|
||||
await this.client.pauseOffer(id, this.mutationId()).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (error instanceof SurplusHttpError && !error.ambiguousMutation) {
|
||||
const selecting: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "selecting",
|
||||
};
|
||||
await this.stateStore.save(selecting);
|
||||
this.state = selecting;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async adoptPendingCreate(): Promise<void> {
|
||||
if (this.state?.phase !== "create_pending") return;
|
||||
const pending = this.state;
|
||||
const offers = await this.client.listAllOffers();
|
||||
const active = offers.filter((offer) => offer.status === "active");
|
||||
const activeMatches = active
|
||||
.filter((offer) =>
|
||||
offer.model === pending.intent.model &&
|
||||
normalizeUrl(offer.sellerBaseUrl) ===
|
||||
normalizeUrl(this.config.providerBaseUrl)
|
||||
)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const inactiveMatches = offers
|
||||
.filter((offer) =>
|
||||
offer.status === "inactive" &&
|
||||
offer.model === pending.intent.model &&
|
||||
normalizeUrl(offer.sellerBaseUrl) ===
|
||||
normalizeUrl(this.config.providerBaseUrl)
|
||||
)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const primary = activeMatches[0] ?? inactiveMatches[0];
|
||||
if (!primary) {
|
||||
if (active.length > 0) await this.pauseOffers(active);
|
||||
throw new Error(
|
||||
"Create result is ambiguous and no matching offer is visible; refusing another POST",
|
||||
);
|
||||
}
|
||||
await this.pauseOffers(active.filter((offer) => offer.id !== primary.id));
|
||||
const confirmedOffers = await this.client.listAllOffers();
|
||||
const confirmedActive = confirmedOffers.filter(
|
||||
(offer) => offer.status === "active",
|
||||
);
|
||||
const expectedActive = primary.status === "active" ? 1 : 0;
|
||||
if (
|
||||
confirmedActive.length !== expectedActive ||
|
||||
(expectedActive === 1 && confirmedActive[0].id !== primary.id)
|
||||
) {
|
||||
await this.pauseOffers(confirmedActive);
|
||||
throw new Error("Adopted Surplus create did not reconcile safely");
|
||||
}
|
||||
const adopted: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "active",
|
||||
offerId: primary.id,
|
||||
intent: pending.intent,
|
||||
highestTrades24h: pending.intent.baselineTrades24h,
|
||||
};
|
||||
try {
|
||||
this.assertAggregateCap(confirmedOffers);
|
||||
await this.stateStore.save(adopted);
|
||||
} catch (error) {
|
||||
await this.pauseOffers(confirmedActive).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
this.state = adopted;
|
||||
if (primary.status === "inactive") {
|
||||
await this.manageActiveOffer();
|
||||
return;
|
||||
}
|
||||
this.markSuccess("active", null, {
|
||||
intent: adopted.intent,
|
||||
offerId: adopted.offerId,
|
||||
competitorId: null,
|
||||
totalEarnedMicroUsd: null,
|
||||
});
|
||||
}
|
||||
|
||||
private async manageActiveOffer(): Promise<void> {
|
||||
if (this.state?.phase !== "active") return;
|
||||
const currentState = this.state;
|
||||
const offers = await this.client.listAllOffers();
|
||||
const activeOffers = offers.filter((offer) => offer.status === "active");
|
||||
const managed = offers.find((offer) => offer.id === currentState.offerId);
|
||||
if (!managed) {
|
||||
await this.pauseOffers(activeOffers);
|
||||
throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
|
||||
}
|
||||
const otherActive = activeOffers.filter(
|
||||
(offer) => offer.id !== currentState.offerId,
|
||||
);
|
||||
if (otherActive.length > 0) {
|
||||
await this.pauseOffers(otherActive);
|
||||
this.markSuccess("suspended", null, null);
|
||||
return;
|
||||
}
|
||||
this.assertAggregateCap(offers);
|
||||
const allowance = await this.allowance.readAllowance();
|
||||
this.assertAllowance(
|
||||
allowance,
|
||||
this.providerExposureUsdNanos(
|
||||
parseUsdDecimal(this.config.perOfferCapUsd, "ceil"),
|
||||
),
|
||||
);
|
||||
const baselineAllowance = BigInt(
|
||||
currentState.intent.baselineRemainingUsdNanos,
|
||||
);
|
||||
const providerSpend = baselineAllowance > allowance.remainingUsdNanos
|
||||
? baselineAllowance - allowance.remainingUsdNanos
|
||||
: 0n;
|
||||
const spendCap = this.providerExposureUsdNanos(
|
||||
parseUsdDecimal(this.config.perOfferCapUsd, "ceil"),
|
||||
);
|
||||
if (this.config.stopAfterTrades > 0 && providerSpend >= spendCap) {
|
||||
const stopping: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "stopping",
|
||||
offerId: currentState.offerId,
|
||||
intent: currentState.intent,
|
||||
highestTrades24h: currentState.highestTrades24h,
|
||||
tradeObservedAt: currentState.intent.createdAt,
|
||||
pauseIdempotencyKey: this.mutationId(),
|
||||
};
|
||||
await this.stateStore.save(stopping);
|
||||
this.state = stopping;
|
||||
await this.finishStopping();
|
||||
return;
|
||||
}
|
||||
const discovered = await this.client.discoverModels(
|
||||
this.config.providerApiKey,
|
||||
this.config.providerBaseUrl,
|
||||
);
|
||||
const selected = this.requireSelectedDiscovery(discovered, currentState.intent);
|
||||
const book = await this.client.getOrderBook(currentState.intent.model);
|
||||
const ownBookOffer = book.offers.find((offer) => offer.id === currentState.offerId);
|
||||
if (
|
||||
this.config.stopAfterTrades > 0 &&
|
||||
ownBookOffer &&
|
||||
ownBookOffer.trades24h >=
|
||||
currentState.highestTrades24h + this.config.stopAfterTrades
|
||||
) {
|
||||
const stopping: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "stopping",
|
||||
offerId: currentState.offerId,
|
||||
intent: currentState.intent,
|
||||
highestTrades24h: ownBookOffer.trades24h,
|
||||
tradeObservedAt: new Date(this.now()).toISOString(),
|
||||
pauseIdempotencyKey: this.mutationId(),
|
||||
};
|
||||
await this.stateStore.save(stopping);
|
||||
this.state = stopping;
|
||||
await this.finishStopping();
|
||||
return;
|
||||
}
|
||||
|
||||
const ownIds = new Set(offers.map((offer) => offer.id));
|
||||
const competitors = eligibleCompetitors(
|
||||
book.offers,
|
||||
this.config.sellerWallet,
|
||||
ownIds,
|
||||
);
|
||||
const quote = quoteSurplusMultiplier({
|
||||
upstreamInputMicroUsdPer1m: Math.ceil(selected.inputUsdPer1m * 1_000_000),
|
||||
upstreamOutputMicroUsdPer1m: Math.ceil(selected.outputUsdPer1m * 1_000_000),
|
||||
recoveryBps: this.config.recoveryBps,
|
||||
undercutBps: this.config.undercutBps,
|
||||
competitors,
|
||||
});
|
||||
const nextIntent: SurplusOfferIntent = {
|
||||
...currentState.intent,
|
||||
costMultiplierPpm: quote.costMultiplierPpm,
|
||||
inputMicroUsdPer1m: quote.inputMicroUsdPer1m,
|
||||
outputMicroUsdPer1m: quote.outputMicroUsdPer1m,
|
||||
};
|
||||
const priceChanged =
|
||||
managed.costMultiplierPpm === null ||
|
||||
Math.abs(managed.costMultiplierPpm - quote.costMultiplierPpm) > 1 ||
|
||||
managed.capDailyUsd !== Number(this.config.perOfferCapUsd);
|
||||
if (this.config.live && priceChanged) {
|
||||
await this.client.updateOffer(currentState.offerId, {
|
||||
costMultiplier: quote.costMultiplier,
|
||||
dailyCapUsd: Number(this.config.perOfferCapUsd),
|
||||
idempotencyKey: this.mutationId(),
|
||||
});
|
||||
}
|
||||
if (this.config.live && managed.status === "inactive") {
|
||||
await this.client.resumeOffer(currentState.offerId, this.mutationId());
|
||||
}
|
||||
const activeState: SurplusControllerState = {
|
||||
...currentState,
|
||||
intent: nextIntent,
|
||||
highestTrades24h: ownBookOffer?.trades24h ?? currentState.highestTrades24h,
|
||||
};
|
||||
await this.stateStore.save(activeState);
|
||||
this.state = activeState;
|
||||
const earnings = await this.client.getEarnings();
|
||||
this.markSuccess("active", allowance, {
|
||||
intent: nextIntent,
|
||||
offerId: currentState.offerId,
|
||||
competitorId: quote.competitorId,
|
||||
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
|
||||
});
|
||||
}
|
||||
|
||||
private async finishStopping(): Promise<void> {
|
||||
if (this.state?.phase !== "stopping") return;
|
||||
const stopping = this.state;
|
||||
const before = await this.client.listAllOffers();
|
||||
const active = before.filter((offer) => offer.status === "active");
|
||||
for (const offer of active) {
|
||||
const key = offer.id === stopping.offerId
|
||||
? stopping.pauseIdempotencyKey
|
||||
: this.mutationId();
|
||||
await this.client.pauseOffer(offer.id, key);
|
||||
}
|
||||
const after = await this.client.listAllOffers();
|
||||
if (after.some((offer) => offer.status === "active")) {
|
||||
throw new Error("Surplus proof stop could not confirm all offers inactive");
|
||||
}
|
||||
const awaiting: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "awaiting_settlement",
|
||||
offerId: stopping.offerId,
|
||||
intent: stopping.intent,
|
||||
tradeObservedAt: stopping.tradeObservedAt,
|
||||
};
|
||||
await this.stateStore.save(awaiting);
|
||||
this.state = awaiting;
|
||||
await this.awaitSettlement();
|
||||
}
|
||||
|
||||
private async awaitSettlement(): Promise<void> {
|
||||
if (this.state?.phase !== "awaiting_settlement") return;
|
||||
const awaiting = this.state;
|
||||
const offers = await this.client.listAllOffers();
|
||||
const active = offers.filter((offer) => offer.status === "active");
|
||||
if (active.length > 0) {
|
||||
await this.pauseOffers(active);
|
||||
this.markSuccess("suspended", null, null);
|
||||
return;
|
||||
}
|
||||
const earnings = await this.client.getEarnings();
|
||||
const sale = this.findSettledSale(earnings, awaiting);
|
||||
if (!sale) {
|
||||
this.markSuccess("awaiting_settlement", null, {
|
||||
intent: awaiting.intent,
|
||||
offerId: awaiting.offerId,
|
||||
competitorId: null,
|
||||
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const completed: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "completed",
|
||||
offerId: awaiting.offerId,
|
||||
model: awaiting.intent.model,
|
||||
providerModelId: awaiting.intent.providerModelId,
|
||||
tradeObservedAt: awaiting.tradeObservedAt,
|
||||
completedAt: new Date(this.now()).toISOString(),
|
||||
settlement: {
|
||||
offerId: sale.offerId,
|
||||
createdAt: sale.createdAt ?? awaiting.tradeObservedAt,
|
||||
sellerCostMicroUsd: sale.sellerCostMicroUsd,
|
||||
settlementStatus: sale.settlementStatus,
|
||||
},
|
||||
};
|
||||
await this.stateStore.save(completed);
|
||||
this.state = completed;
|
||||
this.markSuccess("completed", null, {
|
||||
intent: awaiting.intent,
|
||||
offerId: awaiting.offerId,
|
||||
competitorId: null,
|
||||
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
|
||||
});
|
||||
}
|
||||
|
||||
private findSettledSale(
|
||||
earnings: SurplusEarnings,
|
||||
state: Extract<SurplusControllerState, { phase: "awaiting_settlement" }>,
|
||||
): SurplusEarnings["recentSales"][number] | null {
|
||||
const tradeObservedAt = Date.parse(state.tradeObservedAt);
|
||||
return earnings.recentSales.find((sale) => {
|
||||
if (
|
||||
!this.config.settledStatuses.includes(
|
||||
sale.settlementStatus.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
sale.offerId !== state.offerId ||
|
||||
sale.createdAt === null ||
|
||||
sale.model !== state.intent.model ||
|
||||
sale.sellerCostMicroUsd <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(sale.createdAt) >= tradeObservedAt;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
private requireSelectedDiscovery(
|
||||
discovered: SurplusDiscoveredModel[],
|
||||
intent: SurplusOfferIntent,
|
||||
): SurplusDiscoveredModel {
|
||||
const selected = discovered.find((model) =>
|
||||
model.model === intent.model && model.providerModelId === intent.providerModelId
|
||||
);
|
||||
if (
|
||||
!selected ||
|
||||
!selected.supported ||
|
||||
selected.availabilityStatus !== "available" ||
|
||||
selected.modelType !== "text" ||
|
||||
selected.priceUnit !== "M" ||
|
||||
selected.priceVariable ||
|
||||
selected.inputUsdPer1m <= 0 ||
|
||||
selected.outputUsdPer1m <= 0
|
||||
) {
|
||||
throw new Error(`Selected OpenRouter model is no longer safely sellable: ${intent.model}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private assertAggregateCap(offers: SurplusOffer[]): void {
|
||||
let activeCap = 0n;
|
||||
for (const offer of offers) {
|
||||
if (offer.status !== "active") continue;
|
||||
if (offer.capDailyUsd === null) {
|
||||
throw new Error(`Active Surplus offer has no daily cap: ${offer.id}`);
|
||||
}
|
||||
const capMicroUsd = offer.capDailyUsd * 1_000_000;
|
||||
if (!Number.isSafeInteger(capMicroUsd)) {
|
||||
throw new Error(`Active Surplus offer cap has excess precision: ${offer.id}`);
|
||||
}
|
||||
activeCap += BigInt(capMicroUsd) * 1_000n;
|
||||
}
|
||||
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
|
||||
if (activeCap > aggregateCap) {
|
||||
throw new Error("Active Surplus offer caps exceed the aggregate cap");
|
||||
}
|
||||
}
|
||||
|
||||
private providerExposureUsdNanos(sellerCapUsdNanos: bigint): bigint {
|
||||
const numerator = sellerCapUsdNanos * 10_000n;
|
||||
const denominator = BigInt(this.config.recoveryBps);
|
||||
return (numerator + denominator - 1n) / denominator;
|
||||
}
|
||||
|
||||
private assertAllowance(
|
||||
allowance: OpenRouterKeyAllowance,
|
||||
exposureUsdNanos: bigint,
|
||||
): void {
|
||||
const reserve = parseUsdDecimal(this.config.reserveUsd, "ceil");
|
||||
const maxLimit = parseUsdDecimal(this.config.maxProviderLimitUsd, "floor");
|
||||
if (
|
||||
allowance.limitUsdNanos > maxLimit ||
|
||||
allowance.remainingUsdNanos > allowance.limitUsdNanos
|
||||
) {
|
||||
throw new Error("OpenRouter key limit exceeds the configured containment limit");
|
||||
}
|
||||
if (allowance.remainingUsdNanos < reserve + exposureUsdNanos) {
|
||||
throw new Error("OpenRouter allowance cannot cover reserve threshold plus exposure");
|
||||
}
|
||||
}
|
||||
|
||||
private async pauseOffers(offers: SurplusOffer[]): Promise<void> {
|
||||
if (!this.config.live) return;
|
||||
for (const offer of offers) {
|
||||
await this.client.pauseOffer(offer.id, this.mutationId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private safeError(error: unknown): string {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return detail
|
||||
.replaceAll(this.config.sellerApiKey, "[redacted]")
|
||||
.replaceAll(this.config.providerApiKey, "[redacted]")
|
||||
.replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]")
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
private markSuccess(
|
||||
phase: SurplusControllerStatus["phase"],
|
||||
allowance: OpenRouterKeyAllowance | null,
|
||||
details: {
|
||||
intent: SurplusOfferIntent;
|
||||
offerId: string | null;
|
||||
competitorId: string | null;
|
||||
totalEarnedMicroUsd: number | null;
|
||||
} | null,
|
||||
): void {
|
||||
const successAt = new Date(this.now()).toISOString();
|
||||
const state = this.state;
|
||||
const stateModel = state && "intent" in state
|
||||
? state.intent.model
|
||||
: state?.phase === "completed"
|
||||
? state.model
|
||||
: null;
|
||||
const stateProviderModel = state && "intent" in state
|
||||
? state.intent.providerModelId
|
||||
: state?.phase === "completed"
|
||||
? state.providerModelId
|
||||
: null;
|
||||
const stateOfferId = state && "offerId" in state ? state.offerId : null;
|
||||
this.status = {
|
||||
...this.status,
|
||||
ok: true,
|
||||
phase,
|
||||
model: details?.intent.model ?? stateModel,
|
||||
providerModelId: details?.intent.providerModelId ?? stateProviderModel,
|
||||
offerId: details?.offerId ?? stateOfferId,
|
||||
inputUsdPer1m: details
|
||||
? formatMicroUsd(details.intent.inputMicroUsdPer1m)
|
||||
: this.status.inputUsdPer1m,
|
||||
outputUsdPer1m: details
|
||||
? formatMicroUsd(details.intent.outputMicroUsdPer1m)
|
||||
: this.status.outputUsdPer1m,
|
||||
competitorId: details?.competitorId ?? null,
|
||||
remainingAllowanceUsd: allowance
|
||||
? formatUsdNanos(allowance.remainingUsdNanos, 6)
|
||||
: this.status.remainingAllowanceUsd,
|
||||
totalEarnedUsd: details?.totalEarnedMicroUsd === null || details === null
|
||||
? this.status.totalEarnedUsd
|
||||
: formatMicroUsd(details.totalEarnedMicroUsd),
|
||||
lastSuccessAt: successAt,
|
||||
lastError: null,
|
||||
};
|
||||
this.log(`Surplus controller phase=${phase} model=${this.status.model ?? "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string): string {
|
||||
return value.replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
301
supplier/src/surplus/dashboard.ts
Normal file
301
supplier/src/surplus/dashboard.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { SurplusManagerConfig } from "./config.js";
|
||||
import type {
|
||||
SurplusClient,
|
||||
SurplusDailyEarnings,
|
||||
SurplusEarnings,
|
||||
SurplusOffer,
|
||||
SurplusOrderBook,
|
||||
SurplusSale,
|
||||
} from "./client.js";
|
||||
import {
|
||||
hashSurplusSale,
|
||||
type SurplusDashboardVectorProof,
|
||||
} from "./vectorProof.js";
|
||||
import type {
|
||||
SurplusControllerStatus,
|
||||
SurplusSellerController,
|
||||
} from "./controller.js";
|
||||
import type { OpenRouterKeyClient } from "./openRouterKey.js";
|
||||
import { formatUsdNanos } from "../reseller/money.js";
|
||||
import { formatMicroUsd } from "./policy.js";
|
||||
|
||||
export interface VectorMigrationSummary {
|
||||
status: "retired";
|
||||
model: string;
|
||||
supplierWallet: string;
|
||||
advertRef: string;
|
||||
retirementTransaction: string;
|
||||
retiredOn: string;
|
||||
historicalAp3xEarned: string;
|
||||
historicalSettledJobs: number;
|
||||
historicalUpstreamSpendUsd: string;
|
||||
}
|
||||
export interface DashboardSaleProof {
|
||||
status: "pending" | "confirmed" | "failed";
|
||||
saleHash: string | null;
|
||||
batchRoot: string | null;
|
||||
txHash: string | null;
|
||||
leafIndex: number | null;
|
||||
siblings: string[];
|
||||
}
|
||||
|
||||
export interface DashboardProofSource {
|
||||
dashboardProof(saleId: string): SurplusDashboardVectorProof | null;
|
||||
}
|
||||
|
||||
|
||||
export interface SurplusDashboardSnapshot {
|
||||
generatedAt: string;
|
||||
controller: SurplusControllerStatus;
|
||||
identity: {
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
};
|
||||
provider: {
|
||||
name: "OpenRouter";
|
||||
hardLimitUsd: string;
|
||||
remainingUsd: string;
|
||||
usedUsd: string;
|
||||
reserveUsd: string;
|
||||
estimatedDailyExposureUsd: string;
|
||||
};
|
||||
surplus: {
|
||||
offer: {
|
||||
id: string | null;
|
||||
model: string | null;
|
||||
providerModel: string | null;
|
||||
status: "active" | "inactive" | "missing";
|
||||
dailyCapUsd: string | null;
|
||||
costMultiplier: number | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
rank: number | null;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
volume24hUsd: string;
|
||||
capRemainingUsd: string | null;
|
||||
};
|
||||
earnings: {
|
||||
totalUsd: string;
|
||||
pendingUsd: string;
|
||||
paidUsd: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
today: SurplusDailyEarnings | null;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
};
|
||||
recentSales: Array<{
|
||||
id: string;
|
||||
model: string;
|
||||
createdAt: string | null;
|
||||
settlementStatus: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
transactionHash: string | null;
|
||||
vectorProof: DashboardSaleProof;
|
||||
}>;
|
||||
};
|
||||
vector: VectorMigrationSummary;
|
||||
}
|
||||
|
||||
interface SurplusDashboardOptions {
|
||||
controller: Pick<SurplusSellerController, "snapshot">;
|
||||
client: Pick<SurplusClient, "listAllOffers" | "getEarnings" | "getOrderBook">;
|
||||
allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||
config: SurplusManagerConfig;
|
||||
vector?: VectorMigrationSummary;
|
||||
proofs?: DashboardProofSource;
|
||||
cacheMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_VECTOR_MIGRATION: VectorMigrationSummary = {
|
||||
status: "retired",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
supplierWallet: "addr1v8zy05zsugau8su0lqn8q37xlryh39cww9mw9t6g7vt207c9xfc9d",
|
||||
advertRef:
|
||||
"29bc9e7ec9f4a26f33e419436cfe95239b5d06b75db637e8dbb4cbc478d1a445#0",
|
||||
retirementTransaction:
|
||||
"d7fdae603e5744c98efc20c25d73a83ebfef0308806026a370d9f81c975c11fa",
|
||||
retiredOn: "2026-08-27",
|
||||
historicalAp3xEarned: "1.600000",
|
||||
historicalSettledJobs: 8,
|
||||
historicalUpstreamSpendUsd: "0.000093",
|
||||
};
|
||||
|
||||
export class SurplusDashboardService {
|
||||
private readonly controller: Pick<SurplusSellerController, "snapshot">;
|
||||
private readonly client: Pick<
|
||||
SurplusClient,
|
||||
"listAllOffers" | "getEarnings" | "getOrderBook"
|
||||
>;
|
||||
private readonly allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||
private readonly config: SurplusManagerConfig;
|
||||
private readonly proofs: DashboardProofSource | null;
|
||||
private readonly vector: VectorMigrationSummary;
|
||||
private readonly cacheMs: number;
|
||||
private readonly now: () => number;
|
||||
private cache: { fetchedAt: number; value: SurplusDashboardSnapshot } | null = null;
|
||||
private inFlight: Promise<SurplusDashboardSnapshot> | null = null;
|
||||
|
||||
constructor(options: SurplusDashboardOptions) {
|
||||
this.controller = options.controller;
|
||||
this.client = options.client;
|
||||
this.allowance = options.allowance;
|
||||
this.config = options.config;
|
||||
this.proofs = options.proofs ?? null;
|
||||
this.vector = options.vector ?? DEFAULT_VECTOR_MIGRATION;
|
||||
this.cacheMs = options.cacheMs ?? 15_000;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<SurplusDashboardSnapshot> {
|
||||
const now = this.now();
|
||||
if (this.cache && now - this.cache.fetchedAt < this.cacheMs) {
|
||||
return this.cache.value;
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const load = this.loadSnapshot();
|
||||
this.inFlight = load;
|
||||
try {
|
||||
const value = await load;
|
||||
this.cache = { fetchedAt: this.now(), value };
|
||||
return value;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSnapshot(): Promise<SurplusDashboardSnapshot> {
|
||||
const controller = this.controller.snapshot();
|
||||
const [offers, earnings, allowance, orderBook] = await Promise.all([
|
||||
this.client.listAllOffers(),
|
||||
this.client.getEarnings(),
|
||||
this.allowance.readAllowance(),
|
||||
controller.model ? this.client.getOrderBook(controller.model) : null,
|
||||
]);
|
||||
const offer = findOffer(offers, controller.offerId);
|
||||
const marketOffer = findMarketOffer(orderBook, controller.offerId);
|
||||
const usedUsdNanos = allowance.limitUsdNanos - allowance.remainingUsdNanos;
|
||||
const estimatedDailyExposure =
|
||||
Number(this.config.perOfferCapUsd) * 10_000 / this.config.recoveryBps;
|
||||
const today = earnings.daily.find((day) =>
|
||||
day.day === new Date(this.now()).toISOString().slice(0, 10)
|
||||
) ?? null;
|
||||
|
||||
return {
|
||||
generatedAt: new Date(this.now()).toISOString(),
|
||||
controller,
|
||||
identity: {
|
||||
sellerWallet: this.config.sellerWallet,
|
||||
payoutAddress: this.config.payoutAddress,
|
||||
},
|
||||
provider: {
|
||||
name: "OpenRouter",
|
||||
hardLimitUsd: formatUsdNanos(allowance.limitUsdNanos, 6),
|
||||
remainingUsd: formatUsdNanos(allowance.remainingUsdNanos, 6),
|
||||
usedUsd: formatUsdNanos(usedUsdNanos, 6),
|
||||
reserveUsd: this.config.reserveUsd,
|
||||
estimatedDailyExposureUsd: estimatedDailyExposure.toFixed(6),
|
||||
},
|
||||
surplus: {
|
||||
offer: {
|
||||
id: offer?.id ?? controller.offerId,
|
||||
model: offer?.model ?? controller.model,
|
||||
providerModel: controller.providerModelId,
|
||||
status: offer?.status ?? "missing",
|
||||
dailyCapUsd: offer?.capDailyUsd === null || offer === undefined
|
||||
? null
|
||||
: offer.capDailyUsd.toFixed(6),
|
||||
costMultiplier: offer?.costMultiplierPpm === null || offer === undefined
|
||||
? null
|
||||
: offer.costMultiplierPpm / 1_000_000,
|
||||
inputUsdPer1m: controller.inputUsdPer1m,
|
||||
outputUsdPer1m: controller.outputUsdPer1m,
|
||||
rank: marketOffer?.rank ?? null,
|
||||
available: marketOffer?.available ?? false,
|
||||
healthy: marketOffer?.healthy ?? false,
|
||||
trusted: marketOffer?.trusted ?? false,
|
||||
trades24h: marketOffer?.trades24h ?? 0,
|
||||
volume24hUsd: formatMicroUsd(marketOffer?.volume24hMicroUsd ?? 0),
|
||||
capRemainingUsd: marketOffer?.capRemainingMicroUsd === null ||
|
||||
marketOffer === undefined
|
||||
? null
|
||||
: formatMicroUsd(marketOffer.capRemainingMicroUsd),
|
||||
},
|
||||
earnings: earningsSummary(earnings, today),
|
||||
recentSales: earnings.recentSales.slice(0, 25).map((sale) => ({
|
||||
id: sale.id,
|
||||
model: sale.model,
|
||||
createdAt: sale.createdAt,
|
||||
settlementStatus: sale.settlementStatus,
|
||||
inputTokens: sale.inputTokens,
|
||||
outputTokens: sale.outputTokens,
|
||||
cacheReadTokens: sale.cacheReadTokens,
|
||||
revenueUsd: formatMicroUsd(sale.sellerCostMicroUsd),
|
||||
transactionHash: sale.transactionHash,
|
||||
vectorProof: this.proofFor(sale),
|
||||
})),
|
||||
},
|
||||
vector: this.vector,
|
||||
};
|
||||
}
|
||||
|
||||
private proofFor(sale: SurplusSale): DashboardSaleProof {
|
||||
const ledger = this.proofs?.dashboardProof(sale.id) ?? null;
|
||||
if (ledger !== null) return ledger;
|
||||
let saleHash: string | null = null;
|
||||
try {
|
||||
saleHash = hashSurplusSale(sale);
|
||||
} catch {
|
||||
saleHash = null;
|
||||
}
|
||||
return {
|
||||
status: "pending",
|
||||
saleHash,
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findOffer(
|
||||
offers: SurplusOffer[],
|
||||
offerId: string | null,
|
||||
): SurplusOffer | undefined {
|
||||
return offerId ? offers.find((offer) => offer.id === offerId) : undefined;
|
||||
}
|
||||
|
||||
function findMarketOffer(
|
||||
orderBook: SurplusOrderBook | null,
|
||||
offerId: string | null,
|
||||
) {
|
||||
return offerId
|
||||
? orderBook?.offers.find((offer) => offer.id === offerId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function earningsSummary(
|
||||
earnings: SurplusEarnings,
|
||||
today: SurplusDailyEarnings | null,
|
||||
): SurplusDashboardSnapshot["surplus"]["earnings"] {
|
||||
return {
|
||||
totalUsd: formatMicroUsd(earnings.totalEarnedMicroUsd),
|
||||
pendingUsd: formatMicroUsd(earnings.pendingMicroUsd),
|
||||
paidUsd: formatMicroUsd(earnings.paidMicroUsd),
|
||||
requests: earnings.requestCount,
|
||||
tokens: earnings.tokenCount,
|
||||
today,
|
||||
topModel: earnings.topModel,
|
||||
payoutHoldReason: earnings.payoutHoldReason,
|
||||
payoutHoldReleasesAt: earnings.payoutHoldReleasesAt,
|
||||
};
|
||||
}
|
||||
215
supplier/src/surplus/historyExport.ts
Normal file
215
supplier/src/surplus/historyExport.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* historyExport.ts — loads the Surplus support export
|
||||
* (request_id, settled_at, model, input_tokens, output_tokens,
|
||||
* seller_earned_usd, settlement_status, settlement_type, tx_hash) and maps the
|
||||
* settled rows onto the publisher's sale shape.
|
||||
*
|
||||
* The export carries less detail than the live API. The mapping records what
|
||||
* the export proves and nothing more:
|
||||
* - `createdAt` uses the export's `settled_at` timestamp,
|
||||
* - `cacheReadTokens` and both effective prices are zero (absent from the export),
|
||||
* - `offerId` comes from configuration because the export omits it.
|
||||
* Rows that are not settled (`settlement_status` !== "confirmed") are skipped;
|
||||
* they surface later through the live window or a newer export.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { SurplusSale } from "./client.js";
|
||||
|
||||
const EXPECTED_HEADER = [
|
||||
"request_id",
|
||||
"settled_at",
|
||||
"model",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"seller_earned_usd",
|
||||
"settlement_status",
|
||||
"settlement_type",
|
||||
"tx_hash",
|
||||
] as const;
|
||||
|
||||
const BASE_TX_RE = /^0x[0-9a-fA-F]{64}$/;
|
||||
const TOKENS_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const USD_RE = /^(?:0|[1-9]\d*)(?:\.(\d{1,6}))?$/;
|
||||
|
||||
export interface HistoricalSalesExport {
|
||||
sales: SurplusSale[];
|
||||
settledWithoutTx: number;
|
||||
skippedUnsettled: number;
|
||||
}
|
||||
|
||||
export async function loadHistoricalSalesCsv(
|
||||
path: string,
|
||||
offerId: string,
|
||||
): Promise<HistoricalSalesExport> {
|
||||
if (offerId.trim() === "") {
|
||||
throw new Error("The historical export needs a non-empty offer id");
|
||||
}
|
||||
const rows = parseCsv(await readFile(path, "utf8"));
|
||||
const header = rows.shift();
|
||||
if (
|
||||
header === undefined ||
|
||||
header.length !== EXPECTED_HEADER.length ||
|
||||
EXPECTED_HEADER.some((name, index) => header[index] !== name)
|
||||
) {
|
||||
throw new Error(
|
||||
`Surplus export header must be exactly: ${EXPECTED_HEADER.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result: HistoricalSalesExport = {
|
||||
sales: [],
|
||||
settledWithoutTx: 0,
|
||||
skippedUnsettled: 0,
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const label = `Surplus export row ${index + 2}`;
|
||||
if (row.length !== EXPECTED_HEADER.length) {
|
||||
throw new Error(`${label} has ${row.length} fields, expected 9`);
|
||||
}
|
||||
const [
|
||||
requestId,
|
||||
settledAt,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
earnedUsd,
|
||||
settlementStatus,
|
||||
,
|
||||
txHash,
|
||||
] = row;
|
||||
|
||||
if (requestId === "") throw new Error(`${label} is missing request_id`);
|
||||
if (seen.has(requestId)) {
|
||||
throw new Error(`${label} repeats request_id ${requestId}`);
|
||||
}
|
||||
seen.add(requestId);
|
||||
if (settlementStatus !== "confirmed") {
|
||||
result.skippedUnsettled += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAtMs = Date.parse(settledAt);
|
||||
if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) {
|
||||
throw new Error(`${label} has an invalid settled_at`);
|
||||
}
|
||||
const transactionHash = txHash === "" ? null : normalizeBaseTx(txHash, label);
|
||||
if (transactionHash === null) result.settledWithoutTx += 1;
|
||||
|
||||
result.sales.push({
|
||||
id: requestId,
|
||||
model: nonEmpty(model, `${label} model`),
|
||||
offerId,
|
||||
settlementStatus,
|
||||
createdAt: new Date(createdAtMs).toISOString(),
|
||||
createdAtMs,
|
||||
sellerCostMicroUsd: usdToMicro(earnedUsd, `${label} seller_earned_usd`),
|
||||
inputTokens: tokens(inputTokens, `${label} input_tokens`),
|
||||
outputTokens: tokens(outputTokens, `${label} output_tokens`),
|
||||
cacheReadTokens: 0,
|
||||
effectiveInputUsdPer1m: 0,
|
||||
effectiveOutputUsdPer1m: 0,
|
||||
transactionHash,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeBaseTx(value: string, label: string): string {
|
||||
if (!BASE_TX_RE.test(value)) {
|
||||
throw new Error(`${label} has an invalid tx_hash`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
if (value.trim() === "") throw new Error(`${label} must not be empty`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokens(value: string, label: string): number {
|
||||
if (!TOKENS_RE.test(value)) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${label} exceeds the safe integer range`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Exact decimal → micro-USD integer without float rounding. */
|
||||
function usdToMicro(value: string, label: string): number {
|
||||
const match = USD_RE.exec(value);
|
||||
if (match === null) {
|
||||
throw new Error(`${label} must be a non-negative USD decimal with at most 6 places`);
|
||||
}
|
||||
const whole = Number(value.split(".")[0]);
|
||||
const fraction = Number((match[1] ?? "").padEnd(6, "0"));
|
||||
const micro = whole * 1_000_000 + fraction;
|
||||
if (!Number.isSafeInteger(micro)) {
|
||||
throw new Error(`${label} exceeds the safe integer range`);
|
||||
}
|
||||
return micro;
|
||||
}
|
||||
|
||||
/** Minimal RFC-4180 parser: quoted fields, escaped quotes, CRLF or LF rows. */
|
||||
function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
let index = 0;
|
||||
|
||||
const pushField = (): void => {
|
||||
row.push(field);
|
||||
field = "";
|
||||
};
|
||||
const pushRow = (): void => {
|
||||
pushField();
|
||||
rows.push(row);
|
||||
row = [];
|
||||
};
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
if (quoted) {
|
||||
if (char === '"') {
|
||||
if (text[index + 1] === '"') {
|
||||
field += '"';
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
quoted = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
field += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
if (field !== "") throw new Error(`Unexpected quote at offset ${index}`);
|
||||
quoted = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ",") {
|
||||
pushField();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && text[index + 1] === "\n") index += 1;
|
||||
pushRow();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
field += char;
|
||||
index += 1;
|
||||
}
|
||||
if (quoted) throw new Error("Unterminated quoted field");
|
||||
if (field !== "" || row.length > 0) pushRow();
|
||||
return rows.filter((entry) => !(entry.length === 1 && entry[0] === ""));
|
||||
}
|
||||
127
supplier/src/surplus/openRouterKey.ts
Normal file
127
supplier/src/surplus/openRouterKey.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { parseUsdDecimal } from "../reseller/money.js";
|
||||
|
||||
const MAX_KEY_RESPONSE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface OpenRouterKeyAllowance {
|
||||
limitUsdNanos: bigint;
|
||||
remainingUsdNanos: bigint;
|
||||
reset: string | null;
|
||||
checkedAtMs: number;
|
||||
}
|
||||
|
||||
interface OpenRouterKeyClientOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class OpenRouterKeyClient {
|
||||
private readonly url: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchFn: typeof globalThis.fetch;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: OpenRouterKeyClientOptions) {
|
||||
this.url = `${options.baseUrl.replace(/\/+$/, "")}/v1/auth/key`;
|
||||
this.apiKey = options.apiKey;
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.fetchFn = options.fetchFn ?? globalThis.fetch;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async readAllowance(): Promise<OpenRouterKeyAllowance> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const response = await this.fetchFn(this.url, {
|
||||
headers: { authorization: `Bearer ${this.apiKey}` },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await readBoundedText(response, controller);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenRouter key endpoint returned HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("OpenRouter key endpoint response was not JSON");
|
||||
}
|
||||
const root = object(body, "OpenRouter key response");
|
||||
const data = object(root.data, "OpenRouter key response.data");
|
||||
if (data.limit === null || data.limit_remaining === null) {
|
||||
throw new Error("OpenRouter resale key must have a finite spending limit");
|
||||
}
|
||||
const limit = usdNumberToNanos(data.limit, "OpenRouter key limit");
|
||||
const remaining = usdNumberToNanos(
|
||||
data.limit_remaining,
|
||||
"OpenRouter key limit_remaining",
|
||||
);
|
||||
const reset = data.limit_reset;
|
||||
if (reset !== null && reset !== undefined && typeof reset !== "string") {
|
||||
throw new Error("OpenRouter key limit_reset must be a string or null");
|
||||
}
|
||||
return {
|
||||
limitUsdNanos: limit,
|
||||
remainingUsdNanos: remaining,
|
||||
reset: typeof reset === "string" ? reset : null,
|
||||
checkedAtMs: this.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`OpenRouter allowance check failed: ${detail.replaceAll(this.apiKey, "[redacted]")}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedText(
|
||||
response: Response,
|
||||
controller: AbortController,
|
||||
): Promise<string> {
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_KEY_RESPONSE_BYTES) {
|
||||
controller.abort();
|
||||
throw new Error(
|
||||
`OpenRouter key response exceeded ${MAX_KEY_RESPONSE_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function usdNumberToNanos(value: unknown, field: string): bigint {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
return parseUsdDecimal(value.toFixed(9), "floor");
|
||||
}
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
163
supplier/src/surplus/policy.ts
Normal file
163
supplier/src/surplus/policy.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
export interface SurplusCompetitor {
|
||||
id: string;
|
||||
inputMicroUsdPer1m: number;
|
||||
outputMicroUsdPer1m: number;
|
||||
}
|
||||
|
||||
export interface SurplusMultiplierPolicyInput {
|
||||
upstreamInputMicroUsdPer1m: number;
|
||||
upstreamOutputMicroUsdPer1m: number;
|
||||
recoveryBps: number;
|
||||
undercutBps: number;
|
||||
competitors: SurplusCompetitor[];
|
||||
}
|
||||
|
||||
export interface SurplusMultiplierQuote {
|
||||
costMultiplierPpm: number;
|
||||
costMultiplier: number;
|
||||
inputMicroUsdPer1m: number;
|
||||
outputMicroUsdPer1m: number;
|
||||
floorMultiplierPpm: number;
|
||||
competitorId: string | null;
|
||||
competitive: boolean;
|
||||
}
|
||||
|
||||
const BPS_SCALE = 10_000n;
|
||||
const PPM_SCALE = 1_000_000n;
|
||||
const MICRO_USD = 1_000_000;
|
||||
|
||||
export function quoteSurplusMultiplier(
|
||||
input: SurplusMultiplierPolicyInput,
|
||||
): SurplusMultiplierQuote {
|
||||
positiveSafeInteger(
|
||||
input.upstreamInputMicroUsdPer1m,
|
||||
"upstream input price",
|
||||
);
|
||||
positiveSafeInteger(
|
||||
input.upstreamOutputMicroUsdPer1m,
|
||||
"upstream output price",
|
||||
);
|
||||
boundedBps(input.recoveryBps, "recoveryBps", 1, 10_000);
|
||||
boundedBps(input.undercutBps, "undercutBps", 0, 9_999);
|
||||
|
||||
const floorMultiplierPpm = input.recoveryBps * 100;
|
||||
const competitor = cheapestCompetitor(input.competitors);
|
||||
const competitorMultiplierPpm = competitor
|
||||
? Math.min(
|
||||
ratioPpm(
|
||||
competitor.inputMicroUsdPer1m,
|
||||
input.upstreamInputMicroUsdPer1m,
|
||||
),
|
||||
ratioPpm(
|
||||
competitor.outputMicroUsdPer1m,
|
||||
input.upstreamOutputMicroUsdPer1m,
|
||||
),
|
||||
)
|
||||
: floorMultiplierPpm;
|
||||
let undercutMultiplierPpm = Number(
|
||||
BigInt(competitorMultiplierPpm) *
|
||||
BigInt(10_000 - input.undercutBps) /
|
||||
BPS_SCALE,
|
||||
);
|
||||
if (
|
||||
input.undercutBps > 0 &&
|
||||
undercutMultiplierPpm >= competitorMultiplierPpm
|
||||
) {
|
||||
undercutMultiplierPpm = Math.max(1, competitorMultiplierPpm - 1);
|
||||
}
|
||||
const quotedMultiplierPpm = Math.max(
|
||||
floorMultiplierPpm,
|
||||
undercutMultiplierPpm,
|
||||
);
|
||||
const quotedInput = applyMultiplier(
|
||||
input.upstreamInputMicroUsdPer1m,
|
||||
quotedMultiplierPpm,
|
||||
);
|
||||
const quotedOutput = applyMultiplier(
|
||||
input.upstreamOutputMicroUsdPer1m,
|
||||
quotedMultiplierPpm,
|
||||
);
|
||||
|
||||
return {
|
||||
costMultiplierPpm: quotedMultiplierPpm,
|
||||
costMultiplier: quotedMultiplierPpm / Number(PPM_SCALE),
|
||||
inputMicroUsdPer1m: quotedInput,
|
||||
outputMicroUsdPer1m: quotedOutput,
|
||||
floorMultiplierPpm,
|
||||
competitorId: competitor?.id ?? null,
|
||||
competitive: competitor === null || (
|
||||
quotedInput < competitor.inputMicroUsdPer1m &&
|
||||
quotedOutput < competitor.outputMicroUsdPer1m
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function usdPer1mToMicroUsd(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive finite number`);
|
||||
}
|
||||
const result = Math.ceil(value * MICRO_USD);
|
||||
positiveSafeInteger(result, field);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatMicroUsd(value: number): string {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("micro-USD value must be a non-negative safe integer");
|
||||
}
|
||||
const whole = Math.floor(value / MICRO_USD);
|
||||
const fraction = (value % MICRO_USD).toString().padStart(6, "0");
|
||||
return `${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
function cheapestCompetitor(
|
||||
competitors: SurplusCompetitor[],
|
||||
): SurplusCompetitor | null {
|
||||
let result: SurplusCompetitor | null = null;
|
||||
for (const competitor of competitors) {
|
||||
positiveSafeInteger(competitor.inputMicroUsdPer1m, "competitor input price");
|
||||
positiveSafeInteger(competitor.outputMicroUsdPer1m, "competitor output price");
|
||||
if (result === null || compareCompetitors(competitor, result) < 0) {
|
||||
result = competitor;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareCompetitors(
|
||||
left: SurplusCompetitor,
|
||||
right: SurplusCompetitor,
|
||||
): number {
|
||||
return (
|
||||
left.inputMicroUsdPer1m + left.outputMicroUsdPer1m -
|
||||
(right.inputMicroUsdPer1m + right.outputMicroUsdPer1m) ||
|
||||
left.inputMicroUsdPer1m - right.inputMicroUsdPer1m ||
|
||||
left.outputMicroUsdPer1m - right.outputMicroUsdPer1m ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function ratioPpm(numerator: number, denominator: number): number {
|
||||
return Number(BigInt(numerator) * PPM_SCALE / BigInt(denominator));
|
||||
}
|
||||
|
||||
function applyMultiplier(value: number, multiplierPpm: number): number {
|
||||
return Number(BigInt(value) * BigInt(multiplierPpm) / PPM_SCALE);
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: number, field: string): void {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedBps(
|
||||
value: number,
|
||||
field: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${field} must be from ${minimum} to ${maximum}`);
|
||||
}
|
||||
}
|
||||
110
supplier/src/surplus/selection.ts
Normal file
110
supplier/src/surplus/selection.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
SurplusDiscoveredModel,
|
||||
SurplusMarketSummary,
|
||||
SurplusOrderBookOffer,
|
||||
} from "./client.js";
|
||||
import {
|
||||
quoteSurplusMultiplier,
|
||||
usdPer1mToMicroUsd,
|
||||
type SurplusCompetitor,
|
||||
type SurplusMultiplierQuote,
|
||||
} from "./policy.js";
|
||||
|
||||
export interface SurplusModelCandidate {
|
||||
model: string;
|
||||
providerModelId: string;
|
||||
upstreamInputMicroUsdPer1m: number;
|
||||
upstreamOutputMicroUsdPer1m: number;
|
||||
requests24h: number;
|
||||
volume24h: number;
|
||||
summaryQuote: SurplusMultiplierQuote;
|
||||
}
|
||||
|
||||
export function selectSurplusModelCandidates(
|
||||
discovered: SurplusDiscoveredModel[],
|
||||
markets: SurplusMarketSummary[],
|
||||
recoveryBps: number,
|
||||
undercutBps: number,
|
||||
): SurplusModelCandidate[] {
|
||||
const marketByModel = new Map(markets.map((market) => [market.model, market]));
|
||||
const candidates: SurplusModelCandidate[] = [];
|
||||
for (const model of discovered) {
|
||||
const market = marketByModel.get(model.model);
|
||||
if (
|
||||
!market ||
|
||||
!model.supported ||
|
||||
model.providerModelId === "" ||
|
||||
model.availabilityStatus !== "available" ||
|
||||
model.modelType !== "text" ||
|
||||
model.priceUnit !== "M" ||
|
||||
model.priceVariable ||
|
||||
model.inputUsdPer1m <= 0 ||
|
||||
model.outputUsdPer1m <= 0 ||
|
||||
market.healthySellerCount <= 0 ||
|
||||
market.bestInputMicroUsdPer1m <= 0 ||
|
||||
market.bestOutputMicroUsdPer1m <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const inputPrice = usdPer1mToMicroUsd(
|
||||
model.inputUsdPer1m,
|
||||
`${model.model} discovery input price`,
|
||||
);
|
||||
const outputPrice = usdPer1mToMicroUsd(
|
||||
model.outputUsdPer1m,
|
||||
`${model.model} discovery output price`,
|
||||
);
|
||||
const quote = quoteSurplusMultiplier({
|
||||
upstreamInputMicroUsdPer1m: inputPrice,
|
||||
upstreamOutputMicroUsdPer1m: outputPrice,
|
||||
recoveryBps,
|
||||
undercutBps,
|
||||
competitors: [{
|
||||
id: `summary:${model.model}`,
|
||||
inputMicroUsdPer1m: market.bestInputMicroUsdPer1m,
|
||||
outputMicroUsdPer1m: market.bestOutputMicroUsdPer1m,
|
||||
}],
|
||||
});
|
||||
if (!quote.competitive) continue;
|
||||
candidates.push({
|
||||
model: model.model,
|
||||
providerModelId: model.providerModelId,
|
||||
upstreamInputMicroUsdPer1m: inputPrice,
|
||||
upstreamOutputMicroUsdPer1m: outputPrice,
|
||||
requests24h: market.requests24h,
|
||||
volume24h: market.volume24h,
|
||||
summaryQuote: quote,
|
||||
});
|
||||
}
|
||||
return candidates.sort(compareCandidates);
|
||||
}
|
||||
|
||||
export function eligibleCompetitors(
|
||||
offers: SurplusOrderBookOffer[],
|
||||
sellerWallet: string,
|
||||
ownOfferIds: ReadonlySet<string>,
|
||||
): SurplusCompetitor[] {
|
||||
const normalizedWallet = sellerWallet.toLowerCase();
|
||||
return offers
|
||||
.filter((offer) =>
|
||||
offer.available &&
|
||||
offer.healthy &&
|
||||
offer.seller !== normalizedWallet &&
|
||||
!ownOfferIds.has(offer.id)
|
||||
)
|
||||
.map((offer) => ({
|
||||
id: offer.id,
|
||||
inputMicroUsdPer1m: offer.inputMicroUsdPer1m,
|
||||
outputMicroUsdPer1m: offer.outputMicroUsdPer1m,
|
||||
}));
|
||||
}
|
||||
|
||||
function compareCandidates(
|
||||
left: SurplusModelCandidate,
|
||||
right: SurplusModelCandidate,
|
||||
): number {
|
||||
return right.requests24h - left.requests24h ||
|
||||
right.volume24h - left.volume24h ||
|
||||
left.model.localeCompare(right.model) ||
|
||||
left.providerModelId.localeCompare(right.providerModelId);
|
||||
}
|
||||
355
supplier/src/surplus/state.ts
Normal file
355
supplier/src/surplus/state.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
rename,
|
||||
unlink,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, basename, join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface SurplusOfferIntent {
|
||||
model: string;
|
||||
providerModelId: string;
|
||||
costMultiplierPpm: number;
|
||||
inputMicroUsdPer1m: number;
|
||||
outputMicroUsdPer1m: number;
|
||||
dailyCapUsd: number;
|
||||
baselineRemainingUsdNanos: string;
|
||||
idempotencyKey: string;
|
||||
createdAt: string;
|
||||
baselineTrades24h: number;
|
||||
}
|
||||
|
||||
export interface SurplusSettlementEvidence {
|
||||
offerId: string | null;
|
||||
createdAt: string;
|
||||
sellerCostMicroUsd: number;
|
||||
settlementStatus: string;
|
||||
}
|
||||
|
||||
export type SurplusControllerState =
|
||||
| { version: 1; phase: "selecting" }
|
||||
| { version: 1; phase: "create_pending"; intent: SurplusOfferIntent }
|
||||
| {
|
||||
version: 1;
|
||||
phase: "active";
|
||||
offerId: string;
|
||||
intent: SurplusOfferIntent;
|
||||
highestTrades24h: number;
|
||||
}
|
||||
| {
|
||||
version: 1;
|
||||
phase: "stopping";
|
||||
offerId: string;
|
||||
intent: SurplusOfferIntent;
|
||||
highestTrades24h: number;
|
||||
tradeObservedAt: string;
|
||||
pauseIdempotencyKey: string;
|
||||
}
|
||||
| {
|
||||
version: 1;
|
||||
phase: "awaiting_settlement";
|
||||
offerId: string;
|
||||
intent: SurplusOfferIntent;
|
||||
tradeObservedAt: string;
|
||||
}
|
||||
| {
|
||||
version: 1;
|
||||
phase: "completed";
|
||||
offerId: string;
|
||||
model: string;
|
||||
providerModelId: string;
|
||||
tradeObservedAt: string;
|
||||
completedAt: string;
|
||||
settlement: SurplusSettlementEvidence;
|
||||
};
|
||||
|
||||
export interface SurplusStateStore {
|
||||
load(): Promise<SurplusControllerState>;
|
||||
save(state: SurplusControllerState): Promise<void>;
|
||||
}
|
||||
|
||||
export class FileSurplusStateStore implements SurplusStateStore {
|
||||
private readonly path: string;
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
async load(): Promise<SurplusControllerState> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(
|
||||
this.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNodeError(error, "ENOENT")) return { version: 1, phase: "selecting" };
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
assertSecureOwner(info, false, "Surplus state file");
|
||||
const text = await handle.readFile("utf8");
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Surplus state file is not valid JSON");
|
||||
}
|
||||
return parseState(value);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async save(state: SurplusControllerState): Promise<void> {
|
||||
const directory = dirname(this.path);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await chmod(directory, 0o700);
|
||||
const directoryInfo = await lstat(directory);
|
||||
assertSecureOwner(directoryInfo, true, "Surplus state directory");
|
||||
const tempPath = join(directory, `.${basename(this.path)}.${randomUUID()}.tmp`);
|
||||
const body = `${JSON.stringify(state)}\n`;
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await open(
|
||||
tempPath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
await handle.writeFile(body, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = null;
|
||||
await rename(tempPath, this.path);
|
||||
const directoryHandle = await open(
|
||||
directory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
try {
|
||||
await directoryHandle.sync();
|
||||
} finally {
|
||||
await directoryHandle.close();
|
||||
}
|
||||
} finally {
|
||||
if (handle !== null) await handle.close().catch(() => undefined);
|
||||
await unlink(tempPath).catch((error) => {
|
||||
if (!isNodeError(error, "ENOENT")) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSecureOwner(
|
||||
info: Stats,
|
||||
directory: boolean,
|
||||
field: string,
|
||||
): void {
|
||||
if (directory ? !info.isDirectory() : !info.isFile()) {
|
||||
throw new Error(`${field} has the wrong file type`);
|
||||
}
|
||||
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
||||
throw new Error(`${field} must be owned by the service user`);
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error(`${field} must not allow group or other access`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseState(value: unknown): SurplusControllerState {
|
||||
const root = object(value, "Surplus state");
|
||||
if (root.version !== 1) throw new Error("Surplus state version must be 1");
|
||||
const phase = nonEmptyString(root.phase, "Surplus state phase");
|
||||
if (phase === "selecting") return { version: 1, phase };
|
||||
if (phase === "create_pending") {
|
||||
return { version: 1, phase, intent: parseIntent(root.intent) };
|
||||
}
|
||||
if (phase === "active") {
|
||||
return {
|
||||
version: 1,
|
||||
phase,
|
||||
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
|
||||
intent: parseIntent(root.intent),
|
||||
highestTrades24h: nonNegativeInteger(
|
||||
root.highestTrades24h,
|
||||
"Surplus state highestTrades24h",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (phase === "stopping") {
|
||||
return {
|
||||
version: 1,
|
||||
phase,
|
||||
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
|
||||
intent: parseIntent(root.intent),
|
||||
highestTrades24h: nonNegativeInteger(
|
||||
root.highestTrades24h,
|
||||
"Surplus state highestTrades24h",
|
||||
),
|
||||
tradeObservedAt: isoTimestamp(
|
||||
root.tradeObservedAt,
|
||||
"Surplus state tradeObservedAt",
|
||||
),
|
||||
pauseIdempotencyKey: nonEmptyString(
|
||||
root.pauseIdempotencyKey,
|
||||
"Surplus state pauseIdempotencyKey",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (phase === "awaiting_settlement") {
|
||||
return {
|
||||
version: 1,
|
||||
phase,
|
||||
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
|
||||
intent: parseIntent(root.intent),
|
||||
tradeObservedAt: isoTimestamp(
|
||||
root.tradeObservedAt,
|
||||
"Surplus state tradeObservedAt",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (phase === "completed") {
|
||||
return {
|
||||
version: 1,
|
||||
phase,
|
||||
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
|
||||
model: nonEmptyString(root.model, "Surplus state model"),
|
||||
providerModelId: nonEmptyString(
|
||||
root.providerModelId,
|
||||
"Surplus state providerModelId",
|
||||
),
|
||||
tradeObservedAt: isoTimestamp(
|
||||
root.tradeObservedAt,
|
||||
"Surplus state tradeObservedAt",
|
||||
),
|
||||
completedAt: isoTimestamp(root.completedAt, "Surplus state completedAt"),
|
||||
settlement: parseSettlement(root.settlement),
|
||||
};
|
||||
}
|
||||
throw new Error(`Surplus state phase is unsupported: ${phase}`);
|
||||
}
|
||||
|
||||
function parseSettlement(value: unknown): SurplusSettlementEvidence {
|
||||
const settlement = object(value, "Surplus state settlement");
|
||||
const offerId = settlement.offerId;
|
||||
if (offerId !== null && (typeof offerId !== "string" || offerId === "")) {
|
||||
throw new Error("Surplus state settlement.offerId must be a string or null");
|
||||
}
|
||||
return {
|
||||
offerId,
|
||||
createdAt: isoTimestamp(
|
||||
settlement.createdAt,
|
||||
"Surplus state settlement.createdAt",
|
||||
),
|
||||
sellerCostMicroUsd: positiveInteger(
|
||||
settlement.sellerCostMicroUsd,
|
||||
"Surplus state settlement.sellerCostMicroUsd",
|
||||
),
|
||||
settlementStatus: nonEmptyString(
|
||||
settlement.settlementStatus,
|
||||
"Surplus state settlement.settlementStatus",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntent(value: unknown): SurplusOfferIntent {
|
||||
const intent = object(value, "Surplus state intent");
|
||||
return {
|
||||
model: nonEmptyString(intent.model, "Surplus state intent.model"),
|
||||
providerModelId: nonEmptyString(
|
||||
intent.providerModelId,
|
||||
"Surplus state intent.providerModelId",
|
||||
),
|
||||
costMultiplierPpm: positiveInteger(
|
||||
intent.costMultiplierPpm,
|
||||
"Surplus state intent.costMultiplierPpm",
|
||||
),
|
||||
inputMicroUsdPer1m: positiveInteger(
|
||||
intent.inputMicroUsdPer1m,
|
||||
"Surplus state intent.inputMicroUsdPer1m",
|
||||
),
|
||||
outputMicroUsdPer1m: positiveInteger(
|
||||
intent.outputMicroUsdPer1m,
|
||||
"Surplus state intent.outputMicroUsdPer1m",
|
||||
),
|
||||
dailyCapUsd: positiveNumber(
|
||||
intent.dailyCapUsd,
|
||||
"Surplus state intent.dailyCapUsd",
|
||||
),
|
||||
baselineRemainingUsdNanos: nonNegativeIntegerString(
|
||||
intent.baselineRemainingUsdNanos,
|
||||
"Surplus state intent.baselineRemainingUsdNanos",
|
||||
),
|
||||
idempotencyKey: nonEmptyString(
|
||||
intent.idempotencyKey,
|
||||
"Surplus state intent.idempotencyKey",
|
||||
),
|
||||
createdAt: isoTimestamp(intent.createdAt, "Surplus state intent.createdAt"),
|
||||
baselineTrades24h: nonNegativeInteger(
|
||||
intent.baselineTrades24h,
|
||||
"Surplus state intent.baselineTrades24h",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
throw new Error(`${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeIntegerString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/.test(value)) {
|
||||
throw new Error(`${field} must be a non-negative integer string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isoTimestamp(value: unknown, field: string): string {
|
||||
const timestamp = nonEmptyString(value, field);
|
||||
if (!Number.isFinite(Date.parse(timestamp))) {
|
||||
throw new Error(`${field} must be an ISO timestamp`);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
||||
throw new Error(`${field} must be a positive safe integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new Error(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive finite number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
1085
supplier/src/surplus/vectorProof.ts
Normal file
1085
supplier/src/surplus/vectorProof.ts
Normal file
File diff suppressed because it is too large
Load Diff
297
supplier/src/surplus/vectorPublisher.ts
Normal file
297
supplier/src/surplus/vectorPublisher.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* vectorPublisher.ts — anchors Surplus sale Merkle batches on Vector.
|
||||
*
|
||||
* Policy (2026-08-28): every settled Base USDC settlement transaction gets its
|
||||
* own Vector counterpart. Sales are grouped by their Base `tx_hash`; each group
|
||||
* becomes one Merkle batch anchored in one label-674 Vector transaction.
|
||||
* Observed sales that fell out of the Surplus API window before settling are
|
||||
* anchored together in a trailing catch-up batch so no captured sale is left
|
||||
* without a proof.
|
||||
*
|
||||
* The publisher is fail-closed:
|
||||
* - it never anchors while the wallet cannot cover reserve + fee budget,
|
||||
* - every batch is persisted as a pending intent before any submission,
|
||||
* - a stale pending intent found at cycle start is failed as "submit" and
|
||||
* rebuilt deterministically (same sales ⇒ same root) on the same cycle.
|
||||
*/
|
||||
|
||||
import type { SurplusEarnings, SurplusSale } from "./client.js";
|
||||
import {
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
SURPLUS_VECTOR_BATCH_SIZE,
|
||||
type CanonicalSurplusSale,
|
||||
type FileSurplusVectorProofLedger,
|
||||
type SurplusPendingBatchIntent,
|
||||
} from "./vectorProof.js";
|
||||
|
||||
export interface SurplusProofAnchorMetadata {
|
||||
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
|
||||
root: string;
|
||||
count: number;
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
export type SurplusProofLedgerSeam = Pick<
|
||||
FileSurplusVectorProofLedger,
|
||||
| "ingestSales"
|
||||
| "selectUnprovedSales"
|
||||
| "persistPendingBatch"
|
||||
| "pendingBatchIntent"
|
||||
| "confirmPendingBatch"
|
||||
| "failPendingBatch"
|
||||
| "dashboardProof"
|
||||
>;
|
||||
|
||||
export interface SurplusVectorProofPublisherOptions {
|
||||
ledger: SurplusProofLedgerSeam;
|
||||
earnings: () => Promise<SurplusEarnings>;
|
||||
/**
|
||||
* Optional support-export source for sales the live window can no longer
|
||||
* show. Observed sales win on id collisions; ids already in the ledger are
|
||||
* never re-ingested, so a later live observation cannot conflict.
|
||||
*/
|
||||
historicalSales?: () => Promise<readonly SurplusSale[]>;
|
||||
anchor: (
|
||||
metadata: SurplusProofAnchorMetadata,
|
||||
) => Promise<{ expectedTxHash: string }>;
|
||||
awaitTx: (txHash: string, timeoutMs: number) => Promise<void>;
|
||||
balanceLovelace: () => Promise<bigint>;
|
||||
reserveLovelace: bigint;
|
||||
feeBudgetLovelace: bigint;
|
||||
settledStatuses: readonly string[];
|
||||
intervalMs: number;
|
||||
confirmTimeoutMs: number;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SurplusPublisherCycleResult {
|
||||
ingested: number;
|
||||
anchored: number;
|
||||
fundingBlocked: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export class SurplusVectorProofPublisher {
|
||||
private readonly options: SurplusVectorProofPublisherOptions;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<SurplusPublisherCycleResult> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(options: SurplusVectorProofPublisherOptions) {
|
||||
if (options.reserveLovelace < 0n || options.feeBudgetLovelace <= 0n) {
|
||||
throw new Error(
|
||||
"Surplus proof publisher needs a non-negative reserve and a positive fee budget",
|
||||
);
|
||||
}
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
this.stopped = false;
|
||||
void this.tick();
|
||||
this.timer = setInterval(() => void this.tick(), this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.inFlight !== null) {
|
||||
await this.inFlight.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.inFlight !== null || this.stopped) return;
|
||||
const cycle = this.runCycle();
|
||||
this.inFlight = cycle;
|
||||
try {
|
||||
await cycle;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.options.log(`surplus-proof: cycle failed: ${detail}`);
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runCycle(): Promise<SurplusPublisherCycleResult> {
|
||||
const { ledger, log } = this.options;
|
||||
const result: SurplusPublisherCycleResult = {
|
||||
ingested: 0,
|
||||
anchored: 0,
|
||||
fundingBlocked: false,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
const settled = await this.observeSettledSales();
|
||||
const historical = this.options.historicalSales === undefined
|
||||
? []
|
||||
: await this.options.historicalSales();
|
||||
const candidates = dedupeById([...settled, ...historical]);
|
||||
const fresh = candidates.filter((sale) =>
|
||||
ledger.dashboardProof(sale.id) === null
|
||||
);
|
||||
const { inserted } = await ledger.ingestSales(fresh);
|
||||
result.ingested = inserted;
|
||||
if (inserted > 0) {
|
||||
log(`surplus-proof: captured ${inserted} newly settled sale(s)`);
|
||||
}
|
||||
|
||||
const stale = ledger.pendingBatchIntent();
|
||||
if (stale !== null) {
|
||||
await ledger.failPendingBatch(stale.batchRoot, "submit");
|
||||
log(
|
||||
`surplus-proof: failed stale pending batch ${stale.batchRoot} from an interrupted run; retrying this cycle`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const batch of this.planBatches(candidates, ledger.selectUnprovedSales())) {
|
||||
if (this.stopped) break;
|
||||
if (!(await this.fundsAvailable())) {
|
||||
result.fundingBlocked = true;
|
||||
break;
|
||||
}
|
||||
const anchored = await this.anchorBatch(batch);
|
||||
if (!anchored) {
|
||||
result.failed = true;
|
||||
break;
|
||||
}
|
||||
result.anchored += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Settled = carries a Base settlement tx hash and passes the status filter. */
|
||||
private async observeSettledSales(): Promise<SurplusSale[]> {
|
||||
const { earnings, settledStatuses } = this.options;
|
||||
const snapshot = await earnings();
|
||||
return snapshot.recentSales.filter((sale) =>
|
||||
sale.transactionHash !== null &&
|
||||
(settledStatuses.length === 0 ||
|
||||
settledStatuses.includes(sale.settlementStatus))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One batch per known Base settlement tx (oldest first), then catch-up
|
||||
* batches for unproved sales with no known settlement tx, chunked to the
|
||||
* ledger batch-size limit.
|
||||
*/
|
||||
private planBatches(
|
||||
settled: readonly SurplusSale[],
|
||||
unproved: readonly CanonicalSurplusSale[],
|
||||
): CanonicalSurplusSale[][] {
|
||||
const txBySaleId = new Map<string, string>();
|
||||
for (const sale of settled) {
|
||||
if (sale.transactionHash !== null) {
|
||||
txBySaleId.set(sale.id, sale.transactionHash);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, CanonicalSurplusSale[]>();
|
||||
const leftovers: CanonicalSurplusSale[] = [];
|
||||
for (const sale of unproved) {
|
||||
const settlementTx = txBySaleId.get(sale.saleId);
|
||||
if (settlementTx === undefined) {
|
||||
leftovers.push(sale);
|
||||
continue;
|
||||
}
|
||||
const group = groups.get(settlementTx);
|
||||
if (group === undefined) groups.set(settlementTx, [sale]);
|
||||
else group.push(sale);
|
||||
}
|
||||
|
||||
const batches = [...groups.values()].sort(
|
||||
(left, right) => earliest(left) - earliest(right),
|
||||
);
|
||||
for (let start = 0; start < leftovers.length; start += SURPLUS_VECTOR_BATCH_SIZE) {
|
||||
batches.push(leftovers.slice(start, start + SURPLUS_VECTOR_BATCH_SIZE));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
private async fundsAvailable(): Promise<boolean> {
|
||||
const { balanceLovelace, reserveLovelace, feeBudgetLovelace, log } =
|
||||
this.options;
|
||||
const balance = await balanceLovelace();
|
||||
if (balance >= reserveLovelace + feeBudgetLovelace) return true;
|
||||
log(
|
||||
`surplus-proof: funding required — wallet holds ${balance} lovelace, ` +
|
||||
`needs reserve ${reserveLovelace} + fee budget ${feeBudgetLovelace}; anchoring paused`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async anchorBatch(
|
||||
sales: readonly CanonicalSurplusSale[],
|
||||
): Promise<boolean> {
|
||||
const { ledger, anchor, awaitTx, confirmTimeoutMs, log } = this.options;
|
||||
const saleIds = sales.map((sale) => sale.saleId).sort();
|
||||
const intent: SurplusPendingBatchIntent = await ledger.persistPendingBatch(
|
||||
saleIds,
|
||||
);
|
||||
|
||||
let expectedTxHash: string;
|
||||
try {
|
||||
const built = await anchor({
|
||||
p: SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
root: intent.batchRoot,
|
||||
count: intent.count,
|
||||
first: intent.firstSaleId,
|
||||
last: intent.lastSaleId,
|
||||
});
|
||||
expectedTxHash = built.expectedTxHash;
|
||||
} catch (error) {
|
||||
await ledger.failPendingBatch(intent.batchRoot, "submit");
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`surplus-proof: submit failed for batch ${intent.batchRoot} (${intent.count} sale(s)): ${detail}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await awaitTx(expectedTxHash, confirmTimeoutMs);
|
||||
} catch (error) {
|
||||
await ledger.failPendingBatch(intent.batchRoot, "confirm");
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`surplus-proof: confirmation timed out for batch ${intent.batchRoot} in tx ${expectedTxHash}: ${detail}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ledger.confirmPendingBatch(intent.batchRoot, expectedTxHash);
|
||||
log(
|
||||
`surplus-proof: anchored ${intent.count} sale(s) [${intent.firstSaleId}..${intent.lastSaleId}] ` +
|
||||
`root ${intent.batchRoot} in Vector tx ${expectedTxHash}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function earliest(sales: readonly CanonicalSurplusSale[]): number {
|
||||
let value = Number.POSITIVE_INFINITY;
|
||||
for (const sale of sales) {
|
||||
if (sale.createdAtEpochMs < value) value = sale.createdAtEpochMs;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** First occurrence wins; callers put observed live sales before export rows. */
|
||||
function dedupeById(sales: readonly SurplusSale[]): SurplusSale[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SurplusSale[] = [];
|
||||
for (const sale of sales) {
|
||||
if (seen.has(sale.id)) continue;
|
||||
seen.add(sale.id);
|
||||
unique.push(sale);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function buildSampleConfig(): SupplierConfig {
|
||||
maxChatSessions: 1,
|
||||
chatSettleMode: "full",
|
||||
liveChain: false,
|
||||
reseller: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
58
tests/unit/buyer-resale-dashboard.test.ts
Normal file
58
tests/unit/buyer-resale-dashboard.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../../buyer/src/server.js";
|
||||
|
||||
describe("buyer resale dashboard proxy", () => {
|
||||
it("serves the private supplier snapshot from a same-origin buyer route", async () => {
|
||||
const payload = {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
controller: { ok: true, phase: "active" },
|
||||
surplus: { earnings: { requests: 1_333 } },
|
||||
vector: { status: "retired" },
|
||||
};
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("accept")).toBe("application/json");
|
||||
expect(init?.redirect).toBe("error");
|
||||
return Response.json(payload);
|
||||
});
|
||||
const app = createApp({
|
||||
resaleDashboardUrl:
|
||||
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard/",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(payload);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails without exposing an upstream error body", async () => {
|
||||
const app = createApp({
|
||||
resaleDashboardUrl: "http://seller/internal/resale-dashboard",
|
||||
fetchImpl: async () => new Response("secret upstream detail", { status: 500 }),
|
||||
});
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body.error).toBe("dashboard_upstream_error");
|
||||
expect(JSON.stringify(response.body)).not.toContain("secret upstream detail");
|
||||
});
|
||||
|
||||
it("returns a bounded feature-disabled response when unconfigured", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const app = createApp({ fetchImpl });
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toBe("service_unavailable");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/ty
|
||||
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
|
||||
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
|
||||
import type { ProgressEvent } from "../../buyer/src/sdk/types.js";
|
||||
import { BOUNDED_INPUT_DETAIL_MARKER } from "../../packages/shared/src/tx/inputBound.js";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -473,6 +474,43 @@ describe("Marketplace.submitPrompt() — rejection paths", () => {
|
||||
expect(e.name).toBe("ReceiptVerificationError");
|
||||
expect(e.reason).toBe("test reason");
|
||||
});
|
||||
it("rejects a bounded-input request before posting escrow", async () => {
|
||||
const advert = makeActiveAdvert({
|
||||
detail_uri: `https://supplier.example.com/reseller${BOUNDED_INPUT_DETAIL_MARKER}`,
|
||||
});
|
||||
seedAdvertUtxo(chain, advert);
|
||||
const submitSpy = vi.spyOn(chain, "submitTx");
|
||||
fetchSpy.mockImplementation((url: unknown) => {
|
||||
if (String(url).endsWith("/capability")) {
|
||||
return jsonFetch({
|
||||
capability_id: advert.capability_id,
|
||||
model: advert.model,
|
||||
max_output_tokens: advert.max_output_tokens,
|
||||
max_processing_ms: advert.max_processing_ms,
|
||||
max_input_tokens: 1,
|
||||
price_lovelace: advert.price_lovelace.toString(),
|
||||
advert_ref: `${ADVERT_REF.txHash}#${ADVERT_REF.index}`,
|
||||
supplier_pkh: advert.supplier_pkh,
|
||||
pub_key_hex: supplier.pubKeyHex,
|
||||
});
|
||||
}
|
||||
return jsonFetch({});
|
||||
});
|
||||
const mp = makeMarketplace(chain, fetchSpy);
|
||||
await expect(
|
||||
mp.submitPrompt({
|
||||
advertRef: ADVERT_REF,
|
||||
messages: SAMPLE_MESSAGES,
|
||||
payment_lovelace: PAYMENT,
|
||||
}),
|
||||
).rejects.toSatisfy(
|
||||
(error: unknown) =>
|
||||
error instanceof TxConstructionError &&
|
||||
error.reason === "input_cap_exceeded",
|
||||
);
|
||||
expect(submitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── M1-F-1: isSyncThrow dead-code removal (RED) ─────────────────────────────
|
||||
|
||||
@@ -87,6 +87,37 @@ describe("loadConfig(env)", () => {
|
||||
const env = { ...VALID_ENV, COOKIE_SECURE: "true" };
|
||||
expect(() => loadConfig(env)).toThrow(/COOKIE_SECURE/);
|
||||
});
|
||||
|
||||
it("defaults pdfEnabled to true when PDF_ENABLED is not provided", () => {
|
||||
expect(loadConfig(VALID_ENV).pdfEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('honours PDF_ENABLED="0" (summarizer kill switch)', () => {
|
||||
const config = loadConfig({ ...VALID_ENV, PDF_ENABLED: "0" });
|
||||
expect(config.pdfEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when PDF_ENABLED is not 0 or 1", () => {
|
||||
expect(() => loadConfig({ ...VALID_ENV, PDF_ENABLED: "false" })).toThrow(/PDF_ENABLED/);
|
||||
});
|
||||
|
||||
it("normalizes the private resale dashboard URL", () => {
|
||||
const config = loadConfig({
|
||||
...VALID_ENV,
|
||||
SURPLUS_DASHBOARD_URL:
|
||||
"http://surplus-seller:8080/internal/resale-dashboard/",
|
||||
});
|
||||
expect(config.resaleDashboardUrl).toBe(
|
||||
"http://surplus-seller:8080/internal/resale-dashboard",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a resale dashboard URL with an unsupported protocol", () => {
|
||||
expect(() => loadConfig({
|
||||
...VALID_ENV,
|
||||
SURPLUS_DASHBOARD_URL: "file:///tmp/dashboard.json",
|
||||
})).toThrow(/SURPLUS_DASHBOARD_URL/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createApp tests ──────────────────────────────────────────────────────────
|
||||
|
||||
155
tests/unit/buyer-ui-resale-dashboard.test.tsx
Normal file
155
tests/unit/buyer-ui-resale-dashboard.test.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import App from "../../buyer/src/ui/App.js";
|
||||
import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js";
|
||||
|
||||
const DASHBOARD = {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
controller: { ok: true, phase: "active", lastError: null },
|
||||
identity: {
|
||||
sellerWallet: "0x1111111111111111111111111111111111111111",
|
||||
payoutAddress: "0x1111111111111111111111111111111111111111",
|
||||
},
|
||||
provider: {
|
||||
hardLimitUsd: "20.000000",
|
||||
remainingUsd: "15.157733",
|
||||
usedUsd: "4.842267",
|
||||
reserveUsd: "1.00",
|
||||
estimatedDailyExposureUsd: "1.020408",
|
||||
},
|
||||
surplus: {
|
||||
offer: {
|
||||
id: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
providerModel: "deepseek/deepseek-v4-flash",
|
||||
status: "active",
|
||||
dailyCapUsd: "0.050000",
|
||||
costMultiplier: 0.05065,
|
||||
inputUsdPer1m: "0.004027",
|
||||
outputUsdPer1m: "0.008055",
|
||||
rank: 11,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: 654,
|
||||
volume24hUsd: "0.112434",
|
||||
capRemainingUsd: "0.000000",
|
||||
},
|
||||
earnings: {
|
||||
totalUsd: "0.239903",
|
||||
pendingUsd: "0.219945",
|
||||
paidUsd: "0.019958",
|
||||
requests: 1_333,
|
||||
tokens: 138_612_442,
|
||||
today: { earnedUsd: 0.053093, requests: 240, totalTokens: 33_913_537 },
|
||||
topModel: "deepseek-v4-flash",
|
||||
payoutHoldReason: "new_seller",
|
||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||
},
|
||||
recentSales: [
|
||||
{
|
||||
id: "sale-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:00:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
revenueUsd: "0.000083",
|
||||
vectorProof: {
|
||||
status: "confirmed",
|
||||
saleHash: "1".repeat(64),
|
||||
batchRoot: "2".repeat(64),
|
||||
txHash: "d".repeat(64),
|
||||
leafIndex: 0,
|
||||
siblings: ["3".repeat(64)],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sale-2",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:01:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 20,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 6,
|
||||
revenueUsd: "0.000166",
|
||||
vectorProof: {
|
||||
status: "pending",
|
||||
saleHash: "4".repeat(64),
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sale-3",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:02:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 30,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 9,
|
||||
revenueUsd: "0.000249",
|
||||
vectorProof: {
|
||||
status: "failed",
|
||||
saleHash: "5".repeat(64),
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Capacity markets dashboard", () => {
|
||||
it("renders the authenticated route with a Vector proof state for every sale", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider initialStatus="authenticated">
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Capacity Markets" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Capacity" })).toHaveAttribute("href", "/dashboard");
|
||||
expect(screen.getByRole("heading", { name: "Surplus Intelligence" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Vector proof" })).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.2399")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resale-dashboard")).toHaveClass("max-w-7xl");
|
||||
expect(document.querySelectorAll(".bg-white").length).toBeGreaterThan(3);
|
||||
|
||||
const proofSummary = screen.getByRole("group", { name: "Vector proof summary" });
|
||||
expect(within(proofSummary).getByText("Confirmed 1")).toHaveClass("bg-green-100");
|
||||
expect(within(proofSummary).getByText("Pending 1")).toHaveClass("bg-amber-100");
|
||||
expect(within(proofSummary).getByText("Failed 1")).toHaveClass("bg-red-100");
|
||||
expect(screen.getByText("pending")).toHaveClass("bg-amber-100");
|
||||
expect(screen.getByText("failed")).toHaveClass("bg-red-100");
|
||||
|
||||
const proofLink = screen.getByRole("link", { name: /Confirmed Vector proof transaction/ });
|
||||
expect(proofLink).toHaveAttribute(
|
||||
"href",
|
||||
`https://vector.apexscan.org/en/transaction/${"d".repeat(64)}/summary/`,
|
||||
);
|
||||
expect(within(proofLink).getByText("dddddddddddd…ddddddddd")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/v1/resale-dashboard",
|
||||
{ cache: "no-store" },
|
||||
));
|
||||
});
|
||||
});
|
||||
132
tests/unit/gateway-account-locked.test.ts
Normal file
132
tests/unit/gateway-account-locked.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* gateway-account-locked.test.ts — /account locked_in_escrow_lovelace accuracy.
|
||||
*
|
||||
* The indexer never updates escrow rows on terminal spends (Accept/Reclaim/
|
||||
* Release consume the UTxO without producing a new escrow output), so rows
|
||||
* can sit in an "active" state long after the funds moved. The account
|
||||
* handler must verify each active row's UTxO is still unspent on-chain
|
||||
* before counting it as locked; a spent ref is a stale row, not locked funds.
|
||||
*
|
||||
* Note: supplier_bond stays in the sum on purpose — the buyer fronts
|
||||
* payment + buyer_bond + supplier_bond at PostEscrow (see postEscrow.ts
|
||||
* totalLocked), so all three legs are the key's money while the escrow lives.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
import { makeAccountHandler } from "../../gateway/src/account/routes.js";
|
||||
|
||||
const LIVE_REF = "a".repeat(64) + "#0";
|
||||
const STALE_REF = "b".repeat(64) + "#1";
|
||||
|
||||
function escrowRow(utxoRef: string, state: string) {
|
||||
return {
|
||||
utxo_ref: utxoRef,
|
||||
state,
|
||||
payment_lovelace: "2000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
supplier_bond_lovelace: "1000000",
|
||||
};
|
||||
}
|
||||
|
||||
function fakeKeyRow() {
|
||||
return {
|
||||
id: "key-1",
|
||||
key_hash: "h",
|
||||
key_prefix: "vk_live_abc",
|
||||
label: null,
|
||||
wallet_pkh: "c".repeat(56),
|
||||
deposit_address: "addr1_deposit",
|
||||
enc_priv_nonce: "",
|
||||
enc_priv_ct: "",
|
||||
enc_priv_tag: "",
|
||||
master_key_version: 1,
|
||||
created_at: 0,
|
||||
disabled: 0,
|
||||
demo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(rows: Array<Record<string, unknown>>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise<unknown> }) {
|
||||
const queryUtxo =
|
||||
opts?.queryUtxo ??
|
||||
(async (ref: { txHash: string; index: number }) => {
|
||||
const canonical = `${ref.txHash}#${ref.index}`;
|
||||
return canonical === LIVE_REF ? { ref, lovelace: 4_000_000n } : null;
|
||||
});
|
||||
return {
|
||||
config: { indexerUrl: "http://indexer.test", networkId: 1, masterKeyHex: "00" },
|
||||
store: {
|
||||
sumCostLovelace: () => 0n,
|
||||
countUsage: () => 0,
|
||||
listUsage: () => [],
|
||||
},
|
||||
chain: {
|
||||
queryUtxosByAddress: async () => [],
|
||||
queryUtxo: vi.fn(queryUtxo),
|
||||
},
|
||||
registry: {},
|
||||
fetchFn: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => rows,
|
||||
})),
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* asyncHandler returns a fire-and-forget (req, res) => void wrapper, so the
|
||||
* test must await the res.json() call itself, not the handler invocation.
|
||||
* Errors also surface through json() (sendError maps them to a JSON body).
|
||||
*/
|
||||
async function callAccount(deps: never): Promise<Record<string, never>> {
|
||||
const handler = makeAccountHandler(deps);
|
||||
const req = { gatewayKey: fakeKeyRow() } as unknown as Request;
|
||||
const body = await new Promise<unknown>((resolveBody, rejectBody) => {
|
||||
const timer = setTimeout(() => rejectBody(new Error("handler never responded")), 2_000);
|
||||
const res = {
|
||||
status: (_code: number) => res,
|
||||
json: (b: unknown) => {
|
||||
clearTimeout(timer);
|
||||
resolveBody(b);
|
||||
return res;
|
||||
},
|
||||
} as unknown as Response;
|
||||
handler(req, res);
|
||||
});
|
||||
if ((body as { error?: unknown }).error) {
|
||||
throw new Error(`handler returned error body: ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body as Record<string, never>;
|
||||
}
|
||||
|
||||
describe("GET /account — locked_in_escrow_lovelace", () => {
|
||||
it("counts payment + both bonds for an active escrow whose UTxO is live", async () => {
|
||||
const body = await callAccount(makeDeps([escrowRow(LIVE_REF, "Open")]));
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
|
||||
it("excludes an active-state row whose UTxO is terminally spent (stale indexer row)", async () => {
|
||||
const body = await callAccount(
|
||||
makeDeps([escrowRow(LIVE_REF, "Open"), escrowRow(STALE_REF, "Submitted")]),
|
||||
);
|
||||
// Only the live escrow counts: 4_000_000, not 8_000_000.
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
|
||||
it("skips terminal-state rows without a chain lookup", async () => {
|
||||
const deps = makeDeps([escrowRow(LIVE_REF, "Accepted"), escrowRow(STALE_REF, "Reclaimed")]);
|
||||
const body = await callAccount(deps);
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("0");
|
||||
expect((deps as { chain: { queryUtxo: ReturnType<typeof vi.fn> } }).chain.queryUtxo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts a row conservatively when the chain lookup fails (best-effort)", async () => {
|
||||
const deps = makeDeps([escrowRow(LIVE_REF, "Open")], {
|
||||
queryUtxo: async () => {
|
||||
throw new Error("ogmios unreachable");
|
||||
},
|
||||
});
|
||||
const body = await callAccount(deps);
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
});
|
||||
121
tests/unit/gateway-mutex.test.ts
Normal file
121
tests/unit/gateway-mutex.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* gateway-mutex.test.ts — Mutex deadline + queue bound (2026-08-12 freeze fix).
|
||||
*
|
||||
* Incident: one chain call that never settled, run under a per-key Mutex with
|
||||
* no deadline, convoyed every subsequent request on that key silently while
|
||||
* /models and /account stayed healthy. The fix gives run() a deadline that
|
||||
* abandons the stuck operation (advancing the chain) and a queue bound that
|
||||
* rejects new work instead of queueing without limit.
|
||||
*
|
||||
* Serialization semantics of the original class must be preserved.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
Mutex,
|
||||
MutexTimeoutError,
|
||||
MutexQueueFullError,
|
||||
} from "../../gateway/src/sdk/registry.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Preserved semantics ─────────────────────────────────────────────────────
|
||||
|
||||
describe("Mutex — serialization (preserved semantics)", () => {
|
||||
it("runs fns strictly one at a time, in order", async () => {
|
||||
const mutex = new Mutex();
|
||||
const order: number[] = [];
|
||||
let firstRunning = false;
|
||||
|
||||
const first = mutex.run(async () => {
|
||||
firstRunning = true;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
order.push(1);
|
||||
firstRunning = false;
|
||||
});
|
||||
const second = mutex.run(async () => {
|
||||
expect(firstRunning).toBe(false);
|
||||
order.push(2);
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("a rejecting fn does not poison the chain", async () => {
|
||||
const mutex = new Mutex();
|
||||
|
||||
await expect(mutex.run(async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
|
||||
await expect(mutex.run(async () => "alive")).resolves.toBe("alive");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Deadline (the freeze fix) ───────────────────────────────────────────────
|
||||
|
||||
describe("Mutex — per-run deadline", () => {
|
||||
it("rejects a stuck run with MutexTimeoutError once the deadline passes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mutex = new Mutex({ timeoutMs: 1_000 });
|
||||
|
||||
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck-chain-read");
|
||||
// Attach the rejection expectation BEFORE advancing time so the rejection
|
||||
// is not treated as unhandled.
|
||||
const assertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("queued work proceeds after a stuck run is abandoned (no convoy)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mutex = new Mutex({ timeoutMs: 1_000 });
|
||||
|
||||
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck");
|
||||
const stuckAssertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
|
||||
const queued = mutex.run(async () => "unblocked");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
await stuckAssertion;
|
||||
await expect(queued).resolves.toBe("unblocked");
|
||||
});
|
||||
|
||||
it("a run that finishes in time is unaffected by the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mutex = new Mutex({ timeoutMs: 1_000 });
|
||||
|
||||
const quick = mutex.run(async () => "done");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(quick).resolves.toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Queue bound ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Mutex — queue bound", () => {
|
||||
it("rejects immediately with MutexQueueFullError when the queue is full", async () => {
|
||||
const mutex = new Mutex({ maxQueue: 2 });
|
||||
let releaseFirst!: () => void;
|
||||
const gate = new Promise<void>((r) => {
|
||||
releaseFirst = r;
|
||||
});
|
||||
|
||||
const first = mutex.run(() => gate.then(() => "first"));
|
||||
const second = mutex.run(async () => "second");
|
||||
const third = mutex.run(async () => "third");
|
||||
|
||||
await expect(third).rejects.toBeInstanceOf(MutexQueueFullError);
|
||||
|
||||
releaseFirst();
|
||||
await expect(first).resolves.toBe("first");
|
||||
await expect(second).resolves.toBe("second");
|
||||
});
|
||||
|
||||
it("slots free up once runs settle", async () => {
|
||||
const mutex = new Mutex({ maxQueue: 1 });
|
||||
|
||||
await mutex.run(async () => "a");
|
||||
await expect(mutex.run(async () => "b")).resolves.toBe("b");
|
||||
});
|
||||
});
|
||||
151
tests/unit/reseller-capacity.test.ts
Normal file
151
tests/unit/reseller-capacity.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatInputTokenUpperBound } from "../../packages/shared/src/tx/inputBound.js";
|
||||
import { CapacityGate } from "../../supplier/src/reseller/capacityGate.js";
|
||||
import type {
|
||||
CapacityProvider,
|
||||
ProviderCapacityReading,
|
||||
} from "../../supplier/src/reseller/provider.js";
|
||||
import { OpenRouterCapacityProvider } from "../../supplier/src/reseller/openRouterCapacity.js";
|
||||
|
||||
function reading(remaining: bigint): ProviderCapacityReading {
|
||||
return {
|
||||
provider: "openrouter",
|
||||
keyLimitUsdNanos: 10_000_000_000n,
|
||||
keyRemainingUsdNanos: remaining,
|
||||
limitReset: "monthly",
|
||||
checkedAtMs: 1_000,
|
||||
pricing: {
|
||||
model: "provider/model",
|
||||
promptUsdNanosPerToken: 1_000_000n,
|
||||
completionUsdNanosPerToken: 2_000_000n,
|
||||
requestUsdNanos: 0n,
|
||||
fetchedAtMs: 1_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("chatInputTokenUpperBound", () => {
|
||||
it("uses canonical UTF-8 bytes and fixed chat overhead", () => {
|
||||
expect(chatInputTokenUpperBound([])).toBe(5);
|
||||
expect(chatInputTokenUpperBound([{ role: "user", content: "a" }])).toBe(42);
|
||||
expect(chatInputTokenUpperBound([{ role: "user", content: "é" }])).toBe(43);
|
||||
expect(chatInputTokenUpperBound([{ role: "user", content: "🙂" }])).toBe(45);
|
||||
});
|
||||
|
||||
it("adds each message independently", () => {
|
||||
expect(chatInputTokenUpperBound([
|
||||
{ role: "system", content: "x" },
|
||||
{ role: "user", content: "yz" },
|
||||
])).toBe(83);
|
||||
});
|
||||
|
||||
it("counts tool-call arguments present on direct SDK messages", () => {
|
||||
expect(chatInputTokenUpperBound([{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{
|
||||
id: "x",
|
||||
type: "function",
|
||||
function: { name: "f", arguments: "a".repeat(1000) },
|
||||
}],
|
||||
}])).toBe(1129);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CapacityGate", () => {
|
||||
it("opens only when allowance above reserve covers a worst-case job", async () => {
|
||||
let current = reading(2_000_000_000n);
|
||||
const provider: CapacityProvider = {
|
||||
id: "openrouter",
|
||||
readCapacity: vi.fn(async () => current),
|
||||
};
|
||||
const gate = new CapacityGate({
|
||||
provider,
|
||||
model: "provider/model",
|
||||
reserveUsd: "1",
|
||||
maxInputTokens: 100,
|
||||
maxOutputTokens: 10,
|
||||
staleAfterMs: 5_000,
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
const open = await gate.refresh();
|
||||
expect(open.canServe).toBe(true);
|
||||
expect(open.worstCaseJobUsdNanos).toBe(120_000_000n);
|
||||
expect(open.sellableUsdNanos).toBe(1_000_000_000n);
|
||||
expect(open.availableJobs).toBe(8n);
|
||||
|
||||
current = reading(1_100_000_000n);
|
||||
const closed = await gate.refresh();
|
||||
expect(closed.canServe).toBe(false);
|
||||
expect(closed.reason).toBe("insufficient_for_job");
|
||||
});
|
||||
|
||||
it("fails closed when the provider check fails", async () => {
|
||||
const provider: CapacityProvider = {
|
||||
id: "openrouter",
|
||||
readCapacity: vi.fn(async () => {
|
||||
throw new Error("provider down");
|
||||
}),
|
||||
};
|
||||
const gate = new CapacityGate({
|
||||
provider,
|
||||
model: "provider/model",
|
||||
reserveUsd: "0",
|
||||
maxInputTokens: 10,
|
||||
maxOutputTokens: 10,
|
||||
staleAfterMs: 5_000,
|
||||
});
|
||||
const snapshot = await gate.refresh();
|
||||
expect(snapshot.canServe).toBe(false);
|
||||
expect(snapshot.reason).toBe("provider_unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenRouterCapacityProvider", () => {
|
||||
it("reads a capped key and rounds model prices up", async () => {
|
||||
const fetchFn = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/v1/key")) {
|
||||
return new Response(JSON.stringify({
|
||||
data: { limit: 5, limit_remaining: 4.5, limit_reset: "monthly" },
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
data: [{
|
||||
id: "provider/model",
|
||||
pricing: { prompt: "0.00000007952", completion: "0.00000015904" },
|
||||
}],
|
||||
}), { status: 200 });
|
||||
});
|
||||
const provider = new OpenRouterCapacityProvider({
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
apiKey: "secret",
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
now: () => 123,
|
||||
});
|
||||
const result = await provider.readCapacity("provider/model");
|
||||
expect(result.keyRemainingUsdNanos).toBe(4_499_999_999n);
|
||||
expect(result.pricing.promptUsdNanosPerToken).toBe(80n);
|
||||
expect(result.pricing.completionUsdNanosPerToken).toBe(160n);
|
||||
});
|
||||
|
||||
it("rejects an uncapped inference key", async () => {
|
||||
const fetchFn = vi.fn(async (input: string | URL | Request) => {
|
||||
if (String(input).endsWith("/v1/key")) {
|
||||
return new Response(JSON.stringify({
|
||||
data: { limit: null, limit_remaining: null, limit_reset: null },
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
});
|
||||
const provider = new OpenRouterCapacityProvider({
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
apiKey: "secret",
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
});
|
||||
await expect(provider.readCapacity("provider/model")).rejects.toThrow(/finite/);
|
||||
});
|
||||
});
|
||||
38
tests/unit/reseller-redaction.test.ts
Normal file
38
tests/unit/reseller-redaction.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactPublicPreview } from "../../supplier/src/reseller/evidenceStore.js";
|
||||
|
||||
describe("redactPublicPreview", () => {
|
||||
it("removes common credentials and personal data before publication", () => {
|
||||
const preview = redactPublicPreview(
|
||||
[
|
||||
"SUPPLIER_PRIV_KEY_HEX=" + "a".repeat(64),
|
||||
"password=hunter2",
|
||||
"Authorization: Bearer secret-token",
|
||||
'{"password":"hunter two words"}',
|
||||
"GATEWAY_API_KEY=plain-secret-value",
|
||||
"Authorization: Basic dXNlcjpwYXNz",
|
||||
"email test@example.com",
|
||||
"https://example.com/private",
|
||||
"eyJheader.payload.signature",
|
||||
"ghp_1234567890abcdefghijklmnop",
|
||||
].join(" "),
|
||||
1000,
|
||||
);
|
||||
expect(preview).not.toContain("hunter2");
|
||||
expect(preview).not.toContain("secret-token");
|
||||
expect(preview).not.toContain("hunter two words");
|
||||
expect(preview).not.toContain("plain-secret-value");
|
||||
expect(preview).not.toContain("dXNlcjpwYXNz");
|
||||
expect(preview).not.toContain("test@example.com");
|
||||
expect(preview).not.toContain("example.com/private");
|
||||
expect(preview).not.toContain("eyJheader");
|
||||
expect(preview).not.toContain("ghp_");
|
||||
expect(preview).not.toContain("a".repeat(64));
|
||||
});
|
||||
|
||||
it("applies the configured public preview limit", () => {
|
||||
const preview = redactPublicPreview("x".repeat(100), 20);
|
||||
expect(preview).toHaveLength(20);
|
||||
expect(preview.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
68
tests/unit/reseller-setup-config.test.ts
Normal file
68
tests/unit/reseller-setup-config.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadResellerSetupConfig } from "../../supplier/src/reseller/setupConfig.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function configPath(overrides: Record<string, unknown> = {}): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "reseller-config-"));
|
||||
tempDirs.push(dir);
|
||||
const path = join(dir, "config.json");
|
||||
await writeFile(path, JSON.stringify({
|
||||
networkId: 1,
|
||||
ogmiosUrl: "https://ogmios.vector.mainnet.apexfusion.org",
|
||||
endpointUrl: "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
|
||||
marketplaceModel: "vector/resold-model",
|
||||
openRouterModel: "provider/model",
|
||||
maxInputTokens: 4096,
|
||||
maxOutputTokens: 256,
|
||||
maxProcessingMs: 600000,
|
||||
inferenceTimeoutMs: 180000,
|
||||
priceLovelace: "200000",
|
||||
supplierBondLovelace: "1000000",
|
||||
buyerBondLovelace: "1000000",
|
||||
reserveUsd: "1.00",
|
||||
composeFile: "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml",
|
||||
...overrides,
|
||||
}));
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true })));
|
||||
});
|
||||
|
||||
describe("loadResellerSetupConfig", () => {
|
||||
it("accepts a bounded mainnet setup", async () => {
|
||||
const config = await loadResellerSetupConfig(await configPath());
|
||||
expect(config.inferenceTimeoutMs).toBe(180000);
|
||||
expect(config.endpointUrl).toBe(
|
||||
"https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported Ogmios transports", async () => {
|
||||
await expect(loadResellerSetupConfig(await configPath({
|
||||
ogmiosUrl: "wss://ogmios.example",
|
||||
}))).rejects.toThrow(/ogmiosUrl/);
|
||||
});
|
||||
|
||||
it("rejects endpoint credentials, query data, and fragments", async () => {
|
||||
for (const endpointUrl of [
|
||||
"https://user:secret@example.com",
|
||||
"https://example.com?token=secret",
|
||||
"https://example.com#secret",
|
||||
]) {
|
||||
await expect(loadResellerSetupConfig(await configPath({ endpointUrl })))
|
||||
.rejects.toThrow(/plain HTTPS origin/);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires settlement time outside the inference timeout", async () => {
|
||||
await expect(loadResellerSetupConfig(await configPath({
|
||||
inferenceTimeoutMs: 500000,
|
||||
}))).rejects.toThrow(/does not leave time/);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ function chatSessionConfig(): SupplierConfig {
|
||||
} as SupplierConfig;
|
||||
}
|
||||
|
||||
function makeAppWithSession() {
|
||||
function makeAppWithSession(configOverrides?: Partial<SupplierConfig>) {
|
||||
const chatSessions = new ChatSessionStore();
|
||||
const record = chatSessions.create({
|
||||
escrowRef: ESCROW_REF,
|
||||
@@ -48,7 +48,7 @@ function makeAppWithSession() {
|
||||
const app = createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: chatSessionConfig(),
|
||||
config: { ...chatSessionConfig(), ...configOverrides } as SupplierConfig,
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
chatSessions,
|
||||
@@ -91,6 +91,54 @@ describe("ChatSessionStore.truncateTranscript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/chat/message — stateful upstream mode (OPENAI_SESSION_PASSTHROUGH)", () => {
|
||||
it("sends delta-only messages + user=escrowRef + overridden model; transcript still accumulates fully", async () => {
|
||||
const { app, record } = makeAppWithSession({
|
||||
openaiSessionPassthrough: true,
|
||||
openaiModelOverride: "openclaw",
|
||||
});
|
||||
const okFetch = () =>
|
||||
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
|
||||
|
||||
const fetch1 = okFetch();
|
||||
vi.stubGlobal("fetch", fetch1);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
|
||||
|
||||
const fetch2 = okFetch();
|
||||
vi.stubGlobal("fetch", fetch2);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "second" });
|
||||
|
||||
// Turn 2 upstream call: only the new delta, keyed to the escrow ref, with
|
||||
// the fixed upstream model — NOT the accumulated transcript / advert model.
|
||||
const body2 = JSON.parse(fetch2.mock.calls[0][1].body as string);
|
||||
expect(body2.messages).toEqual([{ role: "user", content: "second" }]);
|
||||
expect(body2.user).toBe(ESCROW_REF);
|
||||
expect(body2.model).toBe("openclaw");
|
||||
|
||||
// The local transcript (receipt source of truth) still has every turn.
|
||||
expect(record.transcript).toEqual([
|
||||
{ role: "user", content: "first" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
{ role: "user", content: "second" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps full-transcript + advert-model behavior when passthrough is off", async () => {
|
||||
const { app } = makeAppWithSession();
|
||||
const fetch1 = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
|
||||
vi.stubGlobal("fetch", fetch1);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
|
||||
|
||||
const body = JSON.parse(fetch1.mock.calls[0][1].body as string);
|
||||
expect(body.messages).toEqual([{ role: "user", content: "first" }]);
|
||||
expect("user" in body).toBe(false);
|
||||
expect(body.model).toBe("kimi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/chat/message — failed-turn rollback", () => {
|
||||
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
|
||||
const { app, record } = makeAppWithSession();
|
||||
|
||||
@@ -294,3 +294,99 @@ describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
|
||||
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig — OPENAI_SESSION_PASSTHROUGH ─────────────────────────────────
|
||||
|
||||
describe("loadConfig() — OPENAI_SESSION_PASSTHROUGH", () => {
|
||||
const chatSessionEnv = () => ({
|
||||
...buildSampleEnv(),
|
||||
CAPABILITY_KIND: "chat-session",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "http://up",
|
||||
});
|
||||
|
||||
it("defaults off", () => {
|
||||
expect(loadConfig(chatSessionEnv()).openaiSessionPassthrough).toBe(false);
|
||||
});
|
||||
|
||||
it('parses "1" for chat-session kind', () => {
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "1" });
|
||||
expect(cfg.openaiSessionPassthrough).toBe(true);
|
||||
});
|
||||
|
||||
it('treats values other than "1" as off (mirrors LIVE_CHAIN strictness)', () => {
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "true" });
|
||||
expect(cfg.openaiSessionPassthrough).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects it for non-chat-session kinds", () => {
|
||||
expect(() => loadConfig({ ...buildSampleEnv(), OPENAI_SESSION_PASSTHROUGH: "1" })).toThrow(
|
||||
/chat-session/,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses OPENAI_MODEL_OVERRIDE (default empty = advert model verbatim)", () => {
|
||||
expect(loadConfig(chatSessionEnv()).openaiModelOverride).toBe("");
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_MODEL_OVERRIDE: "openclaw" });
|
||||
expect(cfg.openaiModelOverride).toBe("openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig() — OpenRouter reseller", () => {
|
||||
const resellerEnv = () => ({
|
||||
...buildSampleEnv(),
|
||||
CAPABILITY_KIND: "chat",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "https://openrouter.ai/api",
|
||||
OPENAI_API_KEY: "seller-only-key",
|
||||
OPENAI_MAX_TOKENS: "256",
|
||||
RESELLER_PROVIDER: "openrouter",
|
||||
RESELLER_DATABASE_URL: "postgresql://reseller:secret@postgres:5432/reseller",
|
||||
RESELLER_RESERVE_USD: "1.25",
|
||||
RESELLER_MAX_INPUT_TOKENS: "4096",
|
||||
});
|
||||
|
||||
it("stays disabled for every legacy config", () => {
|
||||
expect(loadConfig(buildSampleEnv()).reseller).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a complete balance-aware config", () => {
|
||||
const cfg = loadConfig(resellerEnv());
|
||||
expect(cfg.reseller).toMatchObject({
|
||||
provider: "openrouter",
|
||||
reserveUsd: "1.25",
|
||||
maxInputTokens: 4096,
|
||||
pollIntervalMs: 30_000,
|
||||
providerTimeoutMs: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects partial and unsafe configurations", () => {
|
||||
expect(() => loadConfig({
|
||||
...buildSampleEnv(),
|
||||
RESELLER_RESERVE_USD: "1",
|
||||
})).toThrow(/RESELLER_PROVIDER/);
|
||||
expect(() => loadConfig({
|
||||
...resellerEnv(),
|
||||
OPENAI_BASE_URL: "https://example.com",
|
||||
})).toThrow(/OPENAI_BASE_URL/);
|
||||
for (const OPENAI_BASE_URL of [
|
||||
"http://openrouter.ai/api",
|
||||
"https://openrouter.ai/v1",
|
||||
"https://user:secret@openrouter.ai/api",
|
||||
]) {
|
||||
expect(() => loadConfig({
|
||||
...resellerEnv(),
|
||||
OPENAI_BASE_URL,
|
||||
})).toThrow(/OPENAI_BASE_URL/);
|
||||
}
|
||||
expect(() => loadConfig({
|
||||
...resellerEnv(),
|
||||
OPENAI_MAX_TOKENS: "",
|
||||
})).toThrow(/OPENAI_MAX_TOKENS/);
|
||||
expect(() => loadConfig({
|
||||
...resellerEnv(),
|
||||
RESELLER_RESERVE_USD: "-1",
|
||||
})).toThrow(/RESELLER_RESERVE_USD/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,22 @@ describe("callOpenAiStream — tools passthrough", () => {
|
||||
expect("tool_choice" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("includes user in the payload when provided (session passthrough)", async () => {
|
||||
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||
vi.stubGlobal("fetch", fetchFn);
|
||||
await callOpenAiStream({ ...BASE, user: `${"f".repeat(64)}#0` }, () => {});
|
||||
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||
expect(body.user).toBe(`${"f".repeat(64)}#0`);
|
||||
});
|
||||
|
||||
it("omits user from the payload when not provided", async () => {
|
||||
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||
vi.stubGlobal("fetch", fetchFn);
|
||||
await callOpenAiStream(BASE, () => {});
|
||||
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||
expect("user" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("accumulates fragmented delta.tool_calls across frames", async () => {
|
||||
const frames = sse([
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },
|
||||
|
||||
@@ -103,6 +103,20 @@ describe("callOpenAi() — request shape", () => {
|
||||
expect(body.stream).toBe(false);
|
||||
});
|
||||
|
||||
it("includes user in the payload when provided (session passthrough)", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS, user: `${"f".repeat(64)}#0` });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.user).toBe(`${"f".repeat(64)}#0`);
|
||||
});
|
||||
|
||||
it("omits user from the payload when not provided", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect("user" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("omits authorization header when apiKey is not provided (ChatMock path)", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
|
||||
78
tests/unit/supplier-reseller-routes.test.ts
Normal file
78
tests/unit/supplier-reseller-routes.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import type { ResellerRuntime } from "../../supplier/src/reseller/runtime.js";
|
||||
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const ESCROW_REF = `${"f".repeat(64)}#0`;
|
||||
|
||||
function fakeReseller(canServe: boolean, maxInputTokens = 100): ResellerRuntime {
|
||||
const snapshot = {
|
||||
provider: "openrouter",
|
||||
model: "provider/model",
|
||||
canServe,
|
||||
reason: canServe ? "ready" as const : "insufficient_for_job" as const,
|
||||
checkedAtMs: 1_000,
|
||||
lastSuccessAtMs: 1_000,
|
||||
keyLimitUsdNanos: 5_000_000_000n,
|
||||
remainingAllowanceUsdNanos: 2_000_000_000n,
|
||||
reserveUsdNanos: 1_000_000_000n,
|
||||
sellableUsdNanos: 1_000_000_000n,
|
||||
worstCaseJobUsdNanos: 100_000_000n,
|
||||
promptPriceUsdNanosPerToken: 1n,
|
||||
completionPriceUsdNanosPerToken: 1n,
|
||||
requestPriceUsdNanos: 0n,
|
||||
availableJobs: 10n,
|
||||
limitReset: "monthly",
|
||||
error: null,
|
||||
};
|
||||
return {
|
||||
maxInputTokens,
|
||||
effectiveStatus: vi.fn(() => canServe ? "free" : "offline"),
|
||||
capacitySnapshot: vi.fn(() => snapshot),
|
||||
refreshCapacity: vi.fn(async () => snapshot),
|
||||
} as unknown as ResellerRuntime;
|
||||
}
|
||||
|
||||
describe("reseller supplier routes", () => {
|
||||
it("reports offline without retiring an exhausted supplier", async () => {
|
||||
const app = createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: buildSampleConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
reseller: fakeReseller(false),
|
||||
});
|
||||
const response = await request(app).get("/status");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
status: "offline",
|
||||
capacity: {
|
||||
provider: "openrouter",
|
||||
reason: "insufficient_for_job",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized input before any chain submission", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const submitSpy = vi.spyOn(chain, "submitTx");
|
||||
const app = createApp({
|
||||
chain,
|
||||
state: new SupplierState(),
|
||||
config: buildSampleConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
reseller: fakeReseller(true, 1),
|
||||
});
|
||||
const response = await request(app)
|
||||
.post("/v1/chat/completions")
|
||||
.set("X-Escrow-Ref", ESCROW_REF)
|
||||
.send({ messages: [{ role: "user", content: "too large" }] });
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body.reason).toBe("input_cap_exceeded");
|
||||
expect(submitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
466
tests/unit/surplus-client.test.ts
Normal file
466
tests/unit/surplus-client.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SurplusClient } from "../../supplier/src/surplus/client.js";
|
||||
|
||||
const SELLER_KEY = "si_seller_test_only";
|
||||
const PROVIDER_KEY = "sk-or-v1-test-only";
|
||||
|
||||
function ndjsonResponse(chunks: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
});
|
||||
}
|
||||
|
||||
function discoveryRow(model: string, providerModelId: string): string {
|
||||
return JSON.stringify({
|
||||
model,
|
||||
supported: true,
|
||||
pricing: {
|
||||
input_per_1m: 0.07952,
|
||||
output_per_1m: 0.15904,
|
||||
price_unit: "M",
|
||||
price_variable: false,
|
||||
},
|
||||
metadata: {
|
||||
provider_model_id: providerModelId,
|
||||
model_type: "text",
|
||||
availability_status: "available",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function saleRow(
|
||||
id: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
model: "alpha",
|
||||
offer_id: "offer-1",
|
||||
settlement_status: "confirmed",
|
||||
created_at: 1_787_831_146_968,
|
||||
seller_cost_usdc: "83",
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 4,
|
||||
effective_input_per_1m: 0.004,
|
||||
effective_output_per_1m: 0.008,
|
||||
tx_hash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function earningsEnvelope(
|
||||
recentSales: Record<string, unknown>[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
total_earned_usdc: "166",
|
||||
pending_usdc: "166",
|
||||
paid_usdc: "0",
|
||||
daily: [],
|
||||
by_model: [],
|
||||
share: { requests: recentSales.length, tokens: 0, top_model: "alpha" },
|
||||
recent_sales: recentSales,
|
||||
payout_hold: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurplusClient", () => {
|
||||
it("authenticates and parses fragmented NDJSON discovery", async () => {
|
||||
const first = discoveryRow("alpha", "vendor/alpha");
|
||||
const second = discoveryRow("beta", "vendor/beta");
|
||||
const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("authorization")).toBe(`Bearer ${SELLER_KEY}`);
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
api_key: PROVIDER_KEY,
|
||||
base_url: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
return ndjsonResponse([
|
||||
first.slice(0, 25),
|
||||
`${first.slice(25)}\r\n\r\n${second.slice(0, 11)}`,
|
||||
second.slice(11),
|
||||
]);
|
||||
});
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const result = await client.discoverModels(
|
||||
PROVIDER_KEY,
|
||||
"https://openrouter.ai/api/v1",
|
||||
);
|
||||
|
||||
expect(result.map((model) => [model.model, model.providerModelId])).toEqual([
|
||||
["alpha", "vendor/alpha"],
|
||||
["beta", "vendor/beta"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes unsupported discovery rows with null pricing as ineligible", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => ndjsonResponse([JSON.stringify({
|
||||
model: "unsupported",
|
||||
supported: false,
|
||||
pricing: null,
|
||||
metadata: {
|
||||
provider_model_id: "vendor/unsupported",
|
||||
model_type: null,
|
||||
availability_status: null,
|
||||
},
|
||||
})]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.discoverModels(PROVIDER_KEY, "https://openrouter.ai/api/v1"),
|
||||
).resolves.toEqual([expect.objectContaining({
|
||||
model: "unsupported",
|
||||
inputUsdPer1m: 0,
|
||||
outputUsdPer1m: 0,
|
||||
priceUnit: "unknown",
|
||||
modelType: "unknown",
|
||||
availabilityStatus: "unknown",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("rejects one malformed discovery line without returning a partial catalog", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => ndjsonResponse([
|
||||
`${discoveryRow("alpha", "vendor/alpha")}\n`,
|
||||
'{"model":"truncated"',
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(client.discoverModels(PROVIDER_KEY, "https://openrouter.ai/api/v1"))
|
||||
.rejects.toThrow("line 2 was not complete JSON");
|
||||
});
|
||||
|
||||
it("follows every seller-offer page and normalizes micro-USD prices", async () => {
|
||||
const requested: string[] = [];
|
||||
const fetchFn = vi.fn(async (url: string | URL | Request) => {
|
||||
const value = String(url);
|
||||
requested.push(value);
|
||||
if (value.includes("next_token=page-2")) {
|
||||
return Response.json({
|
||||
items: [{
|
||||
offer_id: "offer-2",
|
||||
model: "beta",
|
||||
seller_base_url: "https://openrouter.ai/api/v1",
|
||||
status: "inactive",
|
||||
cap_daily_usd: 1,
|
||||
price_input_per_1m: 4_100,
|
||||
price_output_per_1m: 8_200,
|
||||
}],
|
||||
next_token: null,
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
items: [{
|
||||
id: "offer-1",
|
||||
model: "alpha",
|
||||
seller_base_url: "https://openrouter.ai/api/v1",
|
||||
status: "active",
|
||||
cap_daily_usd: 1,
|
||||
price_input_per_1m: 3_972,
|
||||
price_output_per_1m: 7_944,
|
||||
}],
|
||||
next_token: "page-2",
|
||||
});
|
||||
});
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const result = await client.listAllOffers();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "offer-1",
|
||||
status: "active",
|
||||
inputMicroUsdPer1m: 3_972,
|
||||
outputMicroUsdPer1m: 7_944,
|
||||
});
|
||||
expect(requested[1]).toContain("next_token=page-2");
|
||||
});
|
||||
|
||||
it("fails closed when offer pagination repeats a token", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => Response.json({ items: [], next_token: "same" }),
|
||||
});
|
||||
|
||||
await expect(client.listAllOffers()).rejects.toThrow("repeated a next_token");
|
||||
});
|
||||
|
||||
it("normalizes live market envelopes and effective micro-USD fields", async () => {
|
||||
const fetchFn = vi.fn(async (url: string | URL | Request) => {
|
||||
if (String(url).endsWith("/api/markets")) {
|
||||
return Response.json({ markets: [{
|
||||
model: "alpha:model",
|
||||
requests_24h: 100,
|
||||
volume_24h: 1_000,
|
||||
best_input_per_1m: 3_976,
|
||||
best_output_per_1m: 7_952,
|
||||
healthy_seller_count: 2,
|
||||
}] });
|
||||
}
|
||||
return Response.json({
|
||||
model: "alpha:model",
|
||||
offers: [{
|
||||
id: "external",
|
||||
seller: "0x2222222222222222222222222222222222222222",
|
||||
seller_base_url: "https://openrouter.ai/api/v1",
|
||||
effective_input_per_1m: 3_976,
|
||||
effective_output_per_1m: 7_952,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades_24h: 7,
|
||||
rank: 1,
|
||||
cap_remaining: null,
|
||||
volume_24h: 0,
|
||||
}],
|
||||
});
|
||||
});
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(await client.getMarkets()).toEqual([expect.objectContaining({
|
||||
model: "alpha:model",
|
||||
bestInputMicroUsdPer1m: 3_976,
|
||||
})]);
|
||||
expect(await client.getOrderBook("alpha:model")).toEqual({
|
||||
model: "alpha:model",
|
||||
offers: [expect.objectContaining({ id: "external", trades24h: 7 })],
|
||||
});
|
||||
expect(String(fetchFn.mock.calls[1][0])).toContain("alpha%3Amodel");
|
||||
});
|
||||
|
||||
it("normalizes live earnings, payout hold, and settlement records", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => Response.json({
|
||||
total_earned_usdc: "186810",
|
||||
pending_usdc: "176817",
|
||||
paid_usdc: "9993",
|
||||
daily: [{
|
||||
day: "2026-08-27",
|
||||
earned_usd: 0.18681,
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
requests: 3,
|
||||
total_tokens: 120,
|
||||
}],
|
||||
by_model: [{
|
||||
model: "alpha",
|
||||
earned_usd: 0.18681,
|
||||
requests: 3,
|
||||
total_tokens: 120,
|
||||
last_sale_at: 1787831146968,
|
||||
}],
|
||||
share: {
|
||||
requests: 3,
|
||||
tokens: 120,
|
||||
top_model: "alpha",
|
||||
},
|
||||
recent_sales: [{
|
||||
id: "sale-1",
|
||||
model: "alpha",
|
||||
offer_id: "offer-1",
|
||||
settlement_status: "confirmed",
|
||||
created_at: 1787831146968,
|
||||
seller_cost_usdc: "83",
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 4,
|
||||
effective_input_per_1m: 0.004,
|
||||
effective_output_per_1m: 0.008,
|
||||
tx_hash: "0x" + "a".repeat(64),
|
||||
}],
|
||||
payout_hold: {
|
||||
reason: "new_seller",
|
||||
releases_at: "2026-08-29T11:24:42.552Z",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const earnings = await client.getEarnings();
|
||||
|
||||
expect(earnings).toMatchObject({
|
||||
totalEarnedMicroUsd: 186_810,
|
||||
pendingMicroUsd: 176_817,
|
||||
paidMicroUsd: 9_993,
|
||||
requestCount: 3,
|
||||
tokenCount: 120,
|
||||
topModel: "alpha",
|
||||
payoutHoldReason: "new_seller",
|
||||
});
|
||||
expect(earnings.daily[0]).toMatchObject({ requests: 3, totalTokens: 120 });
|
||||
expect(earnings.recentSales[0]).toMatchObject({
|
||||
id: "sale-1",
|
||||
settlementStatus: "confirmed",
|
||||
cacheReadTokens: 4,
|
||||
sellerCostMicroUsd: 83,
|
||||
});
|
||||
expect(earnings.recentSales[0].createdAt).toBe(
|
||||
new Date(1787831146968).toISOString(),
|
||||
);
|
||||
expect(earnings.recentSales[0].createdAtMs).toBe(1787831146968);
|
||||
});
|
||||
it("defaults omitted cache_read_tokens to zero as the live API sends", async () => {
|
||||
const sparse = saleRow("sale-sparse");
|
||||
delete sparse.cache_read_tokens;
|
||||
delete sparse.tx_hash;
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: "si_seller_test",
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => Response.json(earningsEnvelope([sparse])),
|
||||
});
|
||||
|
||||
const earnings = await client.getEarnings();
|
||||
|
||||
expect(earnings.recentSales[0]).toMatchObject({
|
||||
id: "sale-sparse",
|
||||
cacheReadTokens: 0,
|
||||
transactionHash: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the deduplicated observed sale window in deterministic sale-id order", async () => {
|
||||
const duplicate = saleRow("sale-a", {
|
||||
buyer_address: "must-not-be-retained",
|
||||
});
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () =>
|
||||
Response.json(earningsEnvelope([
|
||||
saleRow("sale-b"),
|
||||
duplicate,
|
||||
{ ...duplicate },
|
||||
])),
|
||||
});
|
||||
|
||||
const sales = await client.listObservedSales();
|
||||
|
||||
expect(sales.map((sale) => sale.id)).toEqual(["sale-a", "sale-b"]);
|
||||
expect(sales[0]).not.toHaveProperty("buyerAddress");
|
||||
expect(sales[0]).not.toHaveProperty("buyer_address");
|
||||
});
|
||||
|
||||
it("fails closed on conflicting immutable fields in the observed sale window", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () =>
|
||||
Response.json(earningsEnvelope([
|
||||
saleRow("sale-a"),
|
||||
saleRow("sale-a", { input_tokens: 11 }),
|
||||
])),
|
||||
});
|
||||
|
||||
await expect(client.listObservedSales()).rejects.toThrow(
|
||||
"conflicting immutable fields for sale sale-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires every immutable proof field in observed sales", async () => {
|
||||
const requiredFields = [
|
||||
"id",
|
||||
"model",
|
||||
"offer_id",
|
||||
"created_at",
|
||||
"seller_cost_usdc",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"effective_input_per_1m",
|
||||
"effective_output_per_1m",
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
const row = saleRow("sale-a");
|
||||
delete row[field];
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => Response.json(earningsEnvelope([row])),
|
||||
});
|
||||
|
||||
await expect(client.listObservedSales()).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails clearly instead of claiming unavailable historical completeness", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await expect(client.listAllSales()).rejects.toThrow(
|
||||
"does not provide historical sale pagination",
|
||||
);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends one absolute cost-multiplier PATCH with the cap", async () => {
|
||||
let request: RequestInit | undefined;
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async (_url, init) => {
|
||||
request = init;
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
});
|
||||
|
||||
await client.updateOffer("offer/one", {
|
||||
costMultiplier: 0.04995,
|
||||
dailyCapUsd: 1,
|
||||
idempotencyKey: "mutation-1",
|
||||
});
|
||||
|
||||
expect(request?.method).toBe("PATCH");
|
||||
expect(JSON.parse(String(request?.body))).toEqual({
|
||||
pricing_mode: "cost_multiplier",
|
||||
cost_multiplier: 0.04995,
|
||||
cap_daily_usd: 1,
|
||||
});
|
||||
const headers = new Headers(request?.headers);
|
||||
expect(headers.get("idempotency-key")).toBe("mutation-1");
|
||||
});
|
||||
});
|
||||
397
tests/unit/surplus-controller.test.ts
Normal file
397
tests/unit/surplus-controller.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SurplusManagerConfig } from "../../supplier/src/surplus/config.js";
|
||||
import {
|
||||
SurplusSellerController,
|
||||
type SurplusControllerClient,
|
||||
} from "../../supplier/src/surplus/controller.js";
|
||||
import {
|
||||
SurplusHttpError,
|
||||
type SurplusDiscoveredModel,
|
||||
type SurplusOffer,
|
||||
type SurplusOfferPatch,
|
||||
type SurplusOfferWrite,
|
||||
} from "../../supplier/src/surplus/client.js";
|
||||
import type { OpenRouterKeyAllowance } from "../../supplier/src/surplus/openRouterKey.js";
|
||||
import type {
|
||||
SurplusControllerState,
|
||||
SurplusStateStore,
|
||||
} from "../../supplier/src/surplus/state.js";
|
||||
|
||||
const SELLER_WALLET = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
function config(): SurplusManagerConfig {
|
||||
return {
|
||||
live: true,
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: "si_seller_test",
|
||||
providerApiKey: "sk-or-v1-test",
|
||||
providerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
capacityBaseUrl: "https://openrouter.ai/api",
|
||||
perOfferCapUsd: "0.05",
|
||||
aggregateCapUsd: "0.05",
|
||||
sellerWallet: SELLER_WALLET,
|
||||
payoutAddress: SELLER_WALLET,
|
||||
maxCandidateOrderBooks: 10,
|
||||
reserveUsd: "1.00",
|
||||
maxProviderLimitUsd: "20.00",
|
||||
recoveryBps: 490,
|
||||
undercutBps: 10,
|
||||
pollIntervalMs: 300_000,
|
||||
requestTimeoutMs: 120_000,
|
||||
stopAfterTrades: 1,
|
||||
settledStatuses: ["confirmed"],
|
||||
statePath: "/tmp/surplus-test-state.json",
|
||||
port: 8080,
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryStateStore implements SurplusStateStore {
|
||||
state: SurplusControllerState = { version: 1, phase: "selecting" };
|
||||
|
||||
async load(): Promise<SurplusControllerState> {
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async save(state: SurplusControllerState): Promise<void> {
|
||||
this.state = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSurplusClient implements SurplusControllerClient {
|
||||
offer: SurplusOffer | null = null;
|
||||
trades24h = 0;
|
||||
settled = false;
|
||||
createCalls = 0;
|
||||
pauseCalls = 0;
|
||||
externalCalls = 0;
|
||||
createError: Error | null = null;
|
||||
discoveryError: Error | null = null;
|
||||
createdPayload: SurplusOfferWrite | null = null;
|
||||
patchPayload: SurplusOfferPatch | null = null;
|
||||
|
||||
async listAllOffers(): Promise<SurplusOffer[]> {
|
||||
this.externalCalls += 1;
|
||||
return this.offer ? [{ ...this.offer }] : [];
|
||||
}
|
||||
|
||||
async discoverModels(): Promise<SurplusDiscoveredModel[]> {
|
||||
this.externalCalls += 1;
|
||||
if (this.discoveryError) throw this.discoveryError;
|
||||
return [{
|
||||
model: "beta-model",
|
||||
providerModelId: "vendor/beta-model",
|
||||
supported: true,
|
||||
inputUsdPer1m: 0.07952,
|
||||
outputUsdPer1m: 0.15904,
|
||||
priceUnit: "M",
|
||||
priceVariable: false,
|
||||
modelType: "text",
|
||||
availabilityStatus: "available",
|
||||
}];
|
||||
}
|
||||
|
||||
async getMarkets() {
|
||||
this.externalCalls += 1;
|
||||
return [{
|
||||
model: "beta-model",
|
||||
requests24h: 100,
|
||||
volume24h: 1_000,
|
||||
bestInputMicroUsdPer1m: 3_976,
|
||||
bestOutputMicroUsdPer1m: 7_952,
|
||||
healthySellerCount: 1,
|
||||
}];
|
||||
}
|
||||
|
||||
async getOrderBook(model: string) {
|
||||
this.externalCalls += 1;
|
||||
const offers = [{
|
||||
id: "external",
|
||||
seller: "0x2222222222222222222222222222222222222222",
|
||||
sellerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
inputMicroUsdPer1m: 3_976,
|
||||
outputMicroUsdPer1m: 7_952,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: 0,
|
||||
rank: 1,
|
||||
capRemainingMicroUsd: null,
|
||||
volume24hMicroUsd: 0,
|
||||
}];
|
||||
if (this.offer) {
|
||||
offers.push({
|
||||
id: this.offer.id,
|
||||
seller: SELLER_WALLET,
|
||||
sellerBaseUrl: this.offer.sellerBaseUrl,
|
||||
inputMicroUsdPer1m: this.offer.inputMicroUsdPer1m ?? 3_972,
|
||||
outputMicroUsdPer1m: this.offer.outputMicroUsdPer1m ?? 7_944,
|
||||
available: this.offer.status === "active",
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: this.trades24h,
|
||||
rank: 2,
|
||||
capRemainingMicroUsd: 50_000,
|
||||
volume24hMicroUsd: 0,
|
||||
});
|
||||
}
|
||||
return { model, offers };
|
||||
}
|
||||
|
||||
async testConnection(): Promise<number> {
|
||||
this.externalCalls += 1;
|
||||
return 10;
|
||||
}
|
||||
|
||||
async createOffer(input: SurplusOfferWrite): Promise<string> {
|
||||
this.externalCalls += 1;
|
||||
this.createCalls += 1;
|
||||
this.createdPayload = input;
|
||||
if (this.createError) throw this.createError;
|
||||
this.offer = {
|
||||
id: "managed-offer",
|
||||
model: input.model,
|
||||
sellerBaseUrl: input.sellerBaseUrl,
|
||||
status: "active",
|
||||
capDailyUsd: input.dailyCapUsd,
|
||||
costMultiplierPpm: Math.round(input.costMultiplier * 1_000_000),
|
||||
inputMicroUsdPer1m: Math.floor(79_520 * input.costMultiplier),
|
||||
outputMicroUsdPer1m: Math.floor(159_040 * input.costMultiplier),
|
||||
};
|
||||
return this.offer.id;
|
||||
}
|
||||
|
||||
async updateOffer(_id: string, patch: SurplusOfferPatch): Promise<void> {
|
||||
this.externalCalls += 1;
|
||||
this.patchPayload = patch;
|
||||
if (!this.offer) throw new Error("missing fake offer");
|
||||
this.offer.costMultiplierPpm = Math.round(
|
||||
patch.costMultiplier * 1_000_000,
|
||||
);
|
||||
this.offer.inputMicroUsdPer1m = Math.floor(
|
||||
79_520 * patch.costMultiplier,
|
||||
);
|
||||
this.offer.outputMicroUsdPer1m = Math.floor(
|
||||
159_040 * patch.costMultiplier,
|
||||
);
|
||||
this.offer.capDailyUsd = patch.dailyCapUsd;
|
||||
}
|
||||
|
||||
async pauseOffer(): Promise<void> {
|
||||
this.externalCalls += 1;
|
||||
this.pauseCalls += 1;
|
||||
if (this.offer) this.offer.status = "inactive";
|
||||
}
|
||||
|
||||
async resumeOffer(): Promise<void> {
|
||||
this.externalCalls += 1;
|
||||
if (this.offer) this.offer.status = "active";
|
||||
}
|
||||
|
||||
async getEarnings() {
|
||||
this.externalCalls += 1;
|
||||
return {
|
||||
totalEarnedMicroUsd: this.settled ? 100 : 0,
|
||||
pendingMicroUsd: 0,
|
||||
paidMicroUsd: this.settled ? 100 : 0,
|
||||
daily: [],
|
||||
byModel: [],
|
||||
requestCount: this.settled ? 1 : 0,
|
||||
tokenCount: this.settled ? 2 : 0,
|
||||
topModel: this.settled ? "beta-model" : null,
|
||||
payoutHoldReason: null,
|
||||
payoutHoldReleasesAt: null,
|
||||
recentSales: this.settled ? [{
|
||||
id: "sale-1",
|
||||
model: "beta-model",
|
||||
offerId: "managed-offer",
|
||||
settlementStatus: "confirmed",
|
||||
createdAt: "2026-08-27T10:00:01.000Z",
|
||||
sellerCostMicroUsd: 100,
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
effectiveInputUsdPer1m: 0.003972,
|
||||
effectiveOutputUsdPer1m: 0.007944,
|
||||
transactionHash: "0x" + "a".repeat(64),
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWANCE: OpenRouterKeyAllowance = {
|
||||
limitUsdNanos: 20_000_000_000n,
|
||||
remainingUsdNanos: 20_000_000_000n,
|
||||
reset: null,
|
||||
checkedAtMs: Date.parse("2026-08-27T10:00:00.000Z"),
|
||||
};
|
||||
|
||||
function controller(
|
||||
client: FakeSurplusClient,
|
||||
store: MemoryStateStore,
|
||||
allowance: { readAllowance(): Promise<OpenRouterKeyAllowance> } = {
|
||||
readAllowance: async () => ALLOWANCE,
|
||||
},
|
||||
managerConfig: SurplusManagerConfig = config(),
|
||||
): SurplusSellerController {
|
||||
let mutation = 0;
|
||||
return new SurplusSellerController({
|
||||
config: managerConfig,
|
||||
client,
|
||||
allowance,
|
||||
stateStore: store,
|
||||
now: () => Date.parse("2026-08-27T10:00:00.000Z"),
|
||||
mutationId: () => `mutation-${++mutation}`,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SurplusSellerController", () => {
|
||||
it("selects a discovered model, creates one bounded offer, and stops after settlement", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
const store = new MemoryStateStore();
|
||||
const activeController = controller(client, store);
|
||||
|
||||
await activeController.runOnce();
|
||||
|
||||
expect(client.createCalls).toBe(1);
|
||||
expect(client.createdPayload).toMatchObject({
|
||||
model: "beta-model",
|
||||
costMultiplier: 0.04995,
|
||||
dailyCapUsd: 0.05,
|
||||
payoutAddress: SELLER_WALLET,
|
||||
});
|
||||
expect(store.state.phase).toBe("active");
|
||||
expect(activeController.snapshot()).toMatchObject({
|
||||
ok: true,
|
||||
phase: "active",
|
||||
model: "beta-model",
|
||||
providerModelId: "vendor/beta-model",
|
||||
});
|
||||
|
||||
client.trades24h = 1;
|
||||
client.settled = true;
|
||||
await activeController.runOnce();
|
||||
|
||||
expect(client.pauseCalls).toBeGreaterThan(0);
|
||||
expect(client.offer?.status).toBe("inactive");
|
||||
expect(store.state.phase).toBe("completed");
|
||||
expect(activeController.snapshot()).toMatchObject({ ok: true, phase: "completed" });
|
||||
|
||||
const callsBeforeRestart = client.externalCalls;
|
||||
const restarted = controller(client, store);
|
||||
await restarted.start();
|
||||
expect(client.externalCalls).toBe(callsBeforeRestart);
|
||||
expect(restarted.snapshot()).toMatchObject({ ok: true, phase: "completed" });
|
||||
await restarted.stop();
|
||||
});
|
||||
|
||||
it("pauses when provider spend reaches the per-offer cap", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
const store = new MemoryStateStore();
|
||||
let remaining = 20_000_000_000n;
|
||||
const activeController = controller(client, store, {
|
||||
readAllowance: async () => ({
|
||||
...ALLOWANCE,
|
||||
remainingUsdNanos: remaining,
|
||||
}),
|
||||
});
|
||||
await activeController.runOnce();
|
||||
expect(client.offer?.status).toBe("active");
|
||||
|
||||
remaining = 18_700_000_000n;
|
||||
await activeController.runOnce();
|
||||
|
||||
expect(client.offer?.status).toBe("inactive");
|
||||
expect(store.state.phase).toBe("awaiting_settlement");
|
||||
});
|
||||
|
||||
it("keeps selling in continuous mode after trades and provider spend", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
const store = new MemoryStateStore();
|
||||
let remaining = 20_000_000_000n;
|
||||
const activeController = controller(
|
||||
client,
|
||||
store,
|
||||
{
|
||||
readAllowance: async () => ({
|
||||
...ALLOWANCE,
|
||||
remainingUsdNanos: remaining,
|
||||
}),
|
||||
},
|
||||
{ ...config(), stopAfterTrades: 0 },
|
||||
);
|
||||
await activeController.runOnce();
|
||||
client.trades24h = 1;
|
||||
remaining = 18_700_000_000n;
|
||||
|
||||
await activeController.runOnce();
|
||||
|
||||
expect(client.offer?.status).toBe("active");
|
||||
expect(store.state.phase).toBe("active");
|
||||
});
|
||||
|
||||
it("adopts and resumes an inactive offer after an ambiguous create", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
client.offer = {
|
||||
id: "managed-offer",
|
||||
model: "beta-model",
|
||||
sellerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
status: "inactive",
|
||||
capDailyUsd: 0.05,
|
||||
costMultiplierPpm: 49_950,
|
||||
inputMicroUsdPer1m: 3_972,
|
||||
outputMicroUsdPer1m: 7_944,
|
||||
};
|
||||
const store = new MemoryStateStore();
|
||||
store.state = {
|
||||
version: 1,
|
||||
phase: "create_pending",
|
||||
intent: {
|
||||
model: "beta-model",
|
||||
providerModelId: "vendor/beta-model",
|
||||
costMultiplierPpm: 49_950,
|
||||
inputMicroUsdPer1m: 3_972,
|
||||
outputMicroUsdPer1m: 7_944,
|
||||
dailyCapUsd: 0.05,
|
||||
baselineRemainingUsdNanos: "20000000000",
|
||||
idempotencyKey: "create-1",
|
||||
createdAt: "2026-08-27T10:00:00.000Z",
|
||||
baselineTrades24h: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await controller(client, store).runOnce();
|
||||
|
||||
expect(client.createCalls).toBe(0);
|
||||
expect(client.offer.status).toBe("active");
|
||||
expect(store.state.phase).toBe("active");
|
||||
});
|
||||
|
||||
it("never retries an ambiguous create when no matching offer is visible", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
client.createError = new SurplusHttpError("network lost", null);
|
||||
const store = new MemoryStateStore();
|
||||
const activeController = controller(client, store);
|
||||
|
||||
await activeController.runOnce();
|
||||
expect(client.createCalls).toBe(1);
|
||||
expect(store.state.phase).toBe("create_pending");
|
||||
|
||||
await activeController.runOnce();
|
||||
expect(client.createCalls).toBe(1);
|
||||
expect(activeController.snapshot()).toMatchObject({ ok: false, phase: "error" });
|
||||
});
|
||||
|
||||
it("pauses the managed offer when a required pricing snapshot fails", async () => {
|
||||
const client = new FakeSurplusClient();
|
||||
const store = new MemoryStateStore();
|
||||
const activeController = controller(client, store);
|
||||
await activeController.runOnce();
|
||||
expect(client.offer?.status).toBe("active");
|
||||
|
||||
client.discoveryError = new Error("discovery unavailable");
|
||||
await activeController.runOnce();
|
||||
|
||||
expect(client.offer?.status).toBe("inactive");
|
||||
expect(activeController.snapshot()).toMatchObject({ ok: false, phase: "error" });
|
||||
});
|
||||
});
|
||||
241
tests/unit/surplus-dashboard.test.ts
Normal file
241
tests/unit/surplus-dashboard.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { SurplusManagerConfig } from "../../supplier/src/surplus/config.js";
|
||||
import {
|
||||
SurplusDashboardService,
|
||||
type SurplusDashboardSnapshot,
|
||||
} from "../../supplier/src/surplus/dashboard.js";
|
||||
import { createSurplusServer } from "../../supplier/src/cli/surplus-seller.js";
|
||||
import type { SurplusControllerStatus } from "../../supplier/src/surplus/controller.js";
|
||||
|
||||
const STATUS: SurplusControllerStatus = {
|
||||
ok: true,
|
||||
phase: "active",
|
||||
live: true,
|
||||
model: "deepseek-v4-flash",
|
||||
providerModelId: "deepseek/deepseek-v4-flash",
|
||||
offerId: "offer-1",
|
||||
inputUsdPer1m: "0.003972",
|
||||
outputUsdPer1m: "0.007944",
|
||||
competitorId: "external",
|
||||
remainingAllowanceUsd: "16.313443",
|
||||
reserveThresholdUsd: "1.00",
|
||||
totalEarnedUsd: "0.186810",
|
||||
lastCycleAt: "2026-08-28T12:00:00.000Z",
|
||||
lastSuccessAt: "2026-08-28T12:00:00.000Z",
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
function config(): SurplusManagerConfig {
|
||||
return {
|
||||
live: true,
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: "si_seller_test_secret",
|
||||
providerApiKey: "sk-or-v1-test-secret",
|
||||
providerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
capacityBaseUrl: "https://openrouter.ai/api",
|
||||
perOfferCapUsd: "0.05",
|
||||
aggregateCapUsd: "0.05",
|
||||
sellerWallet: "0x1111111111111111111111111111111111111111",
|
||||
payoutAddress: "0x1111111111111111111111111111111111111111",
|
||||
maxCandidateOrderBooks: 10,
|
||||
reserveUsd: "1.00",
|
||||
maxProviderLimitUsd: "20.00",
|
||||
recoveryBps: 490,
|
||||
undercutBps: 10,
|
||||
pollIntervalMs: 300_000,
|
||||
requestTimeoutMs: 120_000,
|
||||
stopAfterTrades: 0,
|
||||
settledStatuses: ["confirmed"],
|
||||
statePath: "/tmp/surplus-dashboard-test.json",
|
||||
port: 8080,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(): SurplusDashboardSnapshot {
|
||||
return {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
controller: STATUS,
|
||||
identity: {
|
||||
sellerWallet: "0x1111111111111111111111111111111111111111",
|
||||
payoutAddress: "0x1111111111111111111111111111111111111111",
|
||||
},
|
||||
provider: {
|
||||
name: "OpenRouter",
|
||||
hardLimitUsd: "20.000000",
|
||||
remainingUsd: "16.313443",
|
||||
usedUsd: "3.686557",
|
||||
reserveUsd: "1.00",
|
||||
estimatedDailyExposureUsd: "1.020408",
|
||||
},
|
||||
surplus: {
|
||||
offer: {
|
||||
id: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
providerModel: "deepseek/deepseek-v4-flash",
|
||||
status: "active",
|
||||
dailyCapUsd: "0.050000",
|
||||
costMultiplier: 0.04995,
|
||||
inputUsdPer1m: "0.003972",
|
||||
outputUsdPer1m: "0.007944",
|
||||
rank: 10,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: 429,
|
||||
volume24hUsd: "0.064030",
|
||||
capRemainingUsd: "0.050000",
|
||||
},
|
||||
earnings: {
|
||||
totalUsd: "0.186810",
|
||||
pendingUsd: "0.176817",
|
||||
paidUsd: "0.009993",
|
||||
requests: 1_093,
|
||||
tokens: 104_698_905,
|
||||
today: null,
|
||||
topModel: "deepseek-v4-flash",
|
||||
payoutHoldReason: "new_seller",
|
||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||
},
|
||||
recentSales: [],
|
||||
},
|
||||
vector: {
|
||||
status: "retired",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
supplierWallet: "addr1test",
|
||||
advertRef: `${"a".repeat(64)}#0`,
|
||||
retirementTransaction: "b".repeat(64),
|
||||
retiredOn: "2026-08-27",
|
||||
historicalAp3xEarned: "1.600000",
|
||||
historicalSettledJobs: 8,
|
||||
historicalUpstreamSpendUsd: "0.000093",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurplusDashboardService", () => {
|
||||
it("combines safe live metrics with the Vector migration record", async () => {
|
||||
const listAllOffers = vi.fn(async () => [{
|
||||
id: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
sellerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
status: "active" as const,
|
||||
capDailyUsd: 0.05,
|
||||
costMultiplierPpm: 49_950,
|
||||
inputMicroUsdPer1m: 3_972,
|
||||
outputMicroUsdPer1m: 7_944,
|
||||
}]);
|
||||
const getEarnings = vi.fn(async () => ({
|
||||
totalEarnedMicroUsd: 186_810,
|
||||
pendingMicroUsd: 176_817,
|
||||
paidMicroUsd: 9_993,
|
||||
daily: [{
|
||||
day: "2026-08-28",
|
||||
earnedUsd: 0.01,
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
requests: 1,
|
||||
totalTokens: 12,
|
||||
}],
|
||||
byModel: [],
|
||||
requestCount: 1_093,
|
||||
tokenCount: 104_698_905,
|
||||
topModel: "deepseek-v4-flash",
|
||||
payoutHoldReason: "new_seller",
|
||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||
recentSales: [{
|
||||
id: "sale-1",
|
||||
model: "deepseek-v4-flash",
|
||||
offerId: "offer-1",
|
||||
settlementStatus: "confirmed",
|
||||
createdAt: "2026-08-28T11:00:00.000Z",
|
||||
sellerCostMicroUsd: 83,
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
effectiveInputUsdPer1m: 0.003972,
|
||||
effectiveOutputUsdPer1m: 0.007944,
|
||||
transactionHash: "0x" + "c".repeat(64),
|
||||
}],
|
||||
}));
|
||||
const getOrderBook = vi.fn(async () => ({
|
||||
model: "deepseek-v4-flash",
|
||||
offers: [{
|
||||
id: "offer-1",
|
||||
seller: "0x1111111111111111111111111111111111111111",
|
||||
sellerBaseUrl: "https://openrouter.ai/api/v1",
|
||||
inputMicroUsdPer1m: 3_972,
|
||||
outputMicroUsdPer1m: 7_944,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: 429,
|
||||
rank: 10,
|
||||
capRemainingMicroUsd: 50_000,
|
||||
volume24hMicroUsd: 64_030,
|
||||
}],
|
||||
}));
|
||||
const service = new SurplusDashboardService({
|
||||
controller: { snapshot: () => STATUS },
|
||||
client: { listAllOffers, getEarnings, getOrderBook },
|
||||
allowance: {
|
||||
readAllowance: async () => ({
|
||||
limitUsdNanos: 20_000_000_000n,
|
||||
remainingUsdNanos: 16_313_443_131n,
|
||||
reset: null,
|
||||
checkedAtMs: Date.parse("2026-08-28T12:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
config: config(),
|
||||
now: () => Date.parse("2026-08-28T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
const first = await service.getSnapshot();
|
||||
const second = await service.getSnapshot();
|
||||
|
||||
expect(first.surplus.offer).toMatchObject({
|
||||
id: "offer-1",
|
||||
rank: 10,
|
||||
available: true,
|
||||
dailyCapUsd: "0.050000",
|
||||
});
|
||||
expect(first.surplus.earnings).toMatchObject({
|
||||
totalUsd: "0.186810",
|
||||
requests: 1_093,
|
||||
topModel: "deepseek-v4-flash",
|
||||
});
|
||||
expect(first.provider).toMatchObject({
|
||||
usedUsd: "3.686556",
|
||||
estimatedDailyExposureUsd: "1.020408",
|
||||
});
|
||||
expect(first.vector).toMatchObject({ status: "retired", historicalSettledJobs: 8 });
|
||||
expect(first.surplus.recentSales[0]).toMatchObject({
|
||||
settlementStatus: "confirmed",
|
||||
revenueUsd: "0.000083",
|
||||
});
|
||||
expect(JSON.stringify(first)).not.toContain("si_seller_test_secret");
|
||||
expect(JSON.stringify(first)).not.toContain("sk-or-v1-test-secret");
|
||||
expect(second).toBe(first);
|
||||
expect(listAllOffers).toHaveBeenCalledTimes(1);
|
||||
expect(getEarnings).toHaveBeenCalledTimes(1);
|
||||
expect(getOrderBook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe("private supplier dashboard data surface", () => {
|
||||
it("serves redacted data internally without hosting a dashboard page", async () => {
|
||||
const data = snapshot();
|
||||
const server = createSurplusServer(
|
||||
{ snapshot: () => STATUS, healthy: () => true },
|
||||
{ getSnapshot: async () => data },
|
||||
);
|
||||
|
||||
const api = await request(server).get("/internal/resale-dashboard");
|
||||
expect(api.status).toBe(200);
|
||||
expect(api.body).toEqual(data);
|
||||
expect(JSON.stringify(api.body)).not.toContain("test-secret");
|
||||
|
||||
expect((await request(server).get("/reseller")).status).toBe(404);
|
||||
expect((await request(server).get("/")).status).toBe(404);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user