feat: llm.chat.v1 multi-turn supplier type + free Kimi K2.6 chat demo
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled

Adds a conversational supplier capability and a free demo, both streaming.

New paid `llm.chat.v1` type: one escrow per conversation (flat fee). "Start
chat" posts the escrow with a session-init placeholder prompt_hash and the
supplier Claims (reserving its single slot); turns then stream off-chain via
SSE with zero chain interaction; "End chat" has the supplier Submit a receipt
over the full transcript and the buyer Accept — the moment the user is charged.
The on-chain validator is content-agnostic (pins but never verifies the
hashes), so this reuses the existing contract unchanged. A supplier-side idle
watchdog auto-ends abandoned sessions before deliver_by.

Free Kimi K2.6 chat demo: a synthetic "demo:kimi-chat" tile that uses the same
chat UI but proxies straight to OpenRouter (no escrow, no payment), mirroring
the existing Piper demo.

Shared:
- postChatEscrow.ts + chatSessionPromptHash() (session-init prompt_hash helper
  shared by the buyer builder and the supplier start gate).

Supplier:
- CAPABILITY_KIND="chat-session" (+ CHAT_IDLE_TIMEOUT_MS); callOpenAiStream()
  streaming client; ChatSessionStore + endChatSession(); /v1/chat/{start,
  message,end} handlers with idle + hard-cap watchdog timers.

Buyer:
- Marketplace.startChat()/endChat() with relaxed (session-init) receipt
  verification + crypto verifyReceipt; /v1/chat/{start,message,end} routes
  (SSE passthrough) and a free /v1/chat-demo/message OpenRouter proxy;
  OPENROUTER_API_KEY/OPENROUTER_BASE_URL config; ChatForm.tsx + Dashboard
  KIMI_DEMO_SUPPLIER tile and llm.chat.v1 dispatch.

Deploy:
- docker-compose.supplier-kimi-chat.yml + supplier/.env.kimi-chat.example.

Typecheck passes across all workspaces; full vitest suite green except 15
pre-existing time-bomb/RED failures (tx-claim/accept/post-advert live-CBOR
fixtures with hardcoded past deadlines + the M0-C golden-datum test), confirmed
identical on a clean tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-08 16:18:54 +02:00
parent e01a03b7f2
commit 9acad5b2b8
17 changed files with 1997 additions and 13 deletions

View File

