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>
65 lines
2.3 KiB
TypeScript
65 lines
2.3 KiB
TypeScript
/**
|
|
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
|
|
*/
|
|
|
|
import type { ChatMessage } from "@marketplace/shared/tx";
|
|
import { badRequest } from "./errors.js";
|
|
|
|
export interface ParsedChatRequest {
|
|
model: string;
|
|
messages: ChatMessage[];
|
|
maxTokens?: number;
|
|
stream: boolean;
|
|
}
|
|
|
|
const ROLES = new Set(["system", "user", "assistant"]);
|
|
|
|
/** Validate an OpenAI chat.completions-style body. Rejects tools/functions;
|
|
* silently ignores temperature/n/stop/top_p/etc (documented). */
|
|
export function parseChatRequest(body: unknown): ParsedChatRequest {
|
|
if (typeof body !== "object" || body === null) {
|
|
throw badRequest("invalid_body", "request body must be a JSON object");
|
|
}
|
|
const b = body as Record<string, unknown>;
|
|
|
|
if ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b) {
|
|
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
|
|
}
|
|
|
|
const model = b.model;
|
|
if (typeof model !== "string" || model === "") {
|
|
throw badRequest("invalid_model", "`model` is required and must be a non-empty string");
|
|
}
|
|
|
|
const rawMessages = b.messages;
|
|
if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
|
|
throw badRequest("invalid_messages", "`messages` is required and must be a non-empty array");
|
|
}
|
|
const messages: ChatMessage[] = [];
|
|
for (const m of rawMessages) {
|
|
if (
|
|
typeof m !== "object" ||
|
|
m === null ||
|
|
typeof (m as { role?: unknown }).role !== "string" ||
|
|
typeof (m as { content?: unknown }).content !== "string" ||
|
|
!ROLES.has((m as { role: string }).role)
|
|
) {
|
|
throw badRequest("invalid_messages", "each message must be {role: system|user|assistant, content: string}");
|
|
}
|
|
const mm = m as { role: "system" | "user" | "assistant"; content: string };
|
|
messages.push({ role: mm.role, content: mm.content });
|
|
}
|
|
|
|
let maxTokens: number | undefined;
|
|
const mt = b.max_tokens ?? b.max_completion_tokens;
|
|
if (mt !== undefined && mt !== null) {
|
|
if (typeof mt !== "number" || !Number.isInteger(mt) || mt <= 0) {
|
|
throw badRequest("invalid_max_tokens", "`max_tokens` must be a positive integer");
|
|
}
|
|
maxTokens = mt;
|
|
}
|
|
|
|
const stream = b.stream === true;
|
|
return { model, messages, maxTokens, stream };
|
|
}
|