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,81 @@
/**
* affinity.test.ts — transcript-prefix session matching rules.
*/
import { describe, it, expect } from "vitest";
import { matchSessionPrefix, type AffinityCandidate } from "../src/openai/affinity.js";
import type { ChatMessage } from "@marketplace/shared/tx";
const u = (content: string): ChatMessage => ({ role: "user", content });
const a = (content: string): ChatMessage => ({ role: "assistant", content });
const TOOL_CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
function cand(sessionId: string, mirror: ChatMessage[], lastUsedAt = 0, model = "m"): AffinityCandidate {
return { sessionId, model, mirror, lastUsedAt };
}
describe("matchSessionPrefix", () => {
it("matches an exact prefix and returns the delta", () => {
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("hello"), u("more")], "m");
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
});
it("accepts a multi-message tool delta ending with a tool result", () => {
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
const incoming = [...mirror, { role: "tool" as const, content: "3pm", tool_call_id: "call_1" }];
const got = matchSessionPrefix([cand("s1", mirror)], incoming, "m");
expect(got?.sessionId).toBe("s1");
expect(got?.delta).toHaveLength(1);
expect(got?.delta[0].role).toBe("tool");
});
it("rejects deltas that end with a non-respondable role", () => {
const got = matchSessionPrefix([cand("s1", [u("hi")])], [u("hi"), a("stray assistant")], "m");
expect(got).toBeNull();
});
it("never matches when incoming is not longer than the mirror (retry of answered request)", () => {
const mirror = [u("hi"), a("hello")];
expect(matchSessionPrefix([cand("s1", mirror)], mirror, "m")).toBeNull();
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi")], "m")).toBeNull();
});
it("rejects diverged histories (prefix mismatch)", () => {
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("DIFFERENT"), u("more")], "m");
expect(got).toBeNull();
});
it("requires the model to match", () => {
expect(matchSessionPrefix([cand("s1", [u("hi")], 0, "other")], [u("hi"), u("more")], "m")).toBeNull();
});
it("prefers the longest mirror, then most recently used", () => {
const incoming = [u("hi"), a("hello"), u("more")];
const longest = matchSessionPrefix(
[cand("short", [u("hi")], 99), cand("long", [u("hi"), a("hello")], 0)],
incoming,
"m",
);
expect(longest?.sessionId).toBe("long");
const tie = matchSessionPrefix(
[cand("old", [u("hi"), a("hello")], 1), cand("recent", [u("hi"), a("hello")], 2)],
incoming,
"m",
);
expect(tie?.sessionId).toBe("recent");
});
it("matches an empty mirror (fresh session) against any respondable history", () => {
const incoming = [{ role: "system" as const, content: "be nice" }, u("hi")];
const got = matchSessionPrefix([cand("fresh", [])], incoming, "m");
expect(got).toEqual({ sessionId: "fresh", delta: incoming });
});
it("treats content null-normalization as equivalent (tool-call assistant echo)", () => {
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
// Client echoes the assistant message back with content:"" (normalized from null upstream).
const incoming = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [{ ...TOOL_CALL }] }, { role: "tool" as const, content: "x", tool_call_id: "call_1" }];
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")?.sessionId).toBe("s1");
});
});