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>
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
/**
|
|
* supplier/src/config.ts — Load and validate supplier configuration from env.
|
|
*
|
|
* loadConfig(env) is a pure function (takes a Record<string, string> rather than
|
|
* reading process.env directly) so tests can inject fake environments.
|
|
*
|
|
* Required env vars:
|
|
* SUPPLIER_PRIV_KEY_HEX — 64-char hex Ed25519 private key
|
|
* OGMIOS_URL — ws:// or wss:// URL
|
|
* ADVERT_REF — "<64hex>#<int>" OutputReference to the supplier's advert UTxO
|
|
* NETWORK_ID — "0" (testnet) or "1" (mainnet)
|
|
*
|
|
* Required for CAPABILITY_KIND="chat" (default) + LLM_BACKEND="ollama" (default):
|
|
* OLLAMA_URL — http:// URL for local Ollama
|
|
*
|
|
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
|
|
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
|
|
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
|
|
*
|
|
* 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.
|
|
* LLM_BACKEND — "ollama" (default) or "openai". Selects which HTTP
|
|
* client the chat job runner uses. Only meaningful
|
|
* when CAPABILITY_KIND="chat".
|
|
* PORT — listen port (default 8080)
|
|
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
|
|
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
|
|
* first-token latency is longer than local Ollama)
|
|
* 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.
|
|
* WALLET_HEALTH_INTERVAL_MS
|
|
* — periodic wallet self-consolidate tick interval
|
|
* (default 600_000 = 10 min). "0" disables the
|
|
* ticker. Only fires while LIVE_CHAIN=1.
|
|
*
|
|
* On error: throws Error whose message names the offending field (matches
|
|
* supplier-config.test.ts assertion that error message mentions field name).
|
|
*/
|
|
|
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
|
const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
|
|
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
|
|
const POS_INT_RE = /^[1-9]\d*$/;
|
|
|
|
export interface AdvertRef {
|
|
txHash: string;
|
|
index: number;
|
|
}
|
|
|
|
/** 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";
|
|
|
|
export interface SupplierConfig {
|
|
supplierPrivKeyHex: string;
|
|
ogmiosUrl: string;
|
|
/** Empty string when capabilityKind="tts" or llmBackend="openai". */
|
|
ollamaUrl: string;
|
|
/** Empty string when capabilityKind="tts" or llmBackend="ollama". */
|
|
openaiBaseUrl: string;
|
|
/**
|
|
* Bearer token forwarded as `Authorization: Bearer <key>` when llmBackend="openai".
|
|
* Empty string means no header is sent (correct for ChatMock and other localhost
|
|
* proxies). Required for hosted OpenAI-compatible APIs (DeepSeek, OpenAI, etc.).
|
|
*/
|
|
openaiApiKey: string;
|
|
/** Empty string when capabilityKind="chat". */
|
|
piperUrl: string;
|
|
advertRef: AdvertRef;
|
|
networkId: 0 | 1;
|
|
port: number;
|
|
ollamaTimeoutMs: number;
|
|
openaiTimeoutMs: number;
|
|
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).
|
|
* Only the literal env value "1" sets this to true.
|
|
*/
|
|
liveChain: boolean;
|
|
/**
|
|
* Periodic wallet self-consolidate tick interval, ms. 0 disables the ticker.
|
|
* Default 600_000 (10 min). Only fires while liveChain=true.
|
|
*/
|
|
walletHealthIntervalMs: number;
|
|
}
|
|
|
|
function requireField(env: Record<string, string | undefined>, name: string): string {
|
|
const v = env[name];
|
|
if (v === undefined || v === null || v === "") {
|
|
throw new Error(`loadConfig: missing required env var ${name}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
export function parseAdvertRef(ref: string): AdvertRef {
|
|
if (typeof ref !== "string" || ref.length === 0) {
|
|
throw new Error('parseAdvertRef: ref must be a non-empty "<txHash>#<index>" string');
|
|
}
|
|
const hashIdx = ref.indexOf("#");
|
|
if (hashIdx < 0) {
|
|
throw new Error('parseAdvertRef: missing "#" separator in advert ref');
|
|
}
|
|
const txHash = ref.slice(0, hashIdx);
|
|
const indexStr = ref.slice(hashIdx + 1);
|
|
if (!TX_HASH_RE.test(txHash)) {
|
|
throw new Error("parseAdvertRef: txHash must be 64 hex chars (32 bytes)");
|
|
}
|
|
if (!NON_NEG_INT_RE.test(indexStr)) {
|
|
throw new Error("parseAdvertRef: index must be a non-negative integer");
|
|
}
|
|
return { txHash, index: Number(indexStr) };
|
|
}
|
|
|
|
export function loadConfig(env: Record<string, string | undefined>): SupplierConfig {
|
|
const supplierPrivKeyHex = requireField(env, "SUPPLIER_PRIV_KEY_HEX");
|
|
if (!HEX64_RE.test(supplierPrivKeyHex)) {
|
|
throw new Error("loadConfig: SUPPLIER_PRIV_KEY_HEX must be 64 hex chars (32 bytes)");
|
|
}
|
|
|
|
const ogmiosUrl = requireField(env, "OGMIOS_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" && capKindStr !== "chat-session") {
|
|
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
|
|
}
|
|
const capabilityKind: CapabilityKind = capKindStr;
|
|
|
|
// LLM_BACKEND selects which HTTP client the chat job runner uses. Only
|
|
// meaningful for capabilityKind="chat"; ignored when capabilityKind="tts".
|
|
// Default "ollama" so existing chat suppliers keep booting unchanged.
|
|
const llmBackendStr = env.LLM_BACKEND ?? "ollama";
|
|
if (llmBackendStr !== "ollama" && llmBackendStr !== "openai") {
|
|
throw new Error('loadConfig: LLM_BACKEND must be "ollama" or "openai"');
|
|
}
|
|
const llmBackend: LlmBackend = llmBackendStr;
|
|
|
|
// 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 openaiBaseUrl = "";
|
|
let piperUrl = "";
|
|
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 ?? "";
|
|
} else {
|
|
ollamaUrl = requireField(env, "OLLAMA_URL");
|
|
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
|
|
}
|
|
piperUrl = env.PIPER_URL ?? "";
|
|
} else {
|
|
piperUrl = requireField(env, "PIPER_URL");
|
|
ollamaUrl = env.OLLAMA_URL ?? "";
|
|
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
|
|
}
|
|
|
|
const advertRefStr = requireField(env, "ADVERT_REF");
|
|
const advertRef = parseAdvertRef(advertRefStr);
|
|
|
|
const networkIdStr = requireField(env, "NETWORK_ID");
|
|
if (networkIdStr !== "0" && networkIdStr !== "1") {
|
|
throw new Error('loadConfig: NETWORK_ID must be "0" (testnet) or "1" (mainnet)');
|
|
}
|
|
const networkId: 0 | 1 = networkIdStr === "1" ? 1 : 0;
|
|
|
|
const portStr = env.PORT;
|
|
let port = 8080;
|
|
if (portStr !== undefined && portStr !== "") {
|
|
if (!POS_INT_RE.test(portStr)) {
|
|
throw new Error("loadConfig: PORT must be a positive integer");
|
|
}
|
|
port = Number(portStr);
|
|
}
|
|
|
|
const timeoutStr = env.OLLAMA_TIMEOUT_MS;
|
|
let ollamaTimeoutMs = 120_000;
|
|
if (timeoutStr !== undefined && timeoutStr !== "") {
|
|
if (!POS_INT_RE.test(timeoutStr)) {
|
|
throw new Error("loadConfig: OLLAMA_TIMEOUT_MS must be a positive integer");
|
|
}
|
|
ollamaTimeoutMs = Number(timeoutStr);
|
|
}
|
|
|
|
const openaiTimeoutStr = env.OPENAI_TIMEOUT_MS;
|
|
let openaiTimeoutMs = 180_000;
|
|
if (openaiTimeoutStr !== undefined && openaiTimeoutStr !== "") {
|
|
if (!POS_INT_RE.test(openaiTimeoutStr)) {
|
|
throw new Error("loadConfig: OPENAI_TIMEOUT_MS must be a positive integer");
|
|
}
|
|
openaiTimeoutMs = Number(openaiTimeoutStr);
|
|
}
|
|
|
|
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 !== "") {
|
|
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.
|
|
const liveChain = env.LIVE_CHAIN === "1";
|
|
|
|
// WALLET_HEALTH_INTERVAL_MS accepts any non-negative integer; "0" disables.
|
|
const whIntervalStr = env.WALLET_HEALTH_INTERVAL_MS;
|
|
let walletHealthIntervalMs = 600_000;
|
|
if (whIntervalStr !== undefined && whIntervalStr !== "") {
|
|
if (!NON_NEG_INT_RE.test(whIntervalStr)) {
|
|
throw new Error("loadConfig: WALLET_HEALTH_INTERVAL_MS must be a non-negative integer");
|
|
}
|
|
walletHealthIntervalMs = Number(whIntervalStr);
|
|
}
|
|
|
|
return {
|
|
supplierPrivKeyHex,
|
|
ogmiosUrl,
|
|
ollamaUrl,
|
|
openaiBaseUrl,
|
|
openaiApiKey,
|
|
piperUrl,
|
|
advertRef,
|
|
networkId,
|
|
port,
|
|
ollamaTimeoutMs,
|
|
openaiTimeoutMs,
|
|
piperTimeoutMs,
|
|
capabilityKind,
|
|
llmBackend,
|
|
chatIdleTimeoutMs,
|
|
liveChain,
|
|
walletHealthIntervalMs,
|
|
};
|
|
}
|