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:
@@ -7,13 +7,24 @@
|
||||
* Required env vars:
|
||||
* SUPPLIER_PRIV_KEY_HEX — 64-char hex Ed25519 private key
|
||||
* OGMIOS_URL — ws:// or wss:// URL
|
||||
* OLLAMA_URL — http:// URL for local Ollama
|
||||
* ADVERT_REF — "<64hex>#<int>" OutputReference to the supplier's advert UTxO
|
||||
* 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:
|
||||
* 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)
|
||||
* 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).
|
||||
* Any other value (including "true", "yes", "TRUE")
|
||||
* keeps the supplier in read-only mode for safety.
|
||||
@@ -32,14 +43,22 @@ export interface AdvertRef {
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** Capability the supplier is configured to serve. */
|
||||
export type CapabilityKind = "chat" | "tts";
|
||||
|
||||
export interface SupplierConfig {
|
||||
supplierPrivKeyHex: string;
|
||||
ogmiosUrl: string;
|
||||
/** Empty string when capabilityKind="tts". */
|
||||
ollamaUrl: string;
|
||||
/** Empty string when capabilityKind="chat". */
|
||||
piperUrl: string;
|
||||
advertRef: AdvertRef;
|
||||
networkId: 0 | 1;
|
||||
port: number;
|
||||
ollamaTimeoutMs: number;
|
||||
piperTimeoutMs: number;
|
||||
capabilityKind: CapabilityKind;
|
||||
/**
|
||||
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
|
||||
* 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 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 advertRef = parseAdvertRef(advertRefStr);
|
||||
|
||||
@@ -110,6 +151,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
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
|
||||
// value (including "true", "yes", "TRUE", "", undefined) keeps the supplier
|
||||
// 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,
|
||||
ogmiosUrl,
|
||||
ollamaUrl,
|
||||
piperUrl,
|
||||
advertRef,
|
||||
networkId,
|
||||
port,
|
||||
ollamaTimeoutMs,
|
||||
piperTimeoutMs,
|
||||
capabilityKind,
|
||||
liveChain,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,13 @@ import { createHash } from "crypto";
|
||||
import type { SupplierState } from "./state.js";
|
||||
import type { SupplierConfig } from "./config.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 {
|
||||
chain: ChainProvider;
|
||||
@@ -145,7 +151,8 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
}
|
||||
|
||||
// ── 6. Mark complete ────────────────────────────────────────────────
|
||||
const payload: JobResponsePayload = {
|
||||
const payload: ChatJobResponsePayload = {
|
||||
kind: "chat",
|
||||
choices: [
|
||||
{
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@
|
||||
|
||||
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<{
|
||||
index: number;
|
||||
message: { role: "assistant"; content: string };
|
||||
@@ -41,6 +47,30 @@ export interface JobResponsePayload {
|
||||
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 {
|
||||
httpStatus: number;
|
||||
reason: string;
|
||||
|
||||
143
supplier/src/piper.ts
Normal file
143
supplier/src/piper.ts
Normal 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 };
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import { buildClaimTx, mockSlotToWallclockMs, detectCborBackend } from "@marketp
|
||||
import type { SupplierState } from "./state.js";
|
||||
import type { SupplierConfig } from "./config.js";
|
||||
import { JobStore } from "./jobs.js";
|
||||
import { runChatJob } from "./jobRunner.js";
|
||||
import { runChatJob, runTtsJob } from "./jobRunner.js";
|
||||
import { healthzRouter } from "./routes/healthz.js";
|
||||
|
||||
export interface SupplierDeps {
|
||||
@@ -382,6 +382,13 @@ function makeGetJobHandler(deps: ResolvedDeps) {
|
||||
}
|
||||
if (record.status === "done") {
|
||||
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({
|
||||
choices: payload.choices,
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function createApp(deps: SupplierDeps): Application {
|
||||
@@ -422,8 +657,20 @@ export function createApp(deps: SupplierDeps): Application {
|
||||
|
||||
app.get("/capability", makeCapabilityHandler(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).
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
|
||||
Reference in New Issue
Block a user