@@ -0,0 +1,62 @@
# supplier/.env.kimi-chat.example
#
# Env for the MAINNET Kimi K2.6 MULTI-TURN CHAT supplier (capability
# `llm.chat.v1`, CAPABILITY_KIND=chat-session). Copy to supplier/.env.kimi-chat
# on the host (chmod 600) and fill in real values. .env.kimi-chat is gitignored.
#
# This is a SEPARATE wallet/advert from supplier/.env.kimi (the one-off Kimi
# supplier) — the chat session holds the single-slot lock for the whole
# conversation, so it runs as its own process.
# ─── Required ─────────────────────────────────────────────────────────────
# 64-char hex Ed25519 private key seed for THIS supplier's wallet. SECRET.
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
# OpenRouter API key (Bearer). SECRET.
OPENAI_API_KEY=PASTE_OPENROUTER_KEY_HERE
# Ogmios WebSocket URL (mainnet).
OGMIOS_URL=wss://ogmios.vector.mainnet.apexfusion.org
# OutputReference of this supplier's on-chain `llm.chat.v1` advert UTxO.
# Post it first (see tx:post-advert below), then paste "<txHash>#0" here.
ADVERT_REF=PASTE_TXHASH_HERE#0
# Mainnet.
NETWORK_ID=1
# Enable real chain submit/await (Claim + Submit).
LIVE_CHAIN=1
# ─── Backend (chat-session is openai-only) ────────────────────────────────
# CAPABILITY_KIND, LLM_BACKEND, OPENAI_BASE_URL, OPENAI_TIMEOUT_MS, and
# CHAT_IDLE_TIMEOUT_MS are pinned in docker-compose.supplier-kimi-chat.yml.
# Override CHAT_IDLE_TIMEOUT_MS here only if you want a different idle window.
# ─── tx:post-advert — run once before first deploy ────────────────────────
# Post an llm.chat.v1 advert with a LARGE max-processing-ms so Claim (start)
# and Submit (end) both land before deliver_by:
# pnpm --filter @marketplace/supplier tx:post-advert \
# --capability-id llm.chat.v1 \
# --model moonshotai/kimi-k2.6 \
# --max-output-tokens 4096 \
# --max-processing-ms 1800000 \
# --price-lovelace 2000000 \
# --endpoint-url https://mp-suppliers-kimi-chat.vector.apexfusion.org
# Copy the printed "<txHash>#0" into ADVERT_REF above.
ADVERT_CAPABILITY_ID=llm.chat.v1
ADVERT_MODEL=moonshotai/kimi-k2.6
ADVERT_MAX_OUTPUT_TOKENS=4096
ADVERT_MAX_PROCESSING_MS=1800000
ADVERT_PRICE_LOVELACE=2000000
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
ADVERT_BUYER_BOND_LOVELACE=1000000
ADVERT_ENDPOINT_URL=https://mp-suppliers-kimi-chat.vector.apexfusion.org
ADVERT_DETAIL_URI=
ADVERT_DETAIL_HASH=
# ─── CIP-33 reference scripts (recommended on mainnet) ────────────────────
# ESCROW_REF_UTXO=<txhash>#0
# ADVERT_REF_UTXO=<txhash>#1
# VECTOR_ZERO_TIME_MS=<epoch-ms>

127
supplier/src/chatSession.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* supplier/src/chatSession.ts — in-memory store for open multi-turn chat
* sessions (capabilityKind="chat-session", capability_id="llm.chat.v1").
*
* Unlike the one-off JobStore (one request → one terminal payload), a chat
* session is long-lived: it's created at /v1/chat/start (after the supplier
* Claims the escrow), accumulates a transcript across many /v1/chat/message
* turns that run fully OFF-CHAIN, and is settled at /v1/chat/end (or by the
* idle watchdog) when the supplier Submits a receipt over the whole transcript.
*
* Single-slot: the supplier serves one paid chat at a time (SupplierState
* mutex), so at most one record is "active". Ended records are retained so
* /v1/chat/end is idempotent (returns the same receipt).
*
* Timer handles (idle + hard-cap) live on the record; server.ts arms them and
* endChatSession clears them.
*/
import type { OutputReference } from "@marketplace/shared/chain";
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
import type { ChatMessage } from "@marketplace/shared/tx";
export type ChatSessionStatus = "active" | "ending" | "ended";
export interface ChatSessionEndResult {
receipt: Record<string, unknown>;
receipt_signature: string;
/** The Submitted escrow UTxO ("<txHash>#<index>") the buyer must Accept. */
submitted_ref: string;
}
export interface ChatSessionRecord {
escrowRef: string;
claimedRef: OutputReference;
advert: AdvertDatum;
escrowDatum: EscrowDatum;
/** Full ordered conversation (user + assistant turns). Hashed at End. */
transcript: ChatMessage[];
promptTokens: number;
completionTokens: number;
startedAtMs: number;
lastActivityMs: number;
status: ChatSessionStatus;
idleTimer?: ReturnType<typeof setTimeout>;
hardCapTimer?: ReturnType<typeof setTimeout>;
endResult?: ChatSessionEndResult;
endFailure?: { reason: string; message: string };
}
export interface CreateChatSessionParams {
escrowRef: string;
claimedRef: OutputReference;
advert: AdvertDatum;
escrowDatum: EscrowDatum;
}
export class ChatSessionStore {
private readonly records = new Map<string, ChatSessionRecord>();
create(params: CreateChatSessionParams): ChatSessionRecord {
const now = Date.now();
const record: ChatSessionRecord = {
escrowRef: params.escrowRef,
claimedRef: params.claimedRef,
advert: params.advert,
escrowDatum: params.escrowDatum,
transcript: [],
promptTokens: 0,
completionTokens: 0,
startedAtMs: now,
lastActivityMs: now,
status: "active",
};
this.records.set(params.escrowRef, record);
return record;
}
get(escrowRef: string): ChatSessionRecord | null {
return this.records.get(escrowRef) ?? null;
}
/** True iff there is a record in "active" or "ending" state for this ref. */
isOpen(escrowRef: string): boolean {
const r = this.records.get(escrowRef);
return r !== undefined && r.status !== "ended";
}
appendUser(escrowRef: string, content: string): void {
const r = this.records.get(escrowRef);
if (!r) return;
r.transcript.push({ role: "user", content });
r.lastActivityMs = Date.now();
}
appendAssistant(
escrowRef: string,
content: string,
usage: { prompt_tokens: number; completion_tokens: number },
): void {
const r = this.records.get(escrowRef);
if (!r) return;
r.transcript.push({ role: "assistant", content });
r.promptTokens += usage.prompt_tokens;
r.completionTokens += usage.completion_tokens;
r.lastActivityMs = Date.now();
}
touch(escrowRef: string): void {
const r = this.records.get(escrowRef);
if (r) r.lastActivityMs = Date.now();
}
clearTimers(record: ChatSessionRecord): void {
if (record.idleTimer) {
clearTimeout(record.idleTimer);
record.idleTimer = undefined;
}
if (record.hardCapTimer) {
clearTimeout(record.hardCapTimer);
record.hardCapTimer = undefined;
}
}
count(): number {
return this.records.size;
}
}

