feat(gateway,supplier,shared): shared demo key with session-backed OpenAI endpoint + tool-calling

MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.

- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
  chatMessagesEquivalent keep supplier transcript and gateway mirror
  hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
  legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
  delta.tool_calls, allows content-less tool turns; done frame carries the
  assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
  withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
  limit (demo keys skip the shared per-key limit), models filtered to chat
  capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
  calls onto open sessions, forwards only unseen deltas, transparently
  reopens on supplier idle-close; never falls back to one-shot (its settle
  path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
  turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
  cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
  lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
david
2026-07-07 10:30:17 +02:00
parent b92bff56a7
commit 4ebe7c9683
35 changed files with 1876 additions and 114 deletions

View File

@@ -0,0 +1,88 @@
/**
* tx/chatMessage.ts — canonical ChatMessage normalization + equivalence.
*
* The supplier's session transcript and the gateway's in-memory mirror must be
* hash-identical (receipt response_hash = sha256(canonicalize(transcript))),
* so both sides build messages through normalizeChatMessage: optional fields
* are truly absent when empty, content is never null, and vendor extras are
* stripped. chatMessagesEquivalent is the tolerant comparison used by the
* demo-endpoint transcript-prefix affinity matcher.
*/
import type { ChatMessage, ToolCall } from "./types.js";
const ROLES = new Set(["system", "user", "assistant", "tool"]);
function normalizeToolCall(raw: unknown): ToolCall | null {
if (typeof raw !== "object" || raw === null) return null;
const tc = raw as Record<string, unknown>;
const fn = tc.function as Record<string, unknown> | undefined;
if (typeof tc.id !== "string" || tc.id === "") return null;
if (tc.type !== "function") return null;
if (typeof fn !== "object" || fn === null) return null;
if (typeof fn.name !== "string" || fn.name === "") return null;
if (typeof fn.arguments !== "string") return null;
return { id: tc.id, type: "function", function: { name: fn.name, arguments: fn.arguments } };
}
/**
* Validate and canonicalize an untrusted message-shaped value.
* Returns null when the value is not a usable ChatMessage.
*/
export function normalizeChatMessage(raw: unknown): ChatMessage | null {
if (typeof raw !== "object" || raw === null) return null;
const m = raw as Record<string, unknown>;
if (typeof m.role !== "string" || !ROLES.has(m.role)) return null;
const role = m.role as ChatMessage["role"];
let content: string;
if (typeof m.content === "string") content = m.content;
else if (m.content === null || m.content === undefined) content = "";
else return null;
const msg: ChatMessage = { role, content };
if (m.tool_calls !== undefined && m.tool_calls !== null) {
if (role !== "assistant" || !Array.isArray(m.tool_calls)) return null;
const calls: ToolCall[] = [];
for (const raw of m.tool_calls) {
const tc = normalizeToolCall(raw);
if (!tc) return null;
calls.push(tc);
}
if (calls.length > 0) msg.tool_calls = calls;
}
if (m.tool_call_id !== undefined && m.tool_call_id !== null) {
if (role !== "tool" || typeof m.tool_call_id !== "string" || m.tool_call_id === "") return null;
msg.tool_call_id = m.tool_call_id;
}
if (role === "tool" && msg.tool_call_id === undefined) return null;
return msg;
}
function toolCallsEqual(a?: ToolCall[], b?: ToolCall[]): boolean {
const la = a ?? [];
const lb = b ?? [];
if (la.length !== lb.length) return false;
return la.every((tc, i) =>
tc.id === lb[i].id &&
tc.function.name === lb[i].function.name &&
tc.function.arguments === lb[i].function.arguments,
);
}
/**
* Tolerant equality for affinity prefix matching. Both sides are expected to
* already be normalized; comparison ignores incidental key order and treats
* missing tool fields as empty.
*/
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
return (
a.role === b.role &&
a.content === b.content &&
(a.tool_call_id ?? "") === (b.tool_call_id ?? "") &&
toolCallsEqual(a.tool_calls, b.tool_calls)
);
}

View File

@@ -2,8 +2,9 @@
* tx/index.ts — public exports for M1-B tx builders.
*/
export type { ChatMessage, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
export { TxConstructionError } from "./types.js";
export { normalizeChatMessage, chatMessagesEquivalent } from "./chatMessage.js";
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
export type { Signer } from "./signer.js";

View File

@@ -11,14 +11,29 @@
import type { OutputReference } from "../chain/ChainProvider.js";
/**
* ToolCall — OpenAI-compatible function tool call emitted by an assistant turn.
* `function.arguments` is the raw JSON string as produced by the model.
*/
export interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
/**
* ChatMessage — OpenAI-compatible chat message.
* v1 supports user/system/assistant roles. The wire shape is intentionally
* narrow; future capabilities (tools, multimodal) will extend or replace it.
* `content` is always a string ("" for tool-call-only assistant messages —
* OpenAI's `content: null` is normalized on ingest). `tool_calls` /
* `tool_call_id` MUST be omitted (not set to undefined) when absent: the
* receipt hash canonicalizes the transcript objects, so field presence is
* part of the identity shared between supplier transcript and gateway mirror.
*/
export interface ChatMessage {
role: "system" | "user" | "assistant";
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
/**