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>
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
/**
|
|
* 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 */
|
|
}
|
|
}
|
|
}
|