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");
});
});

View File

@@ -1,23 +1,32 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { tmpdir } from "os";
import { join } from "path";
import { randomUUID } from "crypto";
import { randomBytes, randomUUID } from "crypto";
import request from "supertest";
import { createApp } from "../src/server.js";
import { GatewayStore } from "../src/db/store.js";
import { SdkRegistry } from "../src/sdk/registry.js";
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
import { seal } from "../src/crypto/seal.js";
import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
import type { GatewayConfig } from "../src/config.js";
import type { GatewayDeps } from "../src/deps.js";
const MASTER = "ab".repeat(32);
const SUPPLIERS = [
{ utxo_ref: "aa".repeat(32) + "#0", supplier_pkh: "s1", capability_id: "llm.text.generate.v1", model: "qwen", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
{ utxo_ref: "bb".repeat(32) + "#0", supplier_pkh: "s2", capability_id: "llm.chat.v1", model: "kimi", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
];
function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
function makeDeps(
fetchFn?: typeof globalThis.fetch,
overrides?: { demoIpRate?: { max: number; windowMs: number }; chain?: GatewayDeps["chain"] },
): GatewayDeps {
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
const store = new GatewayStore(dbDir);
const config: GatewayConfig = {
masterKeyHex: "ab".repeat(32),
masterKeyHex: MASTER,
indexerUrl: "http://ix",
ogmiosUrl: "http://og",
networkId: 0,
@@ -26,11 +35,13 @@ function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
dbDir,
signupRate: { max: 1000, windowMs: 60_000 },
keyRate: { max: 1000, windowMs: 60_000 },
demoIpRate: overrides?.demoIpRate ?? { max: 1000, windowMs: 60_000 },
sweeperIntervalMs: 60_000,
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
corsOrigins: [],
};
const chain = { queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"];
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
const registry = new SdkRegistry({
chain,
indexerUrl: config.indexerUrl,
@@ -103,3 +114,238 @@ describe("gateway HTTP", () => {
expect(res.body.error.code).toBe("unsupported_parameter");
});
});
// ─── Shared demo key (session-backed completions) ───────────────────────────
const TOOLS = [{ type: "function", function: { name: "get_time", parameters: {} } }];
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */
const fundedChain = {
queryUtxosByAddress: async () => [
{ ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null },
],
} as unknown as GatewayDeps["chain"];
function supplierSse(frames: unknown[]): Response {
const body = frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
}
function doneFrame(message: Record<string, unknown>, finishReason: string, usage = { prompt_tokens: 3, completion_tokens: 2 }) {
return { type: "done", message, finish_reason: finishReason, usage };
}
/** fetchFn serving the indexer supplier list and scripted /v1/chat/message responses. */
function demoFetch(messageResponses: Array<() => Response>) {
const messageCalls: Array<Record<string, unknown>> = [];
let i = 0;
const fetchFn = (async (url: unknown, init?: { body?: string }) => {
const u = String(url);
if (u.includes("/v1/chat/message")) {
messageCalls.push(init?.body ? (JSON.parse(init.body) as Record<string, unknown>) : {});
const make = messageResponses[Math.min(i, messageResponses.length - 1)];
i += 1;
return make();
}
if (u.includes("/suppliers")) return new Response(JSON.stringify(SUPPLIERS), { status: 200 });
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
return { fetchFn, messageCalls };
}
/** Insert a demo-flagged key directly (public signup can't mint one) and stub
* the SDK's chain-touching startChat on the pre-warmed per-key context. */
function setupDemoKey(deps: GatewayDeps) {
const rawKey = `vk_test_${randomBytes(24).toString("hex")}`;
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, 0);
const sealed = seal(privHex, MASTER);
deps.store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: rawKey.slice(0, 12),
label: "shared-demo",
wallet_pkh: walletKey.pubKeyHash,
deposit_address: walletKey.address,
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: 1,
created_at: Date.now(),
demo: 1,
});
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const startChat = vi.fn(async () => ({
escrowRef: { txHash: randomBytes(32).toString("hex"), index: 0 },
sessionNonce: "nonce",
supplierBaseUrl: "http://sup",
}));
(ctx.sdk as { startChat: unknown }).startChat = startChat;
return { rawKey, keyRow, startChat };
}
describe("gateway demo key", () => {
it("runs a session-backed completion with tools and reuses the session via affinity", async () => {
const { fetchFn, messageCalls } = demoFetch([
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
() => supplierSse([{ type: "token", value: "It is 3pm" }, doneFrame({ role: "assistant", content: "It is 3pm" }, "stop")]),
]);
const deps = makeDeps(fetchFn, { chain: fundedChain });
const { rawKey, startChat } = setupDemoKey(deps);
const app = createApp(deps);
// Turn 1: model asks for a tool call.
const history: Array<Record<string, unknown>> = [{ role: "user", content: "what time is it?" }];
const res1 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: history, tools: TOOLS });
expect(res1.status).toBe(200);
expect(res1.body.choices[0].message.tool_calls).toEqual([TOOL_CALL]);
expect(res1.body.choices[0].finish_reason).toBe("tool_calls");
expect(startChat).toHaveBeenCalledTimes(1);
// First turn replays the full history and forwards tools.
expect((messageCalls[0].messages as unknown[]).length).toBe(1);
expect(messageCalls[0].tools).toEqual(TOOLS);
// Turn 2: client echoes the assistant tool_calls + tool result.
history.push({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
history.push({ role: "tool", content: "3pm", tool_call_id: "call_1" });
const res2 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: history, tools: TOOLS });
expect(res2.status).toBe(200);
expect(res2.body.choices[0].message.content).toBe("It is 3pm");
expect(res2.body.choices[0].finish_reason).toBe("stop");
// Affinity hit: no new session, only the unseen delta is forwarded.
expect(startChat).toHaveBeenCalledTimes(1);
const delta = messageCalls[1].messages as Array<{ role: string }>;
expect(delta).toHaveLength(1);
expect(delta[0].role).toBe("tool");
});
it("streams tool_calls chunks with finish_reason tool_calls", async () => {
const { fetchFn } = demoFetch([
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
]);
const deps = makeDeps(fetchFn, { chain: fundedChain });
const { rawKey } = setupDemoKey(deps);
const res = await request(createApp(deps))
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: [{ role: "user", content: "time?" }], tools: TOOLS, stream: true });
expect(res.status).toBe(200);
const chunks = res.text
.split("\n\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)));
const toolChunk = chunks.find((c) => c.choices?.[0]?.delta?.tool_calls);
expect(toolChunk.choices[0].delta.tool_calls[0].function.name).toBe("get_time");
expect(chunks.at(-1).choices[0].finish_reason).toBe("tool_calls");
expect(res.text).toContain("data: [DONE]");
});
it("transparently reopens when the supplier lost the session (idle-close)", async () => {
const { fetchFn, messageCalls } = demoFetch([
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
() => new Response("not found", { status: 404 }),
() => supplierSse([{ type: "token", value: "Sure" }, doneFrame({ role: "assistant", content: "Sure" }, "stop")]),
]);
const deps = makeDeps(fetchFn, { chain: fundedChain });
const { rawKey, startChat } = setupDemoKey(deps);
const app = createApp(deps);
const res1 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
expect(res1.status).toBe(200);
// Extended history matches the session, but the supplier 404s the turn →
// the executor closes the session and reopens, replaying full history.
const history = [
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hi" },
{ role: "user", content: "more please" },
];
const res2 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: history });
expect(res2.status).toBe(200);
expect(res2.body.choices[0].message.content).toBe("Sure");
expect(startChat).toHaveBeenCalledTimes(2);
expect((messageCalls[2].messages as unknown[]).length).toBe(3);
// The dead session's fee was recorded for spend audit.
const usage = deps.store.listUsage(deps.store.listAllKeys().find((k) => k.demo === 1)!.id, 10);
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "1000000")).toBe(true);
});
it("rejects histories that do not end with user/tool before any chain work", async () => {
const deps = makeDeps(undefined, { chain: fundedChain });
const { rawKey, startChat } = setupDemoKey(deps);
const res = await request(createApp(deps))
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: [{ role: "assistant", content: "hello" }] });
expect(res.status).toBe(400);
expect(startChat).not.toHaveBeenCalled();
});
it("blocks withdraw and hides the deposit address for demo keys", async () => {
const deps = makeDeps();
const { rawKey } = setupDemoKey(deps);
const app = createApp(deps);
const wd = await request(app)
.post("/account/withdraw")
.set("authorization", `Bearer ${rawKey}`)
.send({ to_address: "addr_test1abc" });
expect(wd.status).toBe(403);
expect(wd.body.error.code).toBe("demo_key_restricted");
const acct = await request(app).get("/account").set("authorization", `Bearer ${rawKey}`);
expect(acct.status).toBe(200);
expect(acct.body.deposit_address).toBeNull();
expect(acct.body.demo).toBe(true);
});
it("lists only chat-capable models for demo keys", async () => {
const fetchFn = (async (url: unknown) =>
new Response(JSON.stringify(String(url).includes("/suppliers") ? SUPPLIERS.filter((s) => !String(url).includes("capability_id") || s.capability_id === "llm.chat.v1") : []), { status: 200 })) as unknown as typeof globalThis.fetch;
const deps = makeDeps(fetchFn);
const { rawKey } = setupDemoKey(deps);
const app = createApp(deps);
const demoModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${rawKey}`);
expect(demoModels.body.data.map((m: { id: string }) => m.id)).toEqual(["kimi"]);
const signup = await request(app).post("/signup").send({});
const normalModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${signup.body.api_key as string}`);
expect(normalModels.body.data.map((m: { id: string }) => m.id).sort()).toEqual(["kimi", "qwen"]);
});
it("rate-limits demo requests per IP (and not per key)", async () => {
const deps = makeDeps(undefined, { demoIpRate: { max: 1, windowMs: 60_000 } });
const { rawKey } = setupDemoKey(deps);
const app = createApp(deps);
// Model with no chat suppliers → cheap 404 AFTER passing the limiter.
const body = { model: "nope", messages: [{ role: "user", content: "hi" }] };
const first = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
expect(first.status).toBe(404);
const second = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
expect(second.status).toBe(429);
// Non-demo keys are untouched by the demo IP limiter.
const signup = await request(app).post("/signup").send({});
const normal = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${signup.body.api_key as string}`)
.send(body);
expect(normal.status).not.toBe(429);
});
});

View File

@@ -0,0 +1,79 @@
/**
* migration.test.ts — the demo-column migration on pre-existing databases.
*
* GatewayStore has no migration framework (pure CREATE TABLE IF NOT EXISTS),
* so opening a DB created before the `demo` column must ALTER it in place.
*/
import { describe, it, expect } from "vitest";
import { tmpdir } from "os";
import { join } from "path";
import { mkdirSync } from "fs";
import { randomUUID } from "crypto";
import Database from "better-sqlite3";
import { GatewayStore } from "../src/db/store.js";
const LEGACY_API_KEYS_SQL = `
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
label TEXT,
wallet_pkh TEXT NOT NULL,
deposit_address TEXT NOT NULL,
enc_priv_nonce TEXT NOT NULL,
enc_priv_ct TEXT NOT NULL,
enc_priv_tag TEXT NOT NULL,
master_key_version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
);
`;
function keyRow(id: string) {
return {
id,
key_hash: `hash-${id}`,
key_prefix: "vk_test_xxxx",
label: null,
wallet_pkh: "pkh",
deposit_address: "addr_test1x",
enc_priv_nonce: "n",
enc_priv_ct: "c",
enc_priv_tag: "t",
master_key_version: 1,
created_at: Date.now(),
};
}
describe("GatewayStore demo-column migration", () => {
it("adds the demo column to a legacy DB and defaults existing keys to 0", () => {
const dir = join(tmpdir(), `gw-mig-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
const raw = new Database(join(dir, "gateway.db"));
raw.exec(LEGACY_API_KEYS_SQL);
raw
.prepare(
`INSERT INTO api_keys (id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled)
VALUES (@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0)`,
)
.run(keyRow("legacy"));
raw.close();
const store = new GatewayStore(dir);
const legacy = store.getKeyByHash("hash-legacy");
expect(legacy?.demo).toBe(0);
store.insertKey({ ...keyRow("demo-key"), demo: 1 });
expect(store.getKeyByHash("hash-demo-key")?.demo).toBe(1);
});
it("persists the demo flag on fresh databases (default 0)", () => {
const dir = join(tmpdir(), `gw-fresh-${randomUUID()}`);
const store = new GatewayStore(dir);
store.insertKey(keyRow("plain"));
expect(store.getKeyByHash("hash-plain")?.demo).toBe(0);
});
});

View File

@@ -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");
});