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:
96
tests/unit/shared-chat-message.test.ts
Normal file
96
tests/unit/shared-chat-message.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
tests/unit/supplier-openai-stream.test.ts
Normal file
103
tests/unit/supplier-openai-stream.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user