New multi-tenant gateway package: external developers point any OpenAI SDK at the marketplace. Each API key is its own custodial buyer wallet (AES-256-GCM sealed under GATEWAY_MASTER_KEY); every request posts an on-chain escrow from that user's wallet. - POST /openai/v1/chat/completions (one escrow/call, llm.text.generate.v1, buffered + pseudo-stream), chat sessions (one escrow/session, llm.chat.v1, real SSE), GET /openai/v1/models, /signup, /account, /account/withdraw. - Settles by resolving the Submitted ref via the indexer + awaited Accept; state-aware reclaim sweeper + per-wallet auto-consolidation for collateral. - Reuses the buyer Marketplace SDK via new buyer exports ./sdk; new shared buildWithdrawTx (custodial exit). Runs via tsx; Dockerfile + mainnet compose. Design in docs/gateway.md. Typecheck passes for all packages; 20 vitest tests pass. Not yet exercised against a live chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.4 KiB
TypeScript
106 lines
4.4 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { 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 type { GatewayConfig } from "../src/config.js";
|
|
import type { GatewayDeps } from "../src/deps.js";
|
|
|
|
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" },
|
|
];
|
|
|
|
function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
|
|
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
|
|
const store = new GatewayStore(dbDir);
|
|
const config: GatewayConfig = {
|
|
masterKeyHex: "ab".repeat(32),
|
|
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 },
|
|
sweeperIntervalMs: 60_000,
|
|
walletHealthIntervalMs: 600_000,
|
|
sdkRegistryMax: 100,
|
|
};
|
|
const 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");
|
|
});
|
|
});
|