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
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:
62
supplier/.env.kimi-chat.example
Normal file
62
supplier/.env.kimi-chat.example
Normal 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
127
supplier/src/chatSession.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
120
supplier/src/chatSessionRunner.ts
Normal file
120
supplier/src/chatSessionRunner.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user