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:
81
gateway/test/affinity.test.ts
Normal file
81
gateway/test/affinity.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
79
gateway/test/migration.test.ts
Normal file
79
gateway/test/migration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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