supplier: TTS capability scaffolding (audio.synthesize.piper.v1)

Path-A foundation: extend the supplier service so the same on-chain
escrow lifecycle can serve a PiperTTS supplier alongside the existing
Ollama-backed chat one. Capability dispatch is boot-time; one process
serves one capability via a single advert.

Receipt mapping for TTS is committed inline:
- prompt_hash  = sha256(canonical({text, voice, format, speed}))
- result_hash  = sha256(audio_bytes)
- prompt_tokens / completion_tokens = 0 (off-chain only; Piper has no
  token semantics, on-chain validator never reads these fields)

New / changed files

- supplier/src/piper.ts (new): mirror of ollama.ts. callPiper(...)
  POSTs OpenAI-shape /v1/audio/speech, returns {audio: Uint8Array,
  contentType, wallclock_ms}. Typed errors: piper_failure | piper_
  timeout | piper_malformed (with a 64-byte minimum to catch upstream
  "200 OK, body length 0" cold-start)
- supplier/src/config.ts: + CapabilityKind = "chat" | "tts", + piperUrl
  / piperTimeoutMs. CAPABILITY_KIND env defaults to "chat" so existing
  chat suppliers keep booting unchanged
- supplier/src/jobs.ts: JobResponsePayload becomes a discriminated
  union (ChatJobResponsePayload | TtsJobResponsePayload). TTS variant
  carries audio_b64 + content_type + byte_length so the in-memory
  store stays JSON-clean and the GET poll route returns audio without
  binary content-type negotiation
- supplier/src/jobRunner.ts: + runTtsJob (parallels runChatJob with
  same 7-step shape: setRunning -> upstream -> receipt -> buildSubmit
  -> awaitTx -> complete -> finally release)
- supplier/src/server.ts: + makeTtsHandler (POST /v1/audio/synthesize)
  + makeGetTtsJobHandler (poll). createApp dispatches by config.
  capabilityKind: TTS supplier exposes the audio routes only, chat
  supplier exposes the chat routes only. The on-chain capability_id
  check on every request is belt-and-braces

Tests
- tests/unit/supplier-piper.test.ts: 9 tests (request shape, success,
  network throw, AbortError -> piper_timeout, too-small body ->
  piper_malformed, missing content-type fallback)
- tests/unit/supplier-routes-tts.test.ts: 9 tests (capability-routed
  mounting, all 400 validation paths, sanity that the route is reachable)
- tests/fixtures/supplier-side/sample-config.ts: + buildSampleTtsConfig

243 supplier tests passing; chat path behaviourally untouched.
This commit is contained in:
jelisaveta-talbot
2026-05-06 19:01:35 +02:00
parent 3152b01c04
commit 3c3e009a67
8 changed files with 904 additions and 8 deletions

View File

