diff --git a/src/client/consumer/sixerr-client.ts b/src/client/consumer/sixerr-client.ts index 5dcabd3..71140bd 100644 --- a/src/client/consumer/sixerr-client.ts +++ b/src/client/consumer/sixerr-client.ts @@ -26,7 +26,9 @@ import type { * signer: createLocalPaymentSigner("0x...privateKey"), * }); * - * const response = await client.respond({ input: "Hello, world!" }); + * const response = await client.respond({ + * messages: [{ role: "user", content: "Hello, world!" }], + * }); * ``` */ export class SixerrClient { @@ -44,15 +46,16 @@ export class SixerrClient { */ async respond(options: RespondOptions): Promise { const url = options.agentId - ? `${this.serverUrl}/v1/responses/${options.agentId}` - : `${this.serverUrl}/v1/responses`; + ? `${this.serverUrl}/v1/chat/completions/${options.agentId}` + : `${this.serverUrl}/v1/chat/completions`; const body: Record = { model: options.model ?? "default", - input: options.input, + messages: options.messages, }; if (options.stream) body.stream = true; if (options.routing) body.routing = options.routing; + if (options.max_tokens) body.max_tokens = options.max_tokens; return this.postWith402Handling(url, JSON.stringify(body), options.maxAmount); } @@ -60,16 +63,16 @@ export class SixerrClient { /** * Forward a full request body to the Sixerr server, handling x402 payment. * Unlike `respond()`, this passes the body through untouched — useful for - * proxying OpenResponses requests that include fields like `instructions`, - * `tools`, `max_output_tokens`, etc. + * proxying Chat Completions requests that include fields like `tools`, + * `max_tokens`, etc. */ async respondRaw( rawBody: Record, agentId?: string, ): Promise { const url = agentId - ? `${this.serverUrl}/v1/responses/${agentId}` - : `${this.serverUrl}/v1/responses`; + ? `${this.serverUrl}/v1/chat/completions/${agentId}` + : `${this.serverUrl}/v1/chat/completions`; return this.postWith402Handling(url, JSON.stringify(rawBody)); } diff --git a/src/client/consumer/types.ts b/src/client/consumer/types.ts index 6bb25af..d69f405 100644 --- a/src/client/consumer/types.ts +++ b/src/client/consumer/types.ts @@ -71,14 +71,16 @@ export interface SixerrClientConfig { export interface RespondOptions { /** Model identifier. Defaults to "default". */ model?: string; - /** Input text or message array (OpenResponses format). */ - input: unknown; + /** Messages array (Chat Completions format). */ + messages: unknown[]; /** Enable SSE streaming. */ stream?: boolean; /** Target a specific agent by ID. */ agentId?: string; /** Routing strategy: "cheapest" or "fastest". */ routing?: "cheapest" | "fastest"; + /** Max tokens in the completion. */ + max_tokens?: number; /** Override max USDC for this request. */ maxAmount?: string; } diff --git a/src/client/supplier/inference/inference-handler.ts b/src/client/supplier/inference/inference-handler.ts index 331952d..6e962c2 100644 --- a/src/client/supplier/inference/inference-handler.ts +++ b/src/client/supplier/inference/inference-handler.ts @@ -2,173 +2,158 @@ import * as crypto from "node:crypto"; import { streamSimple, completeSimple } from "@mariozechner/pi-ai"; import type { Api, - AssistantMessage, - AssistantMessageEvent, + AssistantMessage as PiAssistantMessage, Context, ImageContent, - Message, + Message as PiMessage, + Tool, + ToolCall, UserMessage, } from "@mariozechner/pi-ai"; import type { InferenceConfig } from "./types.js"; // --------------------------------------------------------------------------- -// OpenResponses input types (subset needed for prompt building) +// Chat Completions types (local — matches schema) // --------------------------------------------------------------------------- type ContentPart = - | { type: "input_text"; text: string } - | { type: "input_image"; source: { type: "url"; url: string } | { type: "base64"; data: string; media_type: string } } - | { type: "input_file"; source: { type: "url"; url: string } | { type: "base64"; data: string; media_type: string } } - | { type: "output_text"; text: string }; + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } }; -type ItemParam = - | { type: "message"; role: "system" | "developer" | "user" | "assistant"; content: string | ContentPart[] } - | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; content?: string; summary?: string }; - -// --------------------------------------------------------------------------- -// OpenResponses response types -// --------------------------------------------------------------------------- - -interface Usage { - input_tokens: number; - output_tokens: number; - total_tokens: number; +interface ToolCallType { + id: string; + type: "function"; + function: { name: string; arguments: string }; } -interface OutputItem { - type: "message"; - id: string; - role: "assistant"; - content: { type: "output_text"; text: string }[]; - status: "completed"; +type Message = + | { role: "system"; content: string } + | { role: "user"; content: string | ContentPart[] } + | { role: "assistant"; content?: string | null; tool_calls?: ToolCallType[] } + | { role: "tool"; content: string; tool_call_id: string }; + +interface ToolDefinition { + type: "function"; + function: { name: string; description?: string; parameters?: Record }; } -interface ResponseResource { - id: string; - object: "response"; - created_at: number; - status: "completed" | "failed" | "incomplete"; - model: string; - output: OutputItem[]; - usage: Usage; +interface ChatCompletionUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; } // --------------------------------------------------------------------------- -// Prompt building (adapted from openclaw/src/gateway/openresponses-http.ts) +// Tool conversion // --------------------------------------------------------------------------- -function extractTextContent(content: string | ContentPart[]): string { - if (typeof content === "string") return content; - return content - .map((part) => { - if (part.type === "input_text") return part.text; - if (part.type === "output_text") return part.text; - return ""; - }) - .filter(Boolean) - .join("\n"); +function convertTools(tools: ToolDefinition[]): Tool[] { + return tools.map((td) => ({ + name: td.function.name, + description: td.function.description ?? "", + parameters: (td.function.parameters ?? {}) as Tool["parameters"], + })); } -function extractImages(content: string | ContentPart[]): ImageContent[] { - if (typeof content === "string") return []; +// --------------------------------------------------------------------------- +// Image extraction from multipart user content +// --------------------------------------------------------------------------- + +function extractImages(content: ContentPart[]): ImageContent[] { const images: ImageContent[] = []; for (const part of content) { - if (part.type === "input_image" && part.source.type === "base64") { - images.push({ - type: "image", - data: part.source.data, - mimeType: part.source.media_type, - }); + if (part.type === "image_url") { + // data: URIs contain base64 inline; URL references are not supported by pi-ai + const url = part.image_url.url; + if (url.startsWith("data:")) { + const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/); + if (match) { + images.push({ type: "image", data: match[2], mimeType: match[1] }); + } + } } } return images; } -/** - * Convert OpenResponses input to pi-ai Context. - */ +// --------------------------------------------------------------------------- +// buildContext — Chat Completions messages → pi-ai Context +// --------------------------------------------------------------------------- + function buildContext( - input: string | ItemParam[], - instructions?: string, + messages: Message[], + tools?: ToolDefinition[], ): { context: Context; images: ImageContent[] } { const systemParts: string[] = []; - const messages: Message[] = []; + const piMessages: PiMessage[] = []; const images: ImageContent[] = []; - if (instructions) { - systemParts.push(instructions); - } - - if (typeof input === "string") { - messages.push({ - role: "user", - content: input, - timestamp: Date.now(), - } as UserMessage); - } else { - for (const item of input) { - if (item.type === "message") { - const text = extractTextContent(item.content).trim(); - if (!text) continue; - - if (item.role === "system" || item.role === "developer") { - systemParts.push(text); - } else if (item.role === "user") { - const itemImages = extractImages(item.content); - if (itemImages.length > 0) { - images.push(...itemImages); - messages.push({ - role: "user", - content: [{ type: "text" as const, text }], - timestamp: Date.now(), - } as UserMessage); - } else { - messages.push({ - role: "user", - content: text, - timestamp: Date.now(), - } as UserMessage); - } - } else if (item.role === "assistant") { - messages.push({ - role: "assistant", - content: [{ type: "text" as const, text }], - api: "openai-responses" as Api, - provider: "", - model: "", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, - stopReason: "stop", - timestamp: Date.now(), - } as AssistantMessage); - } - } else if (item.type === "function_call_output") { - messages.push({ - role: "toolResult", - toolCallId: item.call_id, - toolName: "", - content: [{ type: "text" as const, text: item.output }], - isError: false, + for (const msg of messages) { + if (msg.role === "system") { + systemParts.push(msg.content); + } else if (msg.role === "user") { + if (typeof msg.content === "string") { + piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() } as UserMessage); + } else { + // Multipart content + const text = msg.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("\n"); + const itemImages = extractImages(msg.content); + if (itemImages.length > 0) images.push(...itemImages); + piMessages.push({ + role: "user", + content: text ? [{ type: "text" as const, text }] : "", timestamp: Date.now(), - }); + } as UserMessage); + } + } else if (msg.role === "assistant") { + const content: (ToolCall | { type: "text"; text: string })[] = []; + if (msg.content) { + content.push({ type: "text" as const, text: msg.content }); } - // Skip reasoning items + let stopReason: "stop" | "toolUse" = "stop"; + if (msg.tool_calls && msg.tool_calls.length > 0) { + stopReason = "toolUse"; + for (const tc of msg.tool_calls) { + let parsedArgs: Record = {}; + try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = { _raw: tc.function.arguments }; } + content.push({ type: "toolCall", id: tc.id, name: tc.function.name, arguments: parsedArgs }); + } + } + piMessages.push({ + role: "assistant", + content, + api: "openai-completions" as Api, + provider: "", + model: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason, + timestamp: Date.now(), + } as PiAssistantMessage); + } else if (msg.role === "tool") { + piMessages.push({ + role: "toolResult", + toolCallId: msg.tool_call_id, + toolName: "", + content: [{ type: "text" as const, text: msg.content }], + isError: false, + timestamp: Date.now(), + }); } } - // If no user messages, add an empty one to satisfy context requirements - if (messages.length === 0) { - messages.push({ - role: "user", - content: "", - timestamp: Date.now(), - } as UserMessage); + // If no messages, add an empty user message to satisfy context requirements + if (piMessages.length === 0) { + piMessages.push({ role: "user", content: "", timestamp: Date.now() } as UserMessage); } return { context: { systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, - messages, + messages: piMessages, + ...(tools && tools.length > 0 ? { tools: convertTools(tools) } : {}), }, images, }; @@ -178,44 +163,14 @@ function buildContext( // Usage conversion // --------------------------------------------------------------------------- -function toOpenResponsesUsage(piUsage: AssistantMessage["usage"]): Usage { +function toChatCompletionsUsage(piUsage: PiAssistantMessage["usage"]): ChatCompletionUsage { return { - input_tokens: piUsage.input, - output_tokens: piUsage.output, + prompt_tokens: piUsage.input, + completion_tokens: piUsage.output, total_tokens: piUsage.totalTokens, }; } -// --------------------------------------------------------------------------- -// Response building -// --------------------------------------------------------------------------- - -function buildResponseResource( - responseId: string, - model: string, - text: string, - usage: Usage, - status: "completed" | "failed" = "completed", -): ResponseResource { - return { - id: responseId, - object: "response", - created_at: Math.floor(Date.now() / 1000), - status, - model, - output: [ - { - type: "message", - id: `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`, - role: "assistant", - content: [{ type: "output_text", text }], - status: "completed", - }, - ], - usage, - }; -} - // --------------------------------------------------------------------------- // handleIncomingRequest // --------------------------------------------------------------------------- @@ -224,9 +179,9 @@ function buildResponseResource( * Handle an incoming request from the Sixerr server by calling the LLM * directly via pi-ai SDK. * - * 1. Parses OpenResponses body to extract message text and images + * 1. Parses Chat Completions body to extract messages and images * 2. Calls the LLM using streamSimple/completeSimple - * 3. Converts output to OpenResponses format → WS messages + * 3. Converts output to Chat Completions format → WS messages * 4. Sends error WS message on failure */ export async function handleIncomingRequest( @@ -238,29 +193,26 @@ export async function handleIncomingRequest( try { const forwardBody = { ...(body as Record) }; const isStreaming = forwardBody.stream === true; - const input = forwardBody.input as string | ItemParam[]; - const instructions = forwardBody.instructions as string | undefined; + const messages = forwardBody.messages as Message[]; + const tools = forwardBody.tools as ToolDefinition[] | undefined; - const { context, images } = buildContext(input, instructions); + const { context, images } = buildContext(messages, tools); const { resolvedModel, modelRegistry } = inferenceConfig; - // Resolve API key for this model const apiKey = await modelRegistry.getApiKey(resolvedModel); const timeoutMs = inferenceConfig.timeoutMs ?? 120_000; const abortController = new AbortController(); const timeoutId = setTimeout(() => abortController.abort(), timeoutMs); - const responseId = `resp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; - const outputItemId = `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; const modelName = `${inferenceConfig.provider}/${inferenceConfig.model}`; try { if (isStreaming) { await handleStreaming( requestId, - responseId, - outputItemId, + completionId, modelName, resolvedModel, context, @@ -272,7 +224,7 @@ export async function handleIncomingRequest( } else { await handleNonStreaming( requestId, - responseId, + completionId, modelName, resolvedModel, context, @@ -301,8 +253,7 @@ export async function handleIncomingRequest( async function handleStreaming( requestId: string, - responseId: string, - outputItemId: string, + completionId: string, modelName: string, model: ReturnType, context: Context, @@ -312,50 +263,30 @@ async function handleStreaming( sendMessage: (msg: unknown) => void, ): Promise { let accumulatedText = ""; - let usage: Usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + let usage: ChatCompletionUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + const created = Math.floor(Date.now() / 1000); - // Send initial response.created event - const initialResponse = buildResponseResource(responseId, modelName, "", usage); - initialResponse.status = "completed"; // will be overwritten - - sendMessage({ - type: "stream_event", - id: requestId, - event: { - type: "response.created", - response: { ...initialResponse, status: "in_progress" as const, output: [] }, - }, - }); + // Track tool calls + const completedToolCalls: ToolCallType[] = []; + let currentToolCallIndex = 0; - // Send output item added - sendMessage({ - type: "stream_event", - id: requestId, - event: { - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: outputItemId, - role: "assistant", - content: [], - status: "in_progress", + // Helper to emit a chunk + function emitChunk(delta: Record, finishReason: string | null) { + sendMessage({ + type: "stream_event", + id: requestId, + event: { + id: completionId, + object: "chat.completion.chunk", + created, + model: modelName, + choices: [{ index: 0, delta, finish_reason: finishReason }], }, - }, - }); + }); + } - // Send content part added - sendMessage({ - type: "stream_event", - id: requestId, - event: { - type: "response.content_part.added", - item_id: outputItemId, - output_index: 0, - content_index: 0, - part: { type: "output_text", text: "" }, - }, - }); + // Initial chunk: role announcement + emitChunk({ role: "assistant" }, null); try { const eventStream = streamSimple(model as any, context, { @@ -367,21 +298,45 @@ async function handleStreaming( for await (const event of eventStream) { if (event.type === "text_delta") { accumulatedText += event.delta; - sendMessage({ - type: "stream_event", - id: requestId, - event: { - type: "response.output_text.delta", - item_id: outputItemId, - output_index: 0, - content_index: 0, - delta: event.delta, - }, + emitChunk({ content: event.delta }, null); + } else if (event.type === "toolcall_start") { + currentToolCallIndex = completedToolCalls.length; + emitChunk({ + tool_calls: [{ + index: currentToolCallIndex, + id: "", // filled on toolcall_end + type: "function", + function: { name: "", arguments: "" }, + }], + }, null); + } else if (event.type === "toolcall_delta") { + emitChunk({ + tool_calls: [{ + index: currentToolCallIndex, + function: { arguments: event.delta }, + }], + }, null); + } else if (event.type === "toolcall_end") { + const tc = event.toolCall; + const argsString = JSON.stringify(tc.arguments); + // Emit final chunk with id and name filled in + emitChunk({ + tool_calls: [{ + index: currentToolCallIndex, + id: tc.id, + type: "function", + function: { name: tc.name, arguments: argsString }, + }], + }, null); + completedToolCalls.push({ + id: tc.id, + type: "function", + function: { name: tc.name, arguments: argsString }, }); } else if (event.type === "done") { - usage = toOpenResponsesUsage(event.message.usage); + usage = toChatCompletionsUsage(event.message.usage); } else if (event.type === "error") { - usage = toOpenResponsesUsage(event.error.usage); + usage = toChatCompletionsUsage(event.error.usage); if (event.error.errorMessage) { sendMessage({ type: "error", @@ -401,28 +356,11 @@ async function handleStreaming( }); } - // Send text done - sendMessage({ - type: "stream_event", - id: requestId, - event: { - type: "response.output_text.done", - item_id: outputItemId, - output_index: 0, - content_index: 0, - text: accumulatedText, - }, - }); - - // Send response.completed - const finalResponse = buildResponseResource(responseId, modelName, accumulatedText, usage); - sendMessage({ - type: "stream_event", - id: requestId, - event: { type: "response.completed", response: finalResponse }, - }); + // Final chunk with finish_reason + const finishReason = completedToolCalls.length > 0 ? "tool_calls" : "stop"; + emitChunk({}, finishReason); - // Send stream_end + // Send stream_end with usage sendMessage({ type: "stream_end", id: requestId, usage }); } @@ -432,7 +370,7 @@ async function handleStreaming( async function handleNonStreaming( requestId: string, - responseId: string, + completionId: string, modelName: string, model: ReturnType, context: Context, @@ -447,14 +385,50 @@ async function handleNonStreaming( ...(images.length > 0 ? { images } : {}), } as any); - // Extract text from assistant message + // Extract text const text = result.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); - const usage = toOpenResponsesUsage(result.usage); - const response = buildResponseResource(responseId, modelName, text, usage); + // Extract tool calls + const toolCalls = result.content.filter( + (c): c is ToolCall => c.type === "toolCall", + ); + + // Build tool_calls array if present + const toolCallItems: ToolCallType[] = toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, + })); + + // Determine finish_reason + let finishReason: "stop" | "length" | "tool_calls" = "stop"; + if (toolCallItems.length > 0) { + finishReason = "tool_calls"; + } else if (result.stopReason === "length") { + finishReason = "length"; + } + + const usage = toChatCompletionsUsage(result.usage); + + const response = { + id: completionId, + object: "chat.completion" as const, + created: Math.floor(Date.now() / 1000), + model: modelName, + choices: [{ + index: 0, + message: { + role: "assistant" as const, + content: text || null, + ...(toolCallItems.length > 0 ? { tool_calls: toolCallItems } : {}), + }, + finish_reason: finishReason, + }], + usage, + }; sendMessage({ type: "response", diff --git a/src/client/supplier/schemas/chatcompletions.ts b/src/client/supplier/schemas/chatcompletions.ts new file mode 100644 index 0000000..d5c60f6 --- /dev/null +++ b/src/client/supplier/schemas/chatcompletions.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Content Parts (reused from OpenResponses — multipart user messages) +// --------------------------------------------------------------------------- + +export const TextContentPartSchema = z.strictObject({ + type: z.literal("text"), + text: z.string(), +}); + +export const ImageUrlContentPartSchema = z.strictObject({ + type: z.literal("image_url"), + image_url: z.object({ + url: z.string(), + detail: z.enum(["auto", "low", "high"]).optional(), + }), +}); + +export const ContentPartSchema = z.union([ + TextContentPartSchema, + ImageUrlContentPartSchema, +]); + +export type ContentPart = z.infer; + +// --------------------------------------------------------------------------- +// Tool Call (nested inside assistant messages) +// --------------------------------------------------------------------------- + +export const ToolCallSchema = z.strictObject({ + id: z.string(), + type: z.literal("function"), + function: z.strictObject({ + name: z.string(), + arguments: z.string(), + }), +}); + +export type ToolCallType = z.infer; + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +export const SystemMessageSchema = z.strictObject({ + role: z.literal("system"), + content: z.string(), +}); + +export const UserMessageSchema = z.strictObject({ + role: z.literal("user"), + content: z.union([z.string(), z.array(ContentPartSchema)]), +}); + +export const AssistantMessageSchema = z.strictObject({ + role: z.literal("assistant"), + content: z.string().nullable().optional(), + tool_calls: z.array(ToolCallSchema).optional(), +}); + +export const ToolMessageSchema = z.strictObject({ + role: z.literal("tool"), + content: z.string(), + tool_call_id: z.string(), +}); + +export const MessageSchema = z.discriminatedUnion("role", [ + SystemMessageSchema, + UserMessageSchema, + AssistantMessageSchema, + ToolMessageSchema, +]); + +export type Message = z.infer; + +// --------------------------------------------------------------------------- +// Tool Definitions (same format as OpenResponses — unchanged) +// --------------------------------------------------------------------------- + +export const FunctionToolDefinitionSchema = z.strictObject({ + type: z.literal("function"), + function: z.object({ + name: z.string().min(1, "Tool name cannot be empty"), + description: z.string().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + }), +}); + +export const ToolDefinitionSchema = FunctionToolDefinitionSchema; + +export type ToolDefinition = z.infer; + +// --------------------------------------------------------------------------- +// Tool Choice (same format — unchanged) +// --------------------------------------------------------------------------- + +export const ToolChoiceSchema = z.union([ + z.literal("auto"), + z.literal("none"), + z.literal("required"), + z.object({ + type: z.literal("function"), + function: z.object({ name: z.string() }), + }), +]); + +// --------------------------------------------------------------------------- +// Chat Completion Request +// --------------------------------------------------------------------------- + +export const ChatCompletionRequestSchema = z.strictObject({ + model: z.string(), + messages: z.array(MessageSchema), + tools: z.array(ToolDefinitionSchema).optional(), + tool_choice: ToolChoiceSchema.optional(), + stream: z.boolean().optional(), + max_tokens: z.number().int().positive().optional(), + // Passthrough fields (accepted but not used by plugin) + temperature: z.number().optional(), + top_p: z.number().optional(), + user: z.string().optional(), + // Sixerr marketplace extensions + routing: z.enum(["cheapest", "fastest"]).optional(), + max_input_token_price: z.string().optional(), + max_output_token_price: z.string().optional(), + bid_timeout_seconds: z.number().int().positive().optional(), +}); + +export type ChatCompletionRequest = z.infer; + +// --------------------------------------------------------------------------- +// Chat Completion Response (non-streaming) +// --------------------------------------------------------------------------- + +export const ChatCompletionUsageSchema = z.object({ + prompt_tokens: z.number().int().nonnegative(), + completion_tokens: z.number().int().nonnegative(), + total_tokens: z.number().int().nonnegative(), +}); + +export type ChatCompletionUsage = z.infer; + +export const ChatCompletionChoiceSchema = z.object({ + index: z.number().int().nonnegative(), + message: AssistantMessageSchema, + finish_reason: z.enum(["stop", "length", "tool_calls"]).nullable(), +}); + +export const ChatCompletionSchema = z.object({ + id: z.string(), + object: z.literal("chat.completion"), + created: z.number().int(), + model: z.string(), + choices: z.array(ChatCompletionChoiceSchema), + usage: ChatCompletionUsageSchema, +}); + +export type ChatCompletion = z.infer; + +// --------------------------------------------------------------------------- +// Chat Completion Chunk (streaming) +// --------------------------------------------------------------------------- + +export const ChatCompletionChunkDeltaSchema = z.object({ + role: z.literal("assistant").optional(), + content: z.string().nullable().optional(), + tool_calls: z.array(z.object({ + index: z.number().int().nonnegative(), + id: z.string().optional(), + type: z.literal("function").optional(), + function: z.object({ + name: z.string().optional(), + arguments: z.string().optional(), + }).optional(), + })).optional(), +}); + +export const ChatCompletionChunkChoiceSchema = z.object({ + index: z.number().int().nonnegative(), + delta: ChatCompletionChunkDeltaSchema, + finish_reason: z.enum(["stop", "length", "tool_calls"]).nullable(), +}); + +export const ChatCompletionChunkSchema = z.object({ + id: z.string(), + object: z.literal("chat.completion.chunk"), + created: z.number().int(), + model: z.string(), + choices: z.array(ChatCompletionChunkChoiceSchema), + usage: ChatCompletionUsageSchema.nullable().optional(), +}); + +export type ChatCompletionChunk = z.infer; diff --git a/src/client/supplier/schemas/index.ts b/src/client/supplier/schemas/index.ts index cb40131..c15b5e8 100644 --- a/src/client/supplier/schemas/index.ts +++ b/src/client/supplier/schemas/index.ts @@ -3,6 +3,6 @@ /** Schema version for copy-drift detection between server and plugin. */ export const SCHEMA_VERSION = 1 as const; -export * from "./openresponses.js"; +export * from "./chatcompletions.js"; export * from "./protocol.js"; export * from "./errors.js"; diff --git a/src/client/supplier/schemas/openresponses.ts b/src/client/supplier/schemas/openresponses.ts deleted file mode 100644 index 541a5bb..0000000 --- a/src/client/supplier/schemas/openresponses.ts +++ /dev/null @@ -1,342 +0,0 @@ -// SOURCE OF TRUTH: sixerr-server/src/schemas/openresponses.ts -// Adapted from openclaw/src/gateway/open-responses.schema.ts using Zod 4 z.strictObject() - -import { z } from "zod"; - -// --------------------------------------------------------------------------- -// Content Parts -// --------------------------------------------------------------------------- - -export const InputTextContentPartSchema = z.strictObject({ - type: z.literal("input_text"), - text: z.string(), -}); - -export const OutputTextContentPartSchema = z.strictObject({ - type: z.literal("output_text"), - text: z.string(), -}); - -// OpenResponses Image Content: Supports URL or base64 sources -export const InputImageSourceSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("url"), - url: z.string().url(), - }), - z.object({ - type: z.literal("base64"), - media_type: z.enum(["image/jpeg", "image/png", "image/gif", "image/webp"]), - data: z.string().min(1), // base64-encoded - }), -]); - -export const InputImageContentPartSchema = z.strictObject({ - type: z.literal("input_image"), - source: InputImageSourceSchema, -}); - -// OpenResponses File Content: Supports URL or base64 sources -export const InputFileSourceSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("url"), - url: z.string().url(), - }), - z.object({ - type: z.literal("base64"), - media_type: z.string().min(1), // MIME type - data: z.string().min(1), // base64-encoded - filename: z.string().optional(), - }), -]); - -export const InputFileContentPartSchema = z.strictObject({ - type: z.literal("input_file"), - source: InputFileSourceSchema, -}); - -export const ContentPartSchema = z.discriminatedUnion("type", [ - InputTextContentPartSchema, - OutputTextContentPartSchema, - InputImageContentPartSchema, - InputFileContentPartSchema, -]); - -export type ContentPart = z.infer; - -// --------------------------------------------------------------------------- -// Item Types (ItemParam) -// --------------------------------------------------------------------------- - -export const MessageItemRoleSchema = z.enum(["system", "developer", "user", "assistant"]); - -export type MessageItemRole = z.infer; - -export const MessageItemSchema = z.strictObject({ - type: z.literal("message"), - role: MessageItemRoleSchema, - content: z.union([z.string(), z.array(ContentPartSchema)]), -}); - -export const FunctionCallItemSchema = z.strictObject({ - type: z.literal("function_call"), - id: z.string().optional(), - call_id: z.string().optional(), - name: z.string(), - arguments: z.string(), -}); - -export const FunctionCallOutputItemSchema = z.strictObject({ - type: z.literal("function_call_output"), - call_id: z.string(), - output: z.string(), -}); - -export const ReasoningItemSchema = z.strictObject({ - type: z.literal("reasoning"), - content: z.string().optional(), - encrypted_content: z.string().optional(), - summary: z.string().optional(), -}); - -export const ItemReferenceItemSchema = z.strictObject({ - type: z.literal("item_reference"), - id: z.string(), -}); - -export const ItemParamSchema = z.discriminatedUnion("type", [ - MessageItemSchema, - FunctionCallItemSchema, - FunctionCallOutputItemSchema, - ReasoningItemSchema, - ItemReferenceItemSchema, -]); - -export type ItemParam = z.infer; - -// --------------------------------------------------------------------------- -// Tool Definitions -// --------------------------------------------------------------------------- - -export const FunctionToolDefinitionSchema = z.strictObject({ - type: z.literal("function"), - function: z.object({ - name: z.string().min(1, "Tool name cannot be empty"), - description: z.string().optional(), - parameters: z.record(z.string(), z.unknown()).optional(), - }), -}); - -// OpenResponses tool definitions match internal ToolDefinition structure -export const ToolDefinitionSchema = FunctionToolDefinitionSchema; - -export type ToolDefinition = z.infer; - -// --------------------------------------------------------------------------- -// Request Body -// --------------------------------------------------------------------------- - -export const ToolChoiceSchema = z.union([ - z.literal("auto"), - z.literal("none"), - z.literal("required"), - z.object({ - type: z.literal("function"), - function: z.object({ name: z.string() }), - }), -]); - -export const CreateResponseBodySchema = z.strictObject({ - model: z.string(), - input: z.union([z.string(), z.array(ItemParamSchema)]), - instructions: z.string().optional(), - tools: z.array(ToolDefinitionSchema).optional(), - tool_choice: ToolChoiceSchema.optional(), - stream: z.boolean().optional(), - max_output_tokens: z.number().int().positive().optional(), - max_tool_calls: z.number().int().positive().optional(), - user: z.string().optional(), - // Phase 1: ignore but accept these fields - temperature: z.number().optional(), - top_p: z.number().optional(), - metadata: z.record(z.string(), z.string()).optional(), - store: z.boolean().optional(), - previous_response_id: z.string().optional(), - reasoning: z - .strictObject({ - effort: z.enum(["low", "medium", "high"]).optional(), - summary: z.enum(["auto", "concise", "detailed"]).optional(), - }) - .optional(), - truncation: z.enum(["auto", "disabled"]).optional(), -}); - -export type CreateResponseBody = z.infer; - -// --------------------------------------------------------------------------- -// Response Resource -// --------------------------------------------------------------------------- - -export const ResponseStatusSchema = z.enum([ - "in_progress", - "completed", - "failed", - "cancelled", - "incomplete", -]); - -export type ResponseStatus = z.infer; - -export const OutputItemSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("message"), - id: z.string(), - role: z.literal("assistant"), - content: z.array(OutputTextContentPartSchema), - status: z.enum(["in_progress", "completed"]).optional(), - }), - z.object({ - type: z.literal("function_call"), - id: z.string(), - call_id: z.string(), - name: z.string(), - arguments: z.string(), - status: z.enum(["in_progress", "completed"]).optional(), - }), - z.object({ - type: z.literal("reasoning"), - id: z.string(), - content: z.string().optional(), - summary: z.string().optional(), - }), -]); - -export type OutputItem = z.infer; - -export const UsageSchema = z.object({ - input_tokens: z.number().int().nonnegative(), - output_tokens: z.number().int().nonnegative(), - total_tokens: z.number().int().nonnegative(), -}); - -export type Usage = z.infer; - -export const ResponseResourceSchema = z.object({ - id: z.string(), - object: z.literal("response"), - created_at: z.number().int(), - status: ResponseStatusSchema, - model: z.string(), - output: z.array(OutputItemSchema), - usage: UsageSchema, - // Optional fields for future phases - error: z - .object({ - code: z.string(), - message: z.string(), - }) - .optional(), -}); - -export type ResponseResource = z.infer; - -// --------------------------------------------------------------------------- -// Streaming Event Types -// --------------------------------------------------------------------------- - -export const ResponseCreatedEventSchema = z.object({ - type: z.literal("response.created"), - response: ResponseResourceSchema, -}); - -export const ResponseInProgressEventSchema = z.object({ - type: z.literal("response.in_progress"), - response: ResponseResourceSchema, -}); - -export const ResponseCompletedEventSchema = z.object({ - type: z.literal("response.completed"), - response: ResponseResourceSchema, -}); - -export const ResponseFailedEventSchema = z.object({ - type: z.literal("response.failed"), - response: ResponseResourceSchema, -}); - -export const OutputItemAddedEventSchema = z.object({ - type: z.literal("response.output_item.added"), - output_index: z.number().int().nonnegative(), - item: OutputItemSchema, -}); - -export const OutputItemDoneEventSchema = z.object({ - type: z.literal("response.output_item.done"), - output_index: z.number().int().nonnegative(), - item: OutputItemSchema, -}); - -export const ContentPartAddedEventSchema = z.object({ - type: z.literal("response.content_part.added"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - content_index: z.number().int().nonnegative(), - part: OutputTextContentPartSchema, -}); - -export const ContentPartDoneEventSchema = z.object({ - type: z.literal("response.content_part.done"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - content_index: z.number().int().nonnegative(), - part: OutputTextContentPartSchema, -}); - -export const OutputTextDeltaEventSchema = z.object({ - type: z.literal("response.output_text.delta"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - content_index: z.number().int().nonnegative(), - delta: z.string(), -}); - -export const OutputTextDoneEventSchema = z.object({ - type: z.literal("response.output_text.done"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - content_index: z.number().int().nonnegative(), - text: z.string(), -}); - -// Function call argument streaming events (OpenAI Responses API spec) -// Not currently emitted by OpenClaw but defined for forward compatibility - -export const FunctionCallArgumentsDeltaEventSchema = z.object({ - type: z.literal("response.function_call_arguments.delta"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - call_id: z.string(), - delta: z.string(), -}); - -export const FunctionCallArgumentsDoneEventSchema = z.object({ - type: z.literal("response.function_call_arguments.done"), - item_id: z.string(), - output_index: z.number().int().nonnegative(), - call_id: z.string(), - name: z.string(), - arguments: z.string(), -}); - -export type StreamingEvent = - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer - | z.infer; diff --git a/src/client/supplier/schemas/protocol.ts b/src/client/supplier/schemas/protocol.ts index 4b609b0..3134798 100644 --- a/src/client/supplier/schemas/protocol.ts +++ b/src/client/supplier/schemas/protocol.ts @@ -94,8 +94,8 @@ export const PluginStreamEndMessageSchema = z.strictObject({ type: z.literal("stream_end"), id: z.string().min(1), usage: z.strictObject({ - input_tokens: z.number().int().nonnegative(), - output_tokens: z.number().int().nonnegative(), + prompt_tokens: z.number().int().nonnegative(), + completion_tokens: z.number().int().nonnegative(), total_tokens: z.number().int().nonnegative(), }), }); diff --git a/src/client/supplier/schemas/schemas.test.ts b/src/client/supplier/schemas/schemas.test.ts index 6a0536d..28a698b 100644 --- a/src/client/supplier/schemas/schemas.test.ts +++ b/src/client/supplier/schemas/schemas.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { - CreateResponseBodySchema, + ChatCompletionRequestSchema, ServerMessageSchema, PluginMessageSchema, SCHEMA_VERSION, @@ -30,7 +30,7 @@ describe("Plugin receives server messages (ServerMessageSchema)", () => { const result = ServerMessageSchema.safeParse({ type: "request", id: "req-1", - body: { model: "test", input: "Hello" }, + body: { model: "test", messages: [{ role: "user", content: "Hello" }] }, }); expect(result.success).toBe(true); if (result.success) { @@ -137,8 +137,8 @@ describe("Plugin sends messages (PluginMessageSchema)", () => { type: "stream_end", id: "req-1", usage: { - input_tokens: 10, - output_tokens: 20, + prompt_tokens: 10, + completion_tokens: 20, total_tokens: 30, }, }); @@ -192,24 +192,106 @@ describe("Plugin sends messages (PluginMessageSchema)", () => { }); // --------------------------------------------------------------------------- -// CreateResponseBodySchema in plugin context +// ChatCompletionRequestSchema in plugin context // --------------------------------------------------------------------------- -describe("CreateResponseBodySchema in plugin context", () => { +describe("ChatCompletionRequestSchema in plugin context", () => { it("accepts minimal valid request", () => { - const result = CreateResponseBodySchema.safeParse({ - model: "openclaw/main", - input: "Hello", + const result = ChatCompletionRequestSchema.safeParse({ + model: "anthropic/claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Hello" }], }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.model).toBe("openclaw/main"); - expect(result.data.input).toBe("Hello"); + expect(result.data.model).toBe("anthropic/claude-sonnet-4-5-20250929"); + expect(result.data.messages).toHaveLength(1); } }); + it("accepts request with system message", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + messages: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello" }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.messages).toHaveLength(2); + } + }); + + it("accepts request with tools", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + messages: [{ role: "user", content: "What is the weather?" }], + tools: [{ + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { type: "object", properties: { location: { type: "string" } } }, + }, + }], + tool_choice: "auto", + }); + expect(result.success).toBe(true); + }); + + it("accepts request with tool results", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + messages: [ + { role: "user", content: "What is the weather?" }, + { role: "assistant", content: null, tool_calls: [{ id: "tc1", type: "function", function: { name: "get_weather", arguments: '{"location":"NYC"}' } }] }, + { role: "tool", content: '{"temp":72}', tool_call_id: "tc1" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts request with max_tokens", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + messages: [{ role: "user", content: "Hello" }], + max_tokens: 1024, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.max_tokens).toBe(1024); + } + }); + + it("accepts request with Sixerr marketplace extensions", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + messages: [{ role: "user", content: "Hello" }], + routing: "cheapest", + max_input_token_price: "100", + max_output_token_price: "200", + bid_timeout_seconds: 30, + }); + expect(result.success).toBe(true); + }); + it("rejects malformed request (missing model)", () => { - const result = CreateResponseBodySchema.safeParse({ + const result = ChatCompletionRequestSchema.safeParse({ + messages: [{ role: "user", content: "Hello" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects malformed request (missing messages)", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", + }); + expect(result.success).toBe(false); + }); + + it("rejects old OpenResponses format (input field)", () => { + const result = ChatCompletionRequestSchema.safeParse({ + model: "default", input: "Hello", }); expect(result.success).toBe(false); diff --git a/src/openclaw/provider.ts b/src/openclaw/provider.ts index d1e5921..44bd59c 100644 --- a/src/openclaw/provider.ts +++ b/src/openclaw/provider.ts @@ -32,7 +32,7 @@ export interface OpenClawPluginApi { // --------------------------------------------------------------------------- /** - * Derive the HTTP base URL for OpenResponses API calls from the config's + * Derive the HTTP base URL for Chat Completions API calls from the config's * server URL (which may be WS or HTTP). */ function httpUrlFromConfig(serverUrl: string): string { diff --git a/src/proxy/http-proxy.ts b/src/proxy/http-proxy.ts index 459cf6c..0bed2e2 100644 --- a/src/proxy/http-proxy.ts +++ b/src/proxy/http-proxy.ts @@ -9,9 +9,9 @@ import type { SixerrClient } from "../client/consumer/sixerr-client.js"; /** * Create a local HTTP proxy that handles x402 Permit2 signing transparently. * - * OpenClaw (or any HTTP client) can POST OpenResponses requests to - * `http://127.0.0.1:{port}/v1/responses` — the proxy forwards them to the - * Sixerr server with payment signatures injected automatically. + * OpenClaw (or any HTTP client) can POST Chat Completions requests to + * `http://127.0.0.1:{port}/v1/chat/completions` — the proxy forwards them to + * the Sixerr server with payment signatures injected automatically. * * Binds to 127.0.0.1 only — no network exposure. */ @@ -65,11 +65,11 @@ async function handleRequest( return; } - // POST /v1/responses or /v1/responses/:agentId - const responsesMatch = method === "POST" && url.match(/^\/v1\/responses(?:\/([^/?]+))?$/); - if (responsesMatch) { - const agentId = responsesMatch[1]; // undefined if no segment - await handleResponses(client, req, res, agentId); + // POST /v1/chat/completions or /v1/chat/completions/:agentId + const completionsMatch = method === "POST" && url.match(/^\/v1\/chat\/completions(?:\/([^/?]+))?$/); + if (completionsMatch) { + const agentId = completionsMatch[1]; // undefined if no segment + await handleCompletions(client, req, res, agentId); return; } @@ -79,10 +79,10 @@ async function handleRequest( } // --------------------------------------------------------------------------- -// POST /v1/responses handler +// POST /v1/chat/completions handler // --------------------------------------------------------------------------- -async function handleResponses( +async function handleCompletions( client: SixerrClient, req: http.IncomingMessage, res: http.ServerResponse,