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,96 @@
/**
* shared-chat-message.test.ts — normalizeChatMessage / chatMessagesEquivalent.
*
* The supplier transcript and the gateway mirror must canonicalize to the SAME
* string (receipt response_hash = sha256(canonicalize(transcript))), so the
* normalizer's field-presence behavior is load-bearing: absent optional fields
* must be truly absent, content is never null, vendor extras are stripped.
*/
import { describe, it, expect } from "vitest";
import { normalizeChatMessage, chatMessagesEquivalent } from "../../packages/shared/src/tx/chatMessage.js";
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
describe("normalizeChatMessage", () => {
it("passes through plain user/system/assistant messages", () => {
expect(normalizeChatMessage({ role: "user", content: "hi" })).toEqual({ role: "user", content: "hi" });
expect(normalizeChatMessage({ role: "system", content: "be nice" })).toEqual({ role: "system", content: "be nice" });
});
it("normalizes null/undefined content to empty string", () => {
expect(normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })).toEqual({
role: "assistant",
content: "",
tool_calls: [TOOL_CALL],
});
});
it("keeps optional fields ABSENT when not provided (hash identity)", () => {
const msg = normalizeChatMessage({ role: "assistant", content: "x" })!;
expect("tool_calls" in msg).toBe(false);
expect("tool_call_id" in msg).toBe(false);
});
it("strips vendor extras from tool calls", () => {
const msg = normalizeChatMessage({
role: "assistant",
content: "",
tool_calls: [{ ...TOOL_CALL, extra_vendor_field: 42, function: { ...TOOL_CALL.function, parsed: {} } }],
})!;
expect(msg.tool_calls).toEqual([TOOL_CALL]);
});
it("requires tool role to carry tool_call_id", () => {
expect(normalizeChatMessage({ role: "tool", content: "result" })).toBeNull();
expect(normalizeChatMessage({ role: "tool", content: "result", tool_call_id: "call_1" })).toEqual({
role: "tool",
content: "result",
tool_call_id: "call_1",
});
});
it("rejects tool_calls on non-assistant roles and malformed shapes", () => {
expect(normalizeChatMessage({ role: "user", content: "x", tool_calls: [TOOL_CALL] })).toBeNull();
expect(normalizeChatMessage({ role: "assistant", content: "", tool_calls: [{ id: "", type: "function", function: { name: "f", arguments: "" } }] })).toBeNull();
expect(normalizeChatMessage({ role: "bogus", content: "x" })).toBeNull();
expect(normalizeChatMessage("nope")).toBeNull();
expect(normalizeChatMessage({ role: "user", content: 42 })).toBeNull();
});
it("canonicalizes identically regardless of input key order (mirror vs transcript)", () => {
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
const b = normalizeChatMessage({
tool_calls: [{ function: { arguments: "{}", name: "get_time" }, type: "function", id: "call_1" }],
content: null,
role: "assistant",
})!;
expect(canonicalize([a])).toBe(canonicalize([b]));
});
});
describe("chatMessagesEquivalent", () => {
it("matches normalized equals and rejects differences", () => {
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
const b = normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })!;
expect(chatMessagesEquivalent(a, b)).toBe(true);
const c = normalizeChatMessage({ role: "assistant", content: "hi" })!;
expect(chatMessagesEquivalent(a, c)).toBe(false);
const d = normalizeChatMessage({
role: "assistant",
content: "",
tool_calls: [{ ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } }],
})!;
expect(chatMessagesEquivalent(a, d)).toBe(false);
});
it("compares tool results by tool_call_id", () => {
const a = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
const b = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
const c = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_2" })!;
expect(chatMessagesEquivalent(a, b)).toBe(true);
expect(chatMessagesEquivalent(a, c)).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
/**
* supplier-openai-stream.test.ts — callOpenAiStream tools passthrough.
*
* Mocks global.fetch with SSE-shaped Response bodies. Covers:
* - tools/tool_choice included in the upstream payload only when provided
* - fragmented delta.tool_calls accumulation across frames
* - pure tool-call turns (empty content) no longer throw openai_malformed
* - empty content AND no tool calls still throws
* - finish_reason capture
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { callOpenAiStream, OpenAiError } from "../../supplier/src/openai.js";
const BASE = { baseUrl: "http://up", model: "kimi", messages: [{ role: "user" as const, content: "hi" }], timeoutMs: 5_000 };
const TOOLS = [{ type: "function", function: { name: "get_time", parameters: {} } }];
function sse(frames: unknown[]): string {
return frames.map((f) => `data: ${typeof f === "string" ? f : JSON.stringify(f)}\n\n`).join("");
}
function makeStreamFetch(body: string) {
return vi.fn().mockResolvedValue(new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }));
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("callOpenAiStream — tools passthrough", () => {
const CONTENT_FRAMES = sse([
{ choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } },
"[DONE]",
]);
it("includes tools + tool_choice in the payload when provided", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream({ ...BASE, tools: TOOLS, toolChoice: "auto" }, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect(body.tools).toEqual(TOOLS);
expect(body.tool_choice).toBe("auto");
});
it("omits tools from the payload when not provided", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream(BASE, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect("tools" in body).toBe(false);
expect("tool_choice" in body).toBe(false);
});
it("accumulates fragmented delta.tool_calls across frames", async () => {
const frames = sse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"tz":' } }] }, finish_reason: null }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"utc"}' } }] }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 7, completion_tokens: 5 } },
"[DONE]",
]);
vi.stubGlobal("fetch", makeStreamFetch(frames));
const result = await callOpenAiStream({ ...BASE, tools: TOOLS }, () => {});
expect(result.tool_calls).toEqual([
{ id: "call_1", type: "function", function: { name: "get_time", arguments: '{"tz":"utc"}' } },
]);
expect(result.finish_reason).toBe("tool_calls");
expect(result.content).toBe("");
expect(result.prompt_tokens).toBe(7);
});
it("orders multiple tool calls by index", async () => {
const frames = sse([
{ choices: [{ delta: { tool_calls: [
{ index: 1, id: "call_b", type: "function", function: { name: "b", arguments: "{}" } },
{ index: 0, id: "call_a", type: "function", function: { name: "a", arguments: "{}" } },
] }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
"[DONE]",
]);
vi.stubGlobal("fetch", makeStreamFetch(frames));
const result = await callOpenAiStream({ ...BASE, tools: TOOLS }, () => {});
expect(result.tool_calls?.map((t) => t.id)).toEqual(["call_a", "call_b"]);
});
it("still throws openai_malformed when content AND tool calls are both empty", async () => {
const frames = sse([{ choices: [{ delta: {}, finish_reason: "stop" }] }, "[DONE]"]);
vi.stubGlobal("fetch", makeStreamFetch(frames));
await expect(callOpenAiStream(BASE, () => {})).rejects.toMatchObject({ reason: "openai_malformed" });
await expect(callOpenAiStream(BASE, () => {})).rejects.toBeInstanceOf(OpenAiError);
});
it("still streams plain content unchanged (no tools)", async () => {
vi.stubGlobal("fetch", makeStreamFetch(CONTENT_FRAMES));
const tokens: string[] = [];
const result = await callOpenAiStream(BASE, (t) => tokens.push(t));
expect(tokens).toEqual(["Hello"]);
expect(result.content).toBe("Hello");
expect(result.tool_calls).toBeUndefined();
expect(result.finish_reason).toBe("stop");
});
});