@@ -7,13 +7,24 @@
* Required env vars: * Required env vars:
* SUPPLIER_PRIV_KEY_HEX — 64-char hex Ed25519 private key * SUPPLIER_PRIV_KEY_HEX — 64-char hex Ed25519 private key
* OGMIOS_URL — ws:// or wss:// URL * OGMIOS_URL — ws:// or wss:// URL
* OLLAMA_URL — http:// URL for local Ollama
* ADVERT_REF — "<64hex>#<int>" OutputReference to the supplier's advert UTxO * ADVERT_REF — "<64hex>#<int>" OutputReference to the supplier's advert UTxO
* NETWORK_ID — "0" (testnet) or "1" (mainnet) * NETWORK_ID — "0" (testnet) or "1" (mainnet)
* *
* Required for CAPABILITY_KIND="chat" (default):
* OLLAMA_URL — http:// URL for local Ollama
*
* Required for CAPABILITY_KIND="tts":
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
*
* Optional: * Optional:
* CAPABILITY_KIND — "chat" (default) or "tts". Selects which upstream
* adapter is loaded and which HTTP route is mounted.
* The route handler still validates the on-chain
* advert's capability_id; this env just decides
* which dispatch lives in the process.
* PORT — listen port (default 8080) * PORT — listen port (default 8080)
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000) * OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off). * LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE") * Any other value (including "true", "yes", "TRUE")
* keeps the supplier in read-only mode for safety. * keeps the supplier in read-only mode for safety.
@@ -32,14 +43,22 @@ export interface AdvertRef {
index: number; index: number;
} }
/** Capability the supplier is configured to serve. */
export type CapabilityKind = "chat" | "tts";
export interface SupplierConfig { export interface SupplierConfig {
supplierPrivKeyHex: string; supplierPrivKeyHex: string;
ogmiosUrl: string; ogmiosUrl: string;
/** Empty string when capabilityKind="tts". */
ollamaUrl: string; ollamaUrl: string;
/** Empty string when capabilityKind="chat". */
piperUrl: string;
advertRef: AdvertRef; advertRef: AdvertRef;
networkId: 0 | 1; networkId: 0 | 1;
port: number; port: number;
ollamaTimeoutMs: number; ollamaTimeoutMs: number;
piperTimeoutMs: number;
capabilityKind: CapabilityKind;
/** /**
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx). * When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes). * Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -82,7 +101,29 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
} }
const ogmiosUrl = requireField(env, "OGMIOS_URL"); const ogmiosUrl = requireField(env, "OGMIOS_URL");
const ollamaUrl = requireField(env, "OLLAMA_URL");
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts") {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat" or "tts"');
}
const capabilityKind: CapabilityKind = capKindStr;
// Per-capability upstream URL — the OTHER capability's URL becomes
// optional/unused. The route handler also validates the on-chain advert's
// capability_id, so a misconfiguration here can't trick a TTS supplier into
// serving chat traffic; this gate just keeps the process boot honest.
let ollamaUrl = "";
let piperUrl = "";
if (capabilityKind === "chat") {
ollamaUrl = requireField(env, "OLLAMA_URL");
piperUrl = env.PIPER_URL ?? "";
} else {
piperUrl = requireField(env, "PIPER_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
}
const advertRefStr = requireField(env, "ADVERT_REF"); const advertRefStr = requireField(env, "ADVERT_REF");
const advertRef = parseAdvertRef(advertRefStr); const advertRef = parseAdvertRef(advertRefStr);
@@ -110,6 +151,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
ollamaTimeoutMs = Number(timeoutStr); ollamaTimeoutMs = Number(timeoutStr);
} }
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
if (!POS_INT_RE.test(piperTimeoutStr)) {
throw new Error("loadConfig: PIPER_TIMEOUT_MS must be a positive integer");
}
piperTimeoutMs = Number(piperTimeoutStr);
}
// LIVE_CHAIN: only the literal "1" opts in to live-chain mode. Any other // LIVE_CHAIN: only the literal "1" opts in to live-chain mode. Any other
// value (including "true", "yes", "TRUE", "", undefined) keeps the supplier // value (including "true", "yes", "TRUE", "", undefined) keeps the supplier
// in safe read-only mode. Real submissions require explicit opt-in. // in safe read-only mode. Real submissions require explicit opt-in.
@@ -119,10 +169,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
supplierPrivKeyHex, supplierPrivKeyHex,
ogmiosUrl, ogmiosUrl,
ollamaUrl, ollamaUrl,
piperUrl,
advertRef, advertRef,
networkId, networkId,
port, port,
ollamaTimeoutMs, ollamaTimeoutMs,
piperTimeoutMs,
capabilityKind,
liveChain, liveChain,
}; };
} }

View File

@@ -36,7 +36,13 @@ import { createHash } from "crypto";
import type { SupplierState } from "./state.js"; import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js"; import type { SupplierConfig } from "./config.js";
import * as ollama from "./ollama.js"; import * as ollama from "./ollama.js";
import type { JobStore, JobResponsePayload } from "./jobs.js"; import * as piper from "./piper.js";
import type {
JobStore,
JobResponsePayload,
ChatJobResponsePayload,
TtsJobResponsePayload,
} from "./jobs.js";
export interface RunChatJobDeps { export interface RunChatJobDeps {
chain: ChainProvider; chain: ChainProvider;
@@ -145,7 +151,8 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
} }
// ── 6. Mark complete ──────────────────────────────────────────────── // ── 6. Mark complete ────────────────────────────────────────────────
const payload: JobResponsePayload = { const payload: ChatJobResponsePayload = {
kind: "chat",
choices: [ choices: [
{ {
index: 0, index: 0,
@@ -171,3 +178,135 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
} }
} }
} }
// ─── runTtsJob ────────────────────────────────────────────────────────────
//
// Mirror of runChatJob for the audio.synthesize.piper.v1 capability. Same
// 7-step shape (setRunning → upstream → receipt → buildSubmit → awaitTx →
// complete → finally release). Differences from chat path:
//
// - Upstream is `piper.callPiper` returning audio bytes + content-type,
// not Ollama returning text + token counts.
// - response_hash = sha256(audio_bytes) — opaque-bytes commitment, in
// contrast to chat which hashes a canonicalised assistant message JSON.
// - prompt_tokens / completion_tokens are reported as 0 in the receipt;
// Piper has no token concept and the on-chain validator doesn't read
// these fields (they're off-chain billing metadata only).
// - JobStore terminal payload is `TtsJobResponsePayload` with audio_b64.
export interface RunTtsJobParams {
deps: RunChatJobDeps;
jobId: string;
escrowRef: string;
claimedRef: OutputReference;
advert: AdvertDatum;
escrowDatum: EscrowDatum;
/** The TTS request body fields. The PROMPT_HASH committed by the buyer in
* the escrow datum is sha256(canonicalize(this same object)), so the
* supplier reproduces that hash and validates it before claiming. */
requestBody: { text: string; voice: string; format: string; speed: number };
}
function sha256BytesHex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
export async function runTtsJob(params: RunTtsJobParams): Promise<void> {
const { deps, jobId, escrowRef, claimedRef, advert, escrowDatum, requestBody } = params;
deps.jobs.setRunning(jobId);
try {
// ── 2. Call Piper ─────────────────────────────────────────────────
let inference: piper.PiperResult;
try {
inference = await piper.callPiper({
piperUrl: deps.config.piperUrl,
text: requestBody.text,
voice: requestBody.voice,
format: requestBody.format,
speed: requestBody.speed,
timeoutMs: deps.config.piperTimeoutMs,
});
} catch (err) {
const reason = (err as piper.PiperError)?.reason ?? "piper_failure";
const message = err instanceof Error ? err.message : String(err);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: reason === "piper_timeout" ? "piper_failure" : reason,
message,
});
return;
}
// ── 3. Build + sign receipt ───────────────────────────────────────
const responseHash = sha256BytesHex(inference.audio);
const receipt = buildReceipt({
prompt_hash: escrowDatum.prompt_hash,
response_hash: responseHash,
model: advert.model,
// Piper has no token semantics — pin both to 0 so the field is present
// but doesn't masquerade as a real token count. Off-chain only; the
// validator never reads these.
prompt_tokens: 0,
completion_tokens: 0,
wallclock_ms: inference.wallclock_ms,
supplier_pkh: deps.supplierKey.pubKeyHash,
escrow_ref: escrowRef,
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
// ── 4. Build + submit Submit tx ───────────────────────────────────
let buildResult: { txCborHex: string; expectedTxHash: string };
try {
buildResult = await buildSubmitTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef: claimedRef,
receiptHash: resultHash,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_failed",
message: `Submit tx failed: ${message}`,
});
return;
}
// ── 5. Submit confirmation ────────────────────────────────────────
try {
await deps.chain.awaitTx(buildResult.expectedTxHash, 60_000);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_timeout",
message: `Submit awaitTx failed: ${msg}`,
});
return;
}
// ── 6. Mark complete ──────────────────────────────────────────────
const audio_b64 = Buffer.from(inference.audio).toString("base64");
const payload: TtsJobResponsePayload = {
kind: "tts",
audio_b64,
format: requestBody.format,
content_type: inference.contentType,
byte_length: inference.audio.byteLength,
receipt: signed.receipt as unknown as Record<string, unknown>,
receipt_signature: signed.signature,
};
deps.jobs.complete(jobId, payload);
} finally {
try {
deps.state.release();
} catch {
/* never let lock-release escape */
}
}
}

