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>
154 lines
7.4 KiB
TypeScript
154 lines
7.4 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { seal, open } from "../src/crypto/seal.js";
|
|
import { Mutex } from "../src/sdk/registry.js";
|
|
import { selectCandidates, listModels, parseRef } from "../src/routing/selectSupplier.js";
|
|
import { parseChatRequest } from "../src/openai/validate.js";
|
|
import { buildChatCompletion, buildChunk, usageFromReceipt, renderMessages } from "../src/openai/shapes.js";
|
|
import { totalLovelace, hasCollateral, preflight } from "../src/onchain/preflight.js";
|
|
import { GatewayError } from "../src/openai/errors.js";
|
|
|
|
const MASTER = "ab".repeat(32); // 64 hex
|
|
|
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
function jsonResponse(body: unknown): typeof globalThis.fetch {
|
|
return (async () => new Response(JSON.stringify(body), { status: 200 })) as unknown as typeof globalThis.fetch;
|
|
}
|
|
|
|
describe("crypto/seal", () => {
|
|
it("round-trips and binds the master key", () => {
|
|
const priv = "cd".repeat(32);
|
|
const sealed = seal(priv, MASTER);
|
|
expect(open(sealed, MASTER)).toBe(priv);
|
|
// Wrong key fails the GCM auth tag.
|
|
expect(() => open(sealed, "ff".repeat(32))).toThrow();
|
|
// Tampered ciphertext fails.
|
|
expect(() => open({ ...sealed, ct: sealed.ct.replace(/.$/, "0") }, MASTER)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Mutex", () => {
|
|
it("serializes in submission order and survives rejection", async () => {
|
|
const m = new Mutex();
|
|
const order: number[] = [];
|
|
const p1 = m.run(async () => {
|
|
await delay(20);
|
|
order.push(1);
|
|
});
|
|
const p2 = m.run(async () => {
|
|
order.push(2);
|
|
});
|
|
await Promise.all([p1, p2]);
|
|
expect(order).toEqual([1, 2]);
|
|
|
|
await m.run(async () => {
|
|
throw new Error("boom");
|
|
}).catch(() => undefined);
|
|
expect(await m.run(async () => 42)).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe("routing/selectSupplier", () => {
|
|
const rows = [
|
|
{ utxo_ref: "aa".repeat(32) + "#0", supplier_pkh: "s1", capability_id: "llm.text.generate.v1", model: "m", max_output_tokens: 1, price_lovelace: "2", supplier_bond_lovelace: "1", buyer_bond_lovelace: "1", endpoint_url: "http://a", advert_status: "Active", status: "free" },
|
|
{ utxo_ref: "bb".repeat(32) + "#1", supplier_pkh: "s2", capability_id: "llm.text.generate.v1", model: "m", max_output_tokens: 1, price_lovelace: "9", supplier_bond_lovelace: "1", buyer_bond_lovelace: "1", endpoint_url: "http://b", advert_status: "Active", status: "unknown" },
|
|
{ utxo_ref: "cc".repeat(32) + "#0", supplier_pkh: "s3", capability_id: "llm.text.generate.v1", model: "m", max_output_tokens: 1, price_lovelace: "1", supplier_bond_lovelace: "1", buyer_bond_lovelace: "1", endpoint_url: "http://c", advert_status: "Retired", status: "free" },
|
|
{ utxo_ref: "dd".repeat(32) + "#0", supplier_pkh: "s4", capability_id: "llm.chat.v1", model: "m", max_output_tokens: 1, price_lovelace: "1", supplier_bond_lovelace: "1", buyer_bond_lovelace: "1", endpoint_url: "http://d", advert_status: "Active", status: "free" },
|
|
{ utxo_ref: "ee".repeat(32) + "#0", supplier_pkh: "s5", capability_id: "llm.text.generate.v1", model: "other", max_output_tokens: 1, price_lovelace: "1", supplier_bond_lovelace: "1", buyer_bond_lovelace: "1", endpoint_url: "http://e", advert_status: "Active", status: "free" },
|
|
];
|
|
|
|
it("matches capability + model + Active + (free|unknown), free first", async () => {
|
|
const got = await selectCandidates({
|
|
indexerUrl: "http://ix",
|
|
model: "m",
|
|
capabilityId: "llm.text.generate.v1",
|
|
fetchFn: jsonResponse(rows),
|
|
});
|
|
expect(got.map((c) => c.supplierPkh)).toEqual(["s1", "s2"]); // s3 Retired, s4 chat, s5 other model
|
|
expect(got[0].status).toBe("free");
|
|
expect(got[1].status).toBe("unknown");
|
|
expect(got[0].priceLovelace).toBe(2n);
|
|
});
|
|
|
|
it("routes chat.v1 separately", async () => {
|
|
const got = await selectCandidates({
|
|
indexerUrl: "http://ix",
|
|
model: "m",
|
|
capabilityId: "llm.chat.v1",
|
|
fetchFn: jsonResponse(rows),
|
|
});
|
|
expect(got.map((c) => c.supplierPkh)).toEqual(["s4"]);
|
|
});
|
|
|
|
it("lists distinct Active models", async () => {
|
|
expect(await listModels({ indexerUrl: "http://ix", fetchFn: jsonResponse(rows) })).toEqual(["m", "other"]);
|
|
});
|
|
|
|
it("parseRef", () => {
|
|
expect(parseRef("aa".repeat(32) + "#3")).toEqual({ txHash: "aa".repeat(32), index: 3 });
|
|
expect(parseRef("nope")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("openai/validate", () => {
|
|
it("accepts a valid body and ignores temperature", () => {
|
|
const p = parseChatRequest({ model: "m", messages: [{ role: "user", content: "hi" }], temperature: 0.7, max_tokens: 50 });
|
|
expect(p.model).toBe("m");
|
|
expect(p.maxTokens).toBe(50);
|
|
expect(p.stream).toBe(false);
|
|
});
|
|
it("rejects tools/functions", () => {
|
|
expect(() => parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], tools: [] })).toThrow(GatewayError);
|
|
});
|
|
it("rejects empty messages and bad model", () => {
|
|
expect(() => parseChatRequest({ model: "m", messages: [] })).toThrow();
|
|
expect(() => parseChatRequest({ messages: [{ role: "user", content: "x" }] })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("openai/shapes", () => {
|
|
const receipt = { prompt_hash: "p", response_hash: "r", model: "m", prompt_tokens: 3, completion_tokens: 5, wallclock_ms: 1, supplier_pkh: "s", escrow_ref: "x#0" };
|
|
it("usage + completion shape", () => {
|
|
const usage = usageFromReceipt(receipt);
|
|
expect(usage).toEqual({ prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 });
|
|
const cc = buildChatCompletion({ id: "id", model: "m", content: "hello", usage, vector: { receipt, receipt_signature: "sig", escrow_ref: "x#0" } });
|
|
expect(cc.object).toBe("chat.completion");
|
|
expect((cc.choices as any)[0].message).toEqual({ role: "assistant", content: "hello" });
|
|
expect((cc as any).x_vector.escrow_ref).toBe("x#0");
|
|
});
|
|
it("chunk shape", () => {
|
|
const ch = buildChunk({ id: "id", model: "m", delta: { content: "tok" }, finishReason: null });
|
|
expect(ch.object).toBe("chat.completion.chunk");
|
|
expect((ch.choices as any)[0].delta.content).toBe("tok");
|
|
});
|
|
it("renderMessages folds roles", () => {
|
|
expect(renderMessages([{ role: "system", content: "be nice" }, { role: "user", content: "hi" }])).toBe("System: be nice\n\nUser: hi");
|
|
});
|
|
});
|
|
|
|
describe("onchain/preflight", () => {
|
|
const pure = (lov: bigint) => ({ ref: { txHash: "x", index: 0 }, address: "a", lovelace: lov, assets: {}, datumHex: null, scriptRef: null });
|
|
const mixed = (lov: bigint) => ({ ...pure(lov), assets: { "policy.tok": 1n } });
|
|
|
|
it("totalLovelace + hasCollateral", () => {
|
|
expect(totalLovelace([pure(3_000_000n), pure(4_000_000n)])).toBe(7_000_000n);
|
|
expect(hasCollateral([pure(4_000_000n)])).toBe(false); // < 5 ADA
|
|
expect(hasCollateral([mixed(6_000_000n)])).toBe(false); // has native asset
|
|
expect(hasCollateral([pure(6_000_000n)])).toBe(true);
|
|
});
|
|
|
|
it("preflight requires balance AND collateral", async () => {
|
|
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n };
|
|
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA
|
|
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
|
|
const ok = await preflight(fakeChain, "a", cost);
|
|
expect(ok.ok).toBe(true);
|
|
expect(ok.requiredLovelace).toBe(11_000_000n);
|
|
|
|
const poor = { queryUtxosByAddress: async () => [pure(4_000_000n)] } as any;
|
|
const bad = await preflight(poor, "a", cost);
|
|
expect(bad.ok).toBe(false);
|
|
expect(bad.collateralOk).toBe(false);
|
|
});
|
|
});
|