Files
agents-marketplace/gateway/src/openai/validate.ts
david 6de62b8cf0 feat(gateway): OpenAI-compatible custodial gateway
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>
2026-06-22 10:22:58 +02:00

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 };
}