View File

@@ -26,7 +26,13 @@
export type JobStatus = "accepted" | "running" | "done" | "failed"; export type JobStatus = "accepted" | "running" | "done" | "failed";
export interface JobResponsePayload { /**
* Chat-flavoured terminal payload. Stored when a `runChatJob` succeeds.
* Shape mirrors the OpenAI chat-completion response so the buyer-side SDK
* can parse it without translation.
*/
export interface ChatJobResponsePayload {
kind?: "chat";
choices: Array<{ choices: Array<{
index: number; index: number;
message: { role: "assistant"; content: string }; message: { role: "assistant"; content: string };
@@ -41,6 +47,30 @@ export interface JobResponsePayload {
receipt_signature: string; receipt_signature: string;
} }
/**
* TTS-flavoured terminal payload. Stored when a `runTtsJob` succeeds.
* Audio bytes are base64-encoded so the in-memory store stays JSON-clean
* (and so the GET /v1/audio/synthesize/:jobId poll route can return them
* without a binary content-type negotiation).
*/
export interface TtsJobResponsePayload {
kind: "tts";
audio_b64: string;
format: string; // "mp3" | "wav" | …
content_type: string; // verbatim from upstream Piper, e.g. "audio/mpeg"
byte_length: number;
receipt: Record<string, unknown>;
receipt_signature: string;
}
/**
* Discriminated union of every job-payload shape the store can hold.
* `kind` defaults to "chat" on the chat shape (older code paths don't
* set it; new `runChatJob` writes can set kind: "chat" explicitly to make
* the discriminator narrowing reliable).
*/
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload;
export interface JobFailure { export interface JobFailure {
httpStatus: number; httpStatus: number;
reason: string; reason: string;

143
supplier/src/piper.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* supplier/src/piper.ts — HTTP client for upstream PiperTTS (openedai-speech-min).
*
* Mirrors the contract of `ollama.ts`: a single async function that POSTs the
* request, surfaces typed errors via a discriminated `reason`, and returns a
* normalised result the runner can hash + commit on chain.
*
* callPiper({ piperUrl, text, voice, format, speed, timeoutMs })
* POST to ${piperUrl}/v1/audio/speech with OpenAI-shape body
* { model: "tts-1", input: text, voice, response_format: format, speed }
* Returns { audio: Uint8Array, contentType: string, wallclock_ms: number }
*
* Error reasons:
* "piper_failure" — non-2xx HTTP, network error, unexpected fetch failure
* "piper_timeout" — request exceeded timeoutMs (AbortError surfaced)
* "piper_malformed" — empty body or impossibly-small response (under 64 bytes
* can't be a real audio frame; protects against an
* upstream returning 200 with no body)
*
* Implementation notes:
* - Uses globalThis.fetch + AbortController so tests can vi.stubGlobal("fetch", ...).
* - Returns raw bytes (Uint8Array) — the runner hashes them directly via sha256
* to compute result_hash for the receipt. We do NOT base64 here; that's a
* job-payload concern (JSON-friendliness), not an upstream concern.
* - wallclock_ms is measured locally (Date.now bracketing the fetch) since
* openedai-speech-min doesn't return a timing field.
*/
export interface CallPiperParams {
piperUrl: string;
text: string;
voice: string;
/** mp3 | wav | opus | aac | flac — passed through verbatim as response_format */
format: string;
/** Playback speed multiplier; openedai-speech-min honours 0.5–1.5 */
speed: number;
timeoutMs: number;
}
export interface PiperResult {
audio: Uint8Array;
contentType: string;
wallclock_ms: number;
}
export type PiperErrorReason = "piper_failure" | "piper_timeout" | "piper_malformed";
export class PiperError extends Error {
public readonly reason: PiperErrorReason;
constructor(reason: PiperErrorReason, message?: string) {
super(message ?? reason);
this.name = "PiperError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
/**
* Minimum byte count we'll accept as "real" audio. PiperTTS-mp3 for a single
* sentence runs ~5–10 KB; an mp3 frame header alone is 4 bytes. We pick a
* conservative 64 to catch the "200 OK, body length 0" failure mode some
* proxies produce on cold-start without flagging genuinely-short audio.
*/
const MIN_AUDIO_BYTES = 64;
export async function callPiper(params: CallPiperParams): Promise<PiperResult> {
const { piperUrl, text, voice, format, speed, timeoutMs } = params;
const url = `${piperUrl.replace(/\/+$/, "")}/v1/audio/speech`;
const body = JSON.stringify({
model: "tts-1",
input: text,
voice,
response_format: format,
speed,
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new PiperError("piper_timeout", `Piper request exceeded ${timeoutMs}ms`);
}
throw new PiperError(
"piper_failure",
`Piper fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch {
/* body unavailable */
}
throw new PiperError(
"piper_failure",
`Piper returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
let audio: Uint8Array;
try {
const buf = await response.arrayBuffer();
audio = new Uint8Array(buf);
} catch (err) {
throw new PiperError(
"piper_malformed",
`Piper response body was unreadable: ${(err as Error)?.message ?? String(err)}`,
);
}
if (audio.byteLength < MIN_AUDIO_BYTES) {
throw new PiperError(
"piper_malformed",
`Piper response too small to be audio: ${audio.byteLength} bytes`,
);
}
const wallclock_ms = Date.now() - startedAt;
const contentType = response.headers.get("content-type") ?? `audio/${format}`;
return { audio, contentType, wallclock_ms };
}

View File

@@ -43,7 +43,7 @@ import { buildClaimTx, mockSlotToWallclockMs, detectCborBackend } from "@marketp
import type { SupplierState } from "./state.js"; import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js"; import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.js"; import { JobStore } from "./jobs.js";
import { runChatJob } from "./jobRunner.js"; import { runChatJob, runTtsJob } from "./jobRunner.js";
import { healthzRouter } from "./routes/healthz.js"; import { healthzRouter } from "./routes/healthz.js";
export interface SupplierDeps { export interface SupplierDeps {
@@ -382,6 +382,13 @@ function makeGetJobHandler(deps: ResolvedDeps) {
} }
if (record.status === "done") { if (record.status === "done") {
const payload = record.responsePayload!; const payload = record.responsePayload!;
// Defensive narrowing: this poll route only knows how to render the
// chat shape. A future shared JobStore could mix capabilities; we'd
// want each kind's poll to be sure it's reading its own.
if ("audio_b64" in payload) {
return jsonError(res, 500, "wrong_payload_kind",
"tts payload returned to chat poll route");
}
return res.status(200).json({ return res.status(200).json({
choices: payload.choices, choices: payload.choices,
usage: payload.usage, usage: payload.usage,
@@ -401,6 +408,234 @@ function makeGetJobHandler(deps: ResolvedDeps) {
}; };
} }
// ─── POST /v1/audio/synthesize ──────────────────────────────────────────────
//
// Mirror of makeChatHandler for the audio.synthesize.piper.v1 capability.
// Same lifecycle (validate → resolve advert → resolve escrow → hash check →
// claim → fire-and-forget runner → 202). Differences:
// - body shape: { text, voice, format, speed } (no messages array)
// - prompt_hash is sha256(canonical({text, voice, format, speed})), so the
// escrow datum the buyer commits MUST hash the same object shape — see
// packages/shared/src/tx/escrow/postEscrow*.ts (TTS variant).
const ALLOWED_TTS_VOICES = new Set([
"alloy", "echo", "fable", "onyx", "nova", "shimmer", "lessac",
]);
const ALLOWED_TTS_FORMATS = new Set(["mp3", "wav", "opus", "aac", "flac"]);
function makeTtsHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body ─────────────────────────────────────────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const text = typeof body.text === "string" ? body.text : "";
if (text.length === 0) {
return jsonError(res, 400, "text_required", "body.text must be a non-empty string");
}
const voice = typeof body.voice === "string" ? body.voice : "";
if (!ALLOWED_TTS_VOICES.has(voice)) {
return jsonError(res, 400, "voice_invalid",
`voice must be one of: ${[...ALLOWED_TTS_VOICES].join(", ")}`);
}
const format = typeof body.format === "string" ? body.format : "";
if (!ALLOWED_TTS_FORMATS.has(format)) {
return jsonError(res, 400, "format_invalid",
`format must be one of: ${[...ALLOWED_TTS_FORMATS].join(", ")}`);
}
const speedRaw = body.speed;
const speed = typeof speedRaw === "number" ? speedRaw : Number(speedRaw);
if (!Number.isFinite(speed) || speed < 0.5 || speed > 1.5) {
return jsonError(res, 400, "speed_out_of_range",
"speed must be a finite number in [0.5, 1.5]");
}
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ──────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
// The TTS prompt commitment is the canonicalisation of the full
// request envelope (text + voice + format + speed). The buyer SDK
// computes the same shape when building the escrow datum.
const expectedPromptHash = sha256Hex(canonicalize({
text, voice, format, speed,
}));
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body");
}
// ── 7. Deadline ─────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 8. Lock ─────────────────────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
// ── 9. Claim tx ─────────────────────────────────────────────────
let claimResult;
try {
claimResult = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} catch (err) {
deps.state.release();
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(err as Error).message}`);
}
try {
await deps.chain.awaitTx(claimResult.expectedTxHash, 60_000);
} catch (err) {
deps.state.release();
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(err as Error).message}`);
}
// ── 10. Spawn runner + 202 ──────────────────────────────────────
const claimedRef: OutputReference = {
txHash: claimResult.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
void runTtsJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: { text, voice, format, speed },
});
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
try { deps.state.release(); } catch { /* ignore */ }
next(err);
return;
}
};
}
// ─── GET /v1/audio/synthesize/:jobId ────────────────────────────────────────
function makeGetTtsJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
if (!("audio_b64" in payload)) {
return jsonError(res, 500, "wrong_payload_kind",
"chat payload returned to tts poll route");
}
return res.status(200).json({
audio_b64: payload.audio_b64,
format: payload.format,
content_type: payload.content_type,
byte_length: payload.byte_length,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── App factory ─────────────────────────────────────────────────────────── // ─── App factory ───────────────────────────────────────────────────────────
export function createApp(deps: SupplierDeps): Application { export function createApp(deps: SupplierDeps): Application {
@@ -422,8 +657,20 @@ export function createApp(deps: SupplierDeps): Application {
app.get("/capability", makeCapabilityHandler(resolved)); app.get("/capability", makeCapabilityHandler(resolved));
app.get("/status", makeStatusHandler(resolved)); app.get("/status", makeStatusHandler(resolved));
app.post("/v1/chat/completions", makeChatHandler(resolved));
app.get("/v1/chat/completions/:jobId", makeGetJobHandler(resolved)); // Dispatch by configured capability. The supplier is a single-capability
// process — it advertises one on-chain capability_id and serves one route
// shape. Hard-mounting the wrong route would let a misconfigured client
// hang on a request that's guaranteed to fail capability validation; we'd
// rather it 404 immediately. (The on-chain capability_id check still runs
// on every request, so this is belt-and-braces.)
if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
} else {
app.post("/v1/chat/completions", makeChatHandler(resolved));
app.get("/v1/chat/completions/:jobId", makeGetJobHandler(resolved));
}
// Centralised error handler (4 args required by Express). // Centralised error handler (4 args required by Express).
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {

View File

@@ -28,10 +28,23 @@ export function buildSampleConfig(): SupplierConfig {
networkId: 0, networkId: 0,
port: 8080, port: 8080,
ollamaTimeoutMs: 120_000, ollamaTimeoutMs: 120_000,
piperUrl: "",
piperTimeoutMs: 120_000,
capabilityKind: "chat",
liveChain: false, liveChain: false,
}; };
} }
/** TTS variant: same wallet/chain plumbing, capabilityKind="tts" + Piper URL set. */
export function buildSampleTtsConfig(): SupplierConfig {
return {
...buildSampleConfig(),
ollamaUrl: "",
piperUrl: "http://piper.fake",
capabilityKind: "tts",
};
}
/** Env map matching the sample config above, for loadConfig() unit tests. */ /** Env map matching the sample config above, for loadConfig() unit tests. */
export function buildSampleEnv(): Record<string, string> { export function buildSampleEnv(): Record<string, string> {
return { return {

View File

@@ -0,0 +1,140 @@
/**
* supplier-piper.test.ts — coverage for supplier/src/piper.ts.
*
* Mirrors the ollama tests: mocks global.fetch (no real PiperTTS host
* required) and asserts request shape, success path, and the typed error
* reasons (`piper_failure`, `piper_timeout`, `piper_malformed`).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { callPiper, PiperError } from "../../supplier/src/piper.js";
const PIPER_URL = "http://piper.fake";
const TEXT = "Hello from the marketplace.";
const VOICE = "nova";
const FORMAT = "mp3";
const SPEED = 1.0;
const TIMEOUT_MS = 5_000;
function makeAudioBuffer(byteLength: number): ArrayBuffer {
const buf = new ArrayBuffer(byteLength);
// Fill with mp3-frame-header-ish bytes so the buffer isn't all-zeros.
const view = new Uint8Array(buf);
for (let i = 0; i < byteLength; i++) view[i] = (i * 37) & 0xff;
return buf;
}
function makeFetchOk(body: ArrayBuffer, contentType = "audio/mpeg") {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? contentType : null) },
arrayBuffer: async () => body,
});
}
function makeFetchError(status: number) {
return vi.fn().mockResolvedValue({
ok: false,
status,
statusText: "Internal Server Error",
headers: { get: () => null },
text: async () => "upstream piper error",
});
}
describe("callPiper() — request shape", () => {
beforeEach(() => {
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(2048)));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("POSTs to ${piperUrl}/v1/audio/speech with OpenAI-shape body", async () => {
await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://piper.fake/v1/audio/speech");
expect(init.method).toBe("POST");
const body = JSON.parse(init.body);
expect(body).toEqual({
model: "tts-1",
input: TEXT,
voice: VOICE,
response_format: FORMAT,
speed: SPEED,
});
});
it("strips trailing slash from piperUrl", async () => {
await callPiper({ piperUrl: "http://piper.fake////", text: "hi", voice: "nova", format: "mp3", speed: 1.0, timeoutMs: TIMEOUT_MS });
const [url] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://piper.fake/v1/audio/speech");
});
});
describe("callPiper() — happy path", () => {
afterEach(() => vi.restoreAllMocks());
it("returns audio bytes + content-type + measured wallclock_ms", async () => {
const buf = makeAudioBuffer(4096);
vi.stubGlobal("fetch", makeFetchOk(buf, "audio/mpeg"));
const result = await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
expect(result.audio).toBeInstanceOf(Uint8Array);
expect(result.audio.byteLength).toBe(4096);
expect(result.contentType).toBe("audio/mpeg");
expect(typeof result.wallclock_ms).toBe("number");
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
});
it("falls back to audio/<format> when upstream omits content-type", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => makeAudioBuffer(128),
}));
const r = await callPiper({ piperUrl: PIPER_URL, text: "hi", voice: "nova", format: "wav", speed: 1.0, timeoutMs: TIMEOUT_MS });
expect(r.contentType).toBe("audio/wav");
});
});
describe("callPiper() — error reasons", () => {
afterEach(() => vi.restoreAllMocks());
it("non-2xx HTTP → PiperError piper_failure", async () => {
vi.stubGlobal("fetch", makeFetchError(500));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
});
it("network throw → PiperError piper_failure", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
});
it("AbortError → PiperError piper_timeout", async () => {
const abortErr = Object.assign(new Error("aborted"), { name: "AbortError" });
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortErr));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_timeout" });
});
it("body smaller than MIN_AUDIO_BYTES → PiperError piper_malformed", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(8)));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_malformed" });
});
it("PiperError exports `reason` for switch dispatch", () => {
const err = new PiperError("piper_timeout", "x");
expect(err.reason).toBe("piper_timeout");
expect(err.name).toBe("PiperError");
});
});

View File

@@ -0,0 +1,131 @@
/**
* supplier-routes-tts.test.ts — coverage for the audio.synthesize.piper.v1
* supplier route added under the path-A multi-capability story.
*
* Focus: the NEW validation logic that lives in `makeTtsHandler`. The
* shared lifecycle pieces (lock, claim tx, awaitTx, runner spawn) reuse
* the chat-handler implementation and are already covered by the chat
* route tests; we don't duplicate that coverage here.
*
* What we DO cover:
* - Mounting: TTS routes appear when capabilityKind="tts" and the chat
* route is absent (and vice-versa)
* - 400 validation: missing X-Escrow-Ref, malformed ref, missing/invalid
* text, voice, format, speed
* - Wiring: route is reachable (express plumbing intact)
*
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
* by the post-deploy smoke against the real testnet, not here.
*/
import { describe, it, expect } from "vitest";
import request from "supertest";
import type { Application } from "express";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import { createApp } from "../../supplier/src/server.js";
import { buildSampleConfig, buildSampleTtsConfig } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
function makeApp(kind: "chat" | "tts"): Application {
return createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: kind === "tts" ? buildSampleTtsConfig() : buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
});
}
function validBody() {
return { text: "Hello world.", voice: "nova", format: "mp3", speed: 1.0 };
}
describe("Capability-routed mounting", () => {
it('TTS supplier exposes /v1/audio/synthesize and NOT /v1/chat/completions', async () => {
const app = makeApp("tts");
const tts = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
// Whatever validation it lands on, it must not 404 (route exists).
expect(tts.status).not.toBe(404);
const chat = await request(app).post("/v1/chat/completions")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send({ messages: [{ role: "user", content: "hi" }] });
expect(chat.status).toBe(404);
});
it('chat supplier exposes /v1/chat/completions and NOT /v1/audio/synthesize', async () => {
const app = makeApp("chat");
const tts = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
expect(tts.status).toBe(404);
});
});
describe("POST /v1/audio/synthesize — header + body validation", () => {
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
const res = await request(makeApp("tts")).post("/v1/audio/synthesize").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_required");
});
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", "not-an-escrow-ref").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_malformed");
});
it("400 text_required when body.text missing or empty", async () => {
const app = makeApp("tts");
for (const body of [{}, { text: "" }, { text: " ", voice: "nova" }]) {
const res = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
// Empty-string text → text_required; whitespace-only also rejected.
// We accept either text_required or voice_invalid (tested separately
// below) — what matters is that this never reaches the chain.
expect(res.status).toBe(400);
}
});
it("400 voice_invalid for unknown voice", async () => {
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), voice: "karen" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("voice_invalid");
});
it("400 format_invalid for unknown format", async () => {
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), format: "ogg" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("format_invalid");
});
it("400 speed_out_of_range for speed below 0.5 / above 1.5", async () => {
const app = makeApp("tts");
for (const speed of [0.4, 1.6, "fast"]) {
const res = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), speed });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("speed_out_of_range");
}
});
it("doesn't reach chain on validation failure (escrow_not_found is what'd come next)", async () => {
// With valid body + ref, the next step is chain.queryUtxo → null → 404
// escrow_not_found. Confirms the validation path is the early gate.
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
// We don't seed the chain → query returns null. This is a chain-side
// 503 (advert_unavailable) since the advert UTxO is also unseeded.
expect([403, 404, 503]).toContain(res.status);
});
});