View File

@@ -0,0 +1,120 @@
/**
* supplier/src/chatSessionRunner.ts — settles an open chat session.
*
* endChatSession is the chat-session analogue of runChatJob's receipt+Submit
* tail (jobRunner.ts steps 3-7). It is invoked from BOTH /v1/chat/end (user
* clicked "End chat") and the idle/hard-cap watchdog (abandoned session). It:
* 1. atomically flips the session "active" → "ending" (idempotent re-entry)
* 2. clears the idle + hard-cap timers
* 3. builds + signs a receipt over the FULL transcript
* (response_hash = sha256(canonical(transcript)); the buyer recomputes
* this from its local transcript mirror to verify)
* 4. buildSubmitTx against the Claimed UTxO → awaitTx (60s)
* 5. stores the terminal result and ALWAYS releases the supplier lock
*
* Never throws. On any failure the session is marked "ended" with endFailure
* and the lock is released (the escrow then lingers Claimed until the buyer
* Reclaims after deliver_by — same fate as a failed one-off job).
*/
import { canonicalize } from "@marketplace/shared/cbor";
import { buildSubmitTx } from "@marketplace/shared/tx";
import { buildReceipt, signReceipt, receiptResultHash } from "@marketplace/shared/receipt";
import { createHash } from "crypto";
import type { RunChatJobDeps } from "./jobRunner.js";
import type { ChatSessionStore, ChatSessionRecord } from "./chatSession.js";
function sha256Hex(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
export interface EndChatSessionDeps extends RunChatJobDeps {
sessions: ChatSessionStore;
}
export interface EndChatSessionParams {
deps: EndChatSessionDeps;
escrowRef: string;
/** Identifies what triggered the end, for logs ("end" | "idle" | "hard-cap"). */
trigger?: string;
}
/**
* Settle a chat session. Returns the (now terminal) record, or null if no
* session exists for the ref. Idempotent: a second call returns the same
* record without re-submitting.
*/
export async function endChatSession(
params: EndChatSessionParams,
): Promise<ChatSessionRecord | null> {
const { deps, escrowRef, trigger } = params;
const record = deps.sessions.get(escrowRef);
if (!record) return null;
// Single-threaded between awaits: this check-then-set is atomic enough to
// dedupe concurrent end/idle triggers.
if (record.status !== "active") return record;
record.status = "ending";
deps.sessions.clearTimers(record);
try {
// ── Receipt over the full transcript ──────────────────────────────────
const transcriptHash = sha256Hex(canonicalize(record.transcript));
const receipt = buildReceipt({
prompt_hash: record.escrowDatum.prompt_hash, // session-init placeholder
response_hash: transcriptHash,
model: record.advert.model,
prompt_tokens: record.promptTokens,
completion_tokens: record.completionTokens,
wallclock_ms: Math.max(0, Date.now() - record.startedAtMs),
supplier_pkh: deps.supplierKey.pubKeyHash,
escrow_ref: record.escrowRef,
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
// ── Submit (Claimed → Submitted) ──────────────────────────────────────
let buildResult: { txCborHex: string; expectedTxHash: string };
try {
buildResult = await buildSubmitTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef: record.claimedRef,
receiptHash: resultHash,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[chat_end_failed] escrow=${escrowRef} trigger=${trigger ?? "end"} reason=submit_failed msg=${message}`);
record.endFailure = { reason: "submit_failed", message: `Submit tx failed: ${message}` };
record.status = "ended";
return record;
}
try {
await deps.chain.awaitTx(buildResult.expectedTxHash, 60_000);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[chat_end_failed] escrow=${escrowRef} trigger=${trigger ?? "end"} reason=submit_timeout msg=${message}`);
record.endFailure = { reason: "submit_timeout", message: `Submit awaitTx failed: ${message}` };
record.status = "ended";
return record;
}
record.endResult = {
receipt: signed.receipt as unknown as Record<string, unknown>,
receipt_signature: signed.signature,
// The Submit tx output (index 0) is the new Submitted UTxO the buyer Accepts.
submitted_ref: `${buildResult.expectedTxHash}#0`,
};
record.status = "ended";
return record;
} finally {
// Always release the single-slot lock so the next chat can start.
try {
deps.state.release();
} catch {
/* never let lock-release escape */
}
}
}

View File

@@ -56,8 +56,12 @@ export interface AdvertRef {
index: number;
}
/** Capability the supplier is configured to serve. */
export type CapabilityKind = "chat" | "tts";
/** Capability the supplier is configured to serve.
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
* "tts" — `audio.synthesize.piper.v1`
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
* streaming). Requires LLM_BACKEND="openai". */
export type CapabilityKind = "chat" | "tts" | "chat-session";
/** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai";
@@ -85,6 +89,13 @@ export interface SupplierConfig {
piperTimeoutMs: number;
capabilityKind: CapabilityKind;
llmBackend: LlmBackend;
/**
* Idle-timeout for an open chat session, ms. A session with no message
* activity for this long is auto-ended (supplier Submits the transcript
* receipt). Only meaningful for capabilityKind="chat-session". Default
* 300_000 (5 min).
*/
chatIdleTimeoutMs: number;
/**
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -136,8 +147,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts") {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat" or "tts"');
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
}
const capabilityKind: CapabilityKind = capKindStr;
@@ -157,7 +168,16 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
let ollamaUrl = "";
let openaiBaseUrl = "";
let piperUrl = "";
if (capabilityKind === "chat") {
if (capabilityKind === "chat-session") {
// Multi-turn chat streams via callOpenAiStream, which only speaks the
// OpenAI-compatible wire format — so chat-session is openai-only.
if (llmBackend !== "openai") {
throw new Error('loadConfig: CAPABILITY_KIND="chat-session" requires LLM_BACKEND="openai"');
}
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_URL ?? "";
} else if (capabilityKind === "chat") {
if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
@@ -210,6 +230,16 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
const openaiApiKey = env.OPENAI_API_KEY ?? "";
// 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;
if (chatIdleStr !== undefined && chatIdleStr !== "") {
if (!POS_INT_RE.test(chatIdleStr)) {
throw new Error("loadConfig: CHAT_IDLE_TIMEOUT_MS must be a positive integer");
}
chatIdleTimeoutMs = Number(chatIdleStr);
}
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -249,6 +279,7 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
piperTimeoutMs,
capabilityKind,
llmBackend,
chatIdleTimeoutMs,
liveChain,
walletHealthIntervalMs,
};

View File

@@ -167,3 +167,163 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
wallclock_ms: wallclockMs,
};
}
/**
* callOpenAiStream — streaming variant of callOpenAi for the chat-session
* supplier. POSTs with `stream: true` and invokes `onToken(delta)` for each
* content delta as it arrives, then returns the SAME OpenAiResult shape
* (full accumulated content + token usage) so receipt-building code is shared.
*
* Parses the OpenAI/OpenRouter SSE wire format:
* data: {"choices":[{"delta":{"content":"tok"}}]}\n\n
* ...
* data: {"choices":[],"usage":{prompt_tokens,completion_tokens,...}}\n\n (include_usage)
* data: [DONE]\n\n
*
* Error reasons match callOpenAi (openai_failure / openai_timeout / openai_malformed).
* The timeout bounds the WHOLE stream (AbortController), matching the non-stream path.
*/
export async function callOpenAiStream(
params: CallOpenAiParams,
onToken: (delta: string) => void,
): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey } = params;
const url = `${baseUrl}/v1/chat/completions`;
const body = JSON.stringify({
model,
messages,
stream: true,
stream_options: { include_usage: true },
});
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "text/event-stream",
};
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI request exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!response.ok) {
clearTimeout(timer);
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OpenAiError(
"openai_failure",
`OpenAI returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
if (!response.body) {
clearTimeout(timer);
throw new OpenAiError("openai_malformed", "OpenAI streaming response had no body");
}
let accumulated = "";
let promptTokens = 0;
let completionTokens = 0;
let buffer = "";
const handleData = (payload: string): void => {
const trimmed = payload.trim();
if (trimmed.length === 0 || trimmed === "[DONE]") return;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
// Skip non-JSON keepalive/comment frames.
return;
}
if (!parsed || typeof parsed !== "object") return;
const obj = parsed as Record<string, unknown>;
const choices = obj.choices;
if (Array.isArray(choices) && choices.length > 0) {
const delta = (choices[0] as Record<string, unknown>)?.delta as
| Record<string, unknown>
| undefined;
const content = delta?.content;
if (typeof content === "string" && content.length > 0) {
accumulated += content;
onToken(content);
}
}
const usageRaw = obj.usage;
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;
}
};
try {
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line.
let sepIdx: number;
while ((sepIdx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
for (const line of frame.split("\n")) {
if (line.startsWith("data:")) handleData(line.slice(5));
}
}
}
// Flush any trailing frame without a final blank line.
if (buffer.length > 0) {
for (const line of buffer.split("\n")) {
if (line.startsWith("data:")) handleData(line.slice(5));
}
}
} catch (err) {
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI stream exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI stream read failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (accumulated.length === 0) {
throw new OpenAiError("openai_malformed", "OpenAI stream produced no content");
}
return {
content: accumulated,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: Date.now() - startedAt,
};
}

View File

@@ -38,12 +38,15 @@ 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 { WalletKey } from "@marketplace/shared/tx";
import { buildClaimTx, mockSlotToWallclockMs, detectCborBackend } from "@marketplace/shared/tx";
import { buildClaimTx, chatSessionPromptHash, mockSlotToWallclockMs, detectCborBackend } from "@marketplace/shared/tx";
import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.js";
import { runChatJob, runTtsJob } from "./jobRunner.js";
import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
import { callOpenAiStream } from "./openai.js";
import { healthzRouter } from "./routes/healthz.js";
import { triggerOnFailureConsolidate } from "./walletHealth.js";
@@ -54,6 +57,8 @@ export interface SupplierDeps {
supplierKey: WalletKey;
/** Optional: defaults to a fresh JobStore. M1-F-async-chat. */
jobs?: JobStore;
/** Optional: defaults to a fresh ChatSessionStore (chat-session capability). */
chatSessions?: ChatSessionStore;
}
const ESCROW_REF_RE = /^[0-9a-fA-F]{64}#(?:0|[1-9]\d*)$/;
@@ -83,6 +88,7 @@ interface ResolvedDeps {
config: SupplierConfig;
supplierKey: WalletKey;
jobs: JobStore;
chatSessions: ChatSessionStore;
}
function makeCapabilityHandler(deps: ResolvedDeps) {
@@ -646,6 +652,272 @@ function makeGetTtsJobHandler(deps: ResolvedDeps) {
};
}
// ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
//
// Escrow bookends with off-chain turns:
// start → validate (mirror makeChatHandler 1-7, but no messages) → Claim
// (Open→Claimed) → create session + arm idle/hard-cap watchdog → 200
// message → SSE stream a turn via callOpenAiStream; zero chain interaction
// end → endChatSession (Submit transcript receipt; Claimed→Submitted) → 200
//
// The buyer then Accepts off this route (server-side, in the buyer-app),
// which is when the user is actually charged. Single-slot: the SupplierState
// mutex is held from Claim (start) to Submit (end), so one paid chat at a time.
interface ChatStartBody {
model?: unknown;
session_nonce?: unknown;
}
function makeChatSessionHandlers(deps: ResolvedDeps) {
const endDeps: EndChatSessionDeps = {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
sessions: deps.chatSessions,
};
function armIdleTimer(record: ChatSessionRecord): void {
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = setTimeout(() => {
void endChatSession({ deps: endDeps, escrowRef: record.escrowRef, trigger: "idle" });
}, deps.config.chatIdleTimeoutMs);
record.idleTimer.unref?.();
}
function armHardCapTimer(record: ChatSessionRecord, nowMs: number): void {
// Force-end before deliver_by so the Submit tx (60s awaitTx budget) lands
// in time. 90s margin = Submit awaitTx + buffer.
const margin = 90_000;
const capMs = Math.max(1_000, record.escrowDatum.deliver_by - nowMs - margin);
record.hardCapTimer = setTimeout(() => {
void endChatSession({ deps: endDeps, escrowRef: record.escrowRef, trigger: "hard-cap" });
}, capMs);
record.hardCapTimer.unref?.();
}
const start = 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 (session_nonce; no messages) ─────────────────────────
const body = (req.body ?? {}) as ChatStartBody;
const sessionNonce = typeof body.session_nonce === "string" ? body.session_nonce : "";
if (sessionNonce.length === 0) {
return jsonError(res, 400, "session_nonce_required", "body.session_nonce must be a non-empty string");
}
// ── 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 (request spec + session-init prompt) ──────────
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");
}
const expectedPromptHash = chatSessionPromptHash({ session_nonce: sessionNonce });
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch", "prompt_hash in escrow does not match session_nonce");
}
// ── 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. Single-slot lock ──────────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another chat");
}
// ── 9. Claim (Open → Claimed) — hold the lock on success ─────────
let claimResult;
try {
claimResult = await buildClaimTx({ chain: deps.chain, supplierKey: deps.supplierKey, escrowRef });
} catch (err) {
deps.state.release();
triggerOnFailureConsolidate({ chain: deps.chain, state: deps.state, supplierKey: deps.supplierKey });
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. Create session + arm watchdog ────────────────────────────
const claimedRef: OutputReference = { txHash: claimResult.expectedTxHash, index: 0 };
const record = deps.chatSessions.create({ escrowRef: escrowRefStr, claimedRef, advert, escrowDatum });
armIdleTimer(record);
armHardCapTimer(record, nowMs);
return res.status(200).json({ status: "claimed", escrow_ref: escrowRefStr });
} catch (err) {
try { deps.state.release(); } catch { /* ignore */ }
next(err);
return;
}
};
const message = async (req: Request, res: Response, next: NextFunction) => {
try {
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}`;
const record = deps.chatSessions.get(escrowRefStr);
if (!record || record.status !== "active") {
return jsonError(res, 404, "chat_session_not_found",
`no active chat session for ${escrowRefStr}`);
}
const body = (req.body ?? {}) as { content?: unknown };
const content = typeof body.content === "string" ? body.content : "";
if (content.length === 0) {
return jsonError(res, 400, "content_required", "body.content must be a non-empty string");
}
// Pause the idle timer while a turn is in flight; re-arm when it ends so
// a long generation never trips the auto-end mid-stream.
if (record.idleTimer) { clearTimeout(record.idleTimer); record.idleTimer = undefined; }
deps.chatSessions.appendUser(escrowRefStr, content);
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof (res as Response & { flushHeaders?: () => void }).flushHeaders === "function") {
(res as Response & { flushHeaders: () => void }).flushHeaders();
}
const sse = (frame: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(frame)}\n\n`);
};
try {
const result = await callOpenAiStream(
{
baseUrl: deps.config.openaiBaseUrl,
model: record.advert.model,
messages: record.transcript,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
},
(delta) => sse({ type: "token", value: delta }),
);
deps.chatSessions.appendAssistant(escrowRefStr, result.content, {
prompt_tokens: result.prompt_tokens,
completion_tokens: result.completion_tokens,
});
sse({ type: "done" });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
sse({ type: "error", message: errMsg });
} finally {
if (record.status === "active") armIdleTimer(record);
res.end();
}
return;
} catch (err) {
next(err);
return;
}
};
const end = async (req: Request, res: Response, next: NextFunction) => {
try {
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}`;
if (!deps.chatSessions.get(escrowRefStr)) {
return jsonError(res, 404, "chat_session_not_found", `no chat session for ${escrowRefStr}`);
}
const record = await endChatSession({ deps: endDeps, escrowRef: escrowRefStr, trigger: "end" });
if (!record) {
return jsonError(res, 404, "chat_session_not_found", `no chat session for ${escrowRefStr}`);
}
if (record.endFailure) {
return jsonError(res, 502, record.endFailure.reason, record.endFailure.message);
}
if (!record.endResult) {
return jsonError(res, 500, "chat_end_incomplete", "session ended without a receipt");
}
return res.status(200).json({
status: "submitted",
escrow_ref: escrowRefStr,
submitted_ref: record.endResult.submitted_ref,
receipt: record.endResult.receipt,
receipt_signature: record.endResult.receipt_signature,
});
} catch (err) {
next(err);
return;
}
};
return { start, message, end };
}
// ─── App factory ───────────────────────────────────────────────────────────
export function createApp(deps: SupplierDeps): Application {
@@ -655,6 +927,7 @@ export function createApp(deps: SupplierDeps): Application {
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs ?? new JobStore(),
chatSessions: deps.chatSessions ?? new ChatSessionStore(),
};
const app = express();
@@ -677,6 +950,11 @@ export function createApp(deps: SupplierDeps): Application {
if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
} else if (resolved.config.capabilityKind === "chat-session") {
const chat = makeChatSessionHandlers(resolved);
app.post("/v1/chat/start", chat.start);
app.post("/v1/chat/message", chat.message);
app.post("/v1/chat/end", chat.end);
} else {
app.post("/v1/chat/completions", makeChatHandler(resolved));
app.get("/v1/chat/completions/:jobId", makeGetJobHandler(resolved));