Files
agents-marketplace/gateway/test/http.test.ts
david 2f7b0194a5 feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode
Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):

Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
  free-until-full status semantics, ref-scoped release(ref) (also fixes the
  latent bug where handler catch-alls released another request's slot), and
  a walletMutex serializing every wallet-spending chain op — Claim, Submit,
  and wallet-health consolidation queue instead of racing the single
  working/collateral UTxO. /status gains active_sessions/max_sessions.

Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
  verifies the Open escrow as an entry ticket but never Claims/Submits
  (/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
  {status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
  Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
  supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
  after deliver_by WITHOUT closing live sessions (the conversation and its
  affinity mirror survive their reclaimed ticket), gates reclaims on
  deliver_by, and keeps an in-process settled-refs memory so terminal
  indexer rows aren't retried every sweep forever.

Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:52 +02:00

571 lines
26 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { tmpdir } from "os";
import { join } from "path";
import { randomBytes, randomUUID } from "crypto";
import request from "supertest";
import { createApp } from "../src/server.js";
import { sweepIdleDemoSessions } from "../src/openai/demoChat.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,
overrides?: {
demoIpRate?: { max: number; windowMs: number };
chain?: GatewayDeps["chain"];
chatSettleMode?: "full" | "ticket";
},
): GatewayDeps {
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
const store = new GatewayStore(dbDir);
const config: GatewayConfig = {
masterKeyHex: MASTER,
indexerUrl: "http://ix",
ogmiosUrl: "http://og",
networkId: 0,
liveChain: true,
port: 0,
dbDir,
signupRate: { max: 1000, windowMs: 60_000 },
keyRate: { max: 1000, windowMs: 60_000 },
demoIpRate: overrides?.demoIpRate ?? { max: 1000, windowMs: 60_000 },
sweeperIntervalMs: 60_000,
demoSessionIdleMs: 180_000,
chatSettleMode: overrides?.chatSettleMode ?? "full",
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
corsOrigins: [],
};
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
const registry = new SdkRegistry({
chain,
indexerUrl: config.indexerUrl,
networkId: config.networkId,
masterKeyHex: config.masterKeyHex,
max: config.sdkRegistryMax,
});
const defaultFetch = (async () => new Response("[]", { status: 200 })) as unknown as typeof globalThis.fetch;
return { config, store, chain, registry, fetchFn: fetchFn ?? defaultFetch };
}
describe("gateway HTTP", () => {
it("GET /healthz", async () => {
const res = await request(createApp(makeDeps())).get("/healthz");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it("GET / serves the UI", async () => {
const res = await request(createApp(makeDeps())).get("/");
expect(res.status).toBe(200);
expect(res.text).toContain("OpenAI-compatible Gateway");
});
it("rejects unauthenticated chat completions with OpenAI error shape", async () => {
const res = await request(createApp(makeDeps()))
.post("/openai/v1/chat/completions")
.send({ model: "qwen", messages: [{ role: "user", content: "hi" }] });
expect(res.status).toBe(401);
expect(res.body.error.type).toBe("authentication_error");
expect(res.body.error.code).toBe("invalid_api_key");
});
it("signup → account roundtrip", async () => {
const app = createApp(makeDeps());
const signup = await request(app).post("/signup").send({ label: "test" });
expect(signup.status).toBe(201);
expect(signup.body.api_key).toMatch(/^vk_test_[0-9a-f]{48}$/);
expect(signup.body.deposit_address).toMatch(/^addr_test1/);
const key = signup.body.api_key as string;
const acct = await request(app).get("/account").set("authorization", `Bearer ${key}`);
expect(acct.status).toBe(200);
expect(acct.body.balance.available_lovelace).toBe("0");
expect(acct.body.collateral_ok).toBe(false);
expect(acct.body.spend.request_count).toBe(0);
});
it("GET /openai/v1/models lists distinct models", async () => {
const fetchFn = (async (url: string) =>
new Response(JSON.stringify(String(url).includes("/suppliers") ? SUPPLIERS : []), { status: 200 })) as unknown as typeof globalThis.fetch;
const app = createApp(makeDeps(fetchFn));
const signup = await request(app).post("/signup").send({});
const key = signup.body.api_key as string;
const res = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${key}`);
expect(res.status).toBe(200);
expect(res.body.object).toBe("list");
expect(res.body.data.map((m: { id: string }) => m.id)).toContain("qwen");
});
it("rejects tools/functions with 400 before any chain work", async () => {
const app = createApp(makeDeps());
const signup = await request(app).post("/signup").send({});
const key = signup.body.api_key as string;
const res = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${key}`)
.send({ model: "qwen", messages: [{ role: "user", content: "hi" }], tools: [{ type: "function" }] });
expect(res.status).toBe(400);
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. Pass `suppliers` to serve a mutable list (eviction tests flip
* status mid-test); /v1/chat/end calls are recorded and 200-acked. */
function demoFetch(messageResponses: Array<() => Response>, suppliers?: () => unknown[]) {
const messageCalls: Array<Record<string, unknown>> = [];
const endCalls: string[] = [];
let i = 0;
const fetchFn = (async (url: unknown, init?: { body?: string; headers?: Record<string, 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("/v1/chat/end")) {
endCalls.push(init?.headers?.["X-Escrow-Ref"] ?? "");
return new Response(JSON.stringify({ status: "submitted" }), { status: 200 });
}
if (u.includes("/suppliers")) return new Response(JSON.stringify(suppliers ? suppliers() : SUPPLIERS), { status: 200 });
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
return { fetchFn, messageCalls, endCalls };
}
/** 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 with nothing respondable 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(res.body.error.code).toBe("invalid_messages");
expect(startChat).not.toHaveBeenCalled();
});
it("answers a trailing-assistant history by trimming to the last user message (regenerate)", async () => {
const { fetchFn, messageCalls } = demoFetch([
() => supplierSse([{ type: "token", value: "Again" }, doneFrame({ role: "assistant", content: "Again" }, "stop")]),
]);
const deps = makeDeps(fetchFn, { 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: "user", content: "hi" },
{ role: "assistant", content: "partial answer" },
],
});
expect(res.status).toBe(200);
expect(res.body.choices[0].message.content).toBe("Again");
expect(startChat).toHaveBeenCalledTimes(1);
// The trailing assistant message is dropped from the replay.
expect((messageCalls[0].messages as Array<{ role: string }>).map((m) => m.role)).toEqual(["user"]);
});
it("keeps the session when the client reshapes the assistant echo (lenient affinity)", async () => {
const { fetchFn, messageCalls } = demoFetch([
() => supplierSse([{ type: "token", value: "<think>hm</think>Hi there" }, doneFrame({ role: "assistant", content: "<think>hm</think>Hi there" }, "stop")]),
() => 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);
// Client echoes the assistant reply with the think block stripped and
// whitespace collapsed — still the same session, delta only.
const res2 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({
model: "kimi",
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hi there" },
{ role: "user", content: "more" },
],
});
expect(res2.status).toBe(200);
expect(res2.body.choices[0].message.content).toBe("Sure");
expect(startChat).toHaveBeenCalledTimes(1);
expect((messageCalls[1].messages as unknown[]).length).toBe(1);
});
it("evicts the LRU demo session when every supplier is pinned", async () => {
const suppliers = [{ ...SUPPLIERS[1] }]; // single chat supplier, mutable status
const { fetchFn, messageCalls, endCalls } = demoFetch(
[
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
() => supplierSse([{ type: "token", value: "New" }, doneFrame({ role: "assistant", content: "New" }, "stop")]),
],
() => suppliers,
);
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);
// The lone supplier is now pinned by session 1 (indexer sees it working).
suppliers[0] = { ...suppliers[0], status: "working" };
// Unrelated history → affinity miss → no free supplier → evict session 1
// (supplier /v1/chat/end) and retry, bypassing the stale indexer status.
const res2 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: [{ role: "user", content: "different topic" }] });
expect(res2.status).toBe(200);
expect(res2.body.choices[0].message.content).toBe("New");
expect(startChat).toHaveBeenCalledTimes(2);
expect(endCalls).toHaveLength(1);
expect((messageCalls[1].messages as unknown[]).length).toBe(1);
// Evicted session closed + billed.
const keyId = deps.store.listAllKeys().find((k) => k.demo === 1)!.id;
const usage = deps.store.listUsage(keyId, 10);
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "1000000")).toBe(true);
expect(deps.store.listOpenSessionsByKey(keyId)).toHaveLength(1); // only session 2
});
it("surfaces the original no-supplier error when there is nothing to evict", async () => {
const suppliers = [{ ...SUPPLIERS[1], status: "working" }];
const { fetchFn, endCalls } = demoFetch([() => new Response("unused", { status: 500 })], () => suppliers);
const deps = makeDeps(fetchFn, { 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: "user", content: "hi" }] });
expect(res.status).toBe(404);
expect(res.body.error.code).toBe("model_not_found");
expect(startChat).not.toHaveBeenCalled();
expect(endCalls).toHaveLength(0);
});
it("ticket mode: session close settles nothing and bills zero", async () => {
const { fetchFn } = demoFetch([
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
]);
const deps = makeDeps(fetchFn, { chain: fundedChain, chatSettleMode: "ticket" });
const { rawKey, keyRow } = setupDemoKey(deps);
const ctx = deps.registry.getContext(keyRow);
const endChat = vi.fn(async (opts: { escrowRef: unknown }) => ({
settleMode: "ticket" as const,
escrowRef: opts.escrowRef,
}));
(ctx.sdk as { endChat: unknown }).endChat = endChat;
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);
const session = deps.store.listOpenSessionsByKey(keyRow.id)[0];
const close = await request(app)
.post(`/openai/v1/chat/sessions/${session.id}/close`)
.set("authorization", `Bearer ${rawKey}`)
.send({});
expect(close.status).toBe(200);
expect(close.body.settle_mode).toBe("ticket");
expect(close.body.accepted_ref).toBeUndefined();
expect(endChat).toHaveBeenCalledTimes(1);
const usage = deps.store.listUsage(keyRow.id, 10);
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "0")).toBe(true);
});
it("ticket mode: janitor-closed demo sessions bill zero", async () => {
const { fetchFn, endCalls } = demoFetch([
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
]);
const deps = makeDeps(fetchFn, { chain: fundedChain, chatSettleMode: "ticket" });
const { rawKey, keyRow } = setupDemoKey(deps);
const app = createApp(deps);
const res = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
expect(res.status).toBe(200);
await sweepIdleDemoSessions(deps, Date.now() + deps.config.demoSessionIdleMs + 1);
expect(endCalls.length).toBeGreaterThan(0);
const usage = deps.store.listUsage(keyRow.id, 10);
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "0")).toBe(true);
});
it("janitor closes idle demo sessions so the next request opens fresh", async () => {
const { fetchFn, messageCalls, endCalls } = demoFetch([
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
() => supplierSse([{ type: "token", value: "Fresh" }, doneFrame({ role: "assistant", content: "Fresh" }, "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);
// Pretend demoSessionIdleMs elapsed: the sweeper tick closes the session.
await sweepIdleDemoSessions(deps, Date.now() + deps.config.demoSessionIdleMs + 1);
expect(endCalls).toHaveLength(1);
const keyId = deps.store.listAllKeys().find((k) => k.demo === 1)!.id;
expect(deps.store.listOpenSessionsByKey(keyId)).toHaveLength(0);
// Continuing the thread now misses affinity and reopens with full replay.
const res2 = await request(app)
.post("/openai/v1/chat/completions")
.set("authorization", `Bearer ${rawKey}`)
.send({
model: "kimi",
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hi" },
{ role: "user", content: "more" },
],
});
expect(res2.status).toBe(200);
expect(startChat).toHaveBeenCalledTimes(2);
expect((messageCalls[1].messages as unknown[]).length).toBe(3);
});
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);
});
});