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:
@@ -84,6 +84,10 @@ describe("routing/selectSupplier", () => {
|
||||
expect(await listModels({ indexerUrl: "http://ix", fetchFn: jsonResponse(rows) })).toEqual(["m", "other"]);
|
||||
});
|
||||
|
||||
it("lists only one capability's models when capabilityId is given", async () => {
|
||||
expect(await listModels({ indexerUrl: "http://ix", capabilityId: "llm.chat.v1", fetchFn: jsonResponse(rows) })).toEqual(["m"]);
|
||||
});
|
||||
|
||||
it("parseRef", () => {
|
||||
expect(parseRef("aa".repeat(32) + "#3")).toEqual({ txHash: "aa".repeat(32), index: 3 });
|
||||
expect(parseRef("nope")).toBeNull();
|
||||
@@ -104,6 +108,37 @@ describe("openai/validate", () => {
|
||||
expect(() => parseChatRequest({ model: "m", messages: [] })).toThrow();
|
||||
expect(() => parseChatRequest({ messages: [{ role: "user", content: "x" }] })).toThrow();
|
||||
});
|
||||
|
||||
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||
|
||||
it("allowTools accepts tools, tool role, null content and assistant tool_calls", () => {
|
||||
const p = parseChatRequest(
|
||||
{
|
||||
model: "m",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: null, tool_calls: [TOOL_CALL] },
|
||||
{ role: "tool", content: "3pm", tool_call_id: "call_1" },
|
||||
],
|
||||
tools: [{ type: "function", function: { name: "get_time", parameters: {} } }],
|
||||
tool_choice: "auto",
|
||||
},
|
||||
{ allowTools: true },
|
||||
);
|
||||
expect(p.tools).toHaveLength(1);
|
||||
expect(p.toolChoice).toBe("auto");
|
||||
expect(p.messages[1]).toEqual({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
|
||||
expect(p.messages[2]).toEqual({ role: "tool", content: "3pm", tool_call_id: "call_1" });
|
||||
});
|
||||
|
||||
it("allowTools still rejects legacy functions and the default path still rejects tool role", () => {
|
||||
expect(() =>
|
||||
parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], functions: [] }, { allowTools: true }),
|
||||
).toThrow(GatewayError);
|
||||
expect(() =>
|
||||
parseChatRequest({ model: "m", messages: [{ role: "tool", content: "x", tool_call_id: "c" }] }),
|
||||
).toThrow(GatewayError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai/shapes", () => {
|
||||
@@ -121,6 +156,24 @@ describe("openai/shapes", () => {
|
||||
expect(ch.object).toBe("chat.completion.chunk");
|
||||
expect((ch.choices as any)[0].delta.content).toBe("tok");
|
||||
});
|
||||
it("tool_calls chunk + finish_reason tool_calls", () => {
|
||||
const tc = { index: 0, id: "call_1", type: "function" as const, function: { name: "f", arguments: "{}" } };
|
||||
const ch = buildChunk({ id: "id", model: "m", delta: { tool_calls: [tc] }, finishReason: "tool_calls" });
|
||||
expect((ch.choices as any)[0].delta.tool_calls).toEqual([tc]);
|
||||
expect((ch.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
it("completion with tool_calls and finish_reason", () => {
|
||||
const cc = buildChatCompletion({
|
||||
id: "id",
|
||||
model: "m",
|
||||
content: "",
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
toolCalls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }],
|
||||
finishReason: "tool_calls",
|
||||
});
|
||||
expect((cc.choices as any)[0].message.tool_calls).toHaveLength(1);
|
||||
expect((cc.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
it("renderMessages folds roles", () => {
|
||||
expect(renderMessages([{ role: "system", content: "be nice" }, { role: "user", content: "hi" }])).toBe("System: be nice\n\nUser: hi");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user