From d12ca9f9d0d3c289758e9a80ff9a856e01ce4592 Mon Sep 17 00:00:00 2001 From: skredik <126781073+alexanderkreidich@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:04:49 +0200 Subject: [PATCH] Support pretty rendering for Responses API inputs --- .../src/utils/chatml/adapters/openai.ts | 116 +++++++++++++++++- packages/shared/src/utils/chatml/core.ts | 4 +- .../chatml/jumptoplayground.clienttest.ts | 21 ++++ .../__tests__/chatml/adapters/openai.test.ts | 102 +++++++++++++++ .../src/__tests__/chatml/integration.test.ts | 21 ++++ 5 files changed, 257 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/utils/chatml/adapters/openai.ts b/packages/shared/src/utils/chatml/adapters/openai.ts index 2d59d3f011dc..bca3af1104ec 100644 --- a/packages/shared/src/utils/chatml/adapters/openai.ts +++ b/packages/shared/src/utils/chatml/adapters/openai.ts @@ -12,12 +12,63 @@ import { z } from "zod"; * These are permissive - only validate structural markers, not full API contracts */ +function hasResponsesInputItems(input: unknown): boolean { + return ( + Array.isArray(input) && + input.some((item) => { + if (!item || typeof item !== "object") return false; + + const obj = item as Record; + return "role" in obj || "type" in obj || "content" in obj; + }) + ); +} + +function hasResponsesRequestMarkers(data: Record): boolean { + return ( + "tools" in data || + "reasoning" in data || + "store" in data || + "include" in data || + "instructions" in data || + "previous_response_id" in data || + "truncation" in data || + "max_output_tokens" in data || + "parallel_tool_calls" in data + ); +} + +function hasStringResponsesInput(data: Record): boolean { + if (typeof data.input !== "string") return false; + if (hasResponsesRequestMarkers(data)) return true; + + return ( + typeof data.model === "string" && + !data.model.toLowerCase().includes("embedding") + ); +} + // INPUT SCHEMAS (requests) const OpenAIInputChatCompletionsSchema = z.looseObject({ messages: z.array(z.any()), tools: z.array(z.any()).optional(), }); +const OpenAIInputResponsesSchema = z + .looseObject({ + input: z.union([z.string(), z.array(z.any())]), + tools: z.array(z.any()).optional(), + }) + .refine((data) => { + if (typeof data.input === "string") { + return hasStringResponsesInput(data); + } + + return ( + hasResponsesInputItems(data.input) || hasResponsesRequestMarkers(data) + ); + }); + const OpenAIInputMessagesSchema = z .array( z.looseObject({ @@ -266,6 +317,17 @@ function normalizeMessage(msg: unknown): Record { return normalized; } +function normalizeResponsesInputItem(item: unknown): Record { + if (typeof item === "string") { + return { + role: "user", + content: item, + }; + } + + return normalizeMessage(item); +} + /** * Flatten tool definition from nested or flat format to standard format * Handles both Chat Completions {type, function: {name, ...}} and flat {name, ...} @@ -288,7 +350,18 @@ function flattenToolDefinition(tool: unknown): Record { function preprocessData(data: unknown): unknown { if (!data) return data; - // OpenAI Chat Completions API: {tools, messages} OR Responses API: {tools, output} + const looksLikeResponsesInput = + typeof data === "object" && + data !== null && + !Array.isArray(data) && + "input" in data && + (hasResponsesInputItems((data as Record).input) || + hasStringResponsesInput(data as Record) || + hasResponsesRequestMarkers(data as Record)); + + // OpenAI Chat Completions API: {tools, messages} + // OpenAI Responses API request: {tools, input} + // OpenAI Responses API response: {tools, output} // References: // - https://platform.openai.com/docs/api-reference/chat/create // - https://platform.openai.com/docs/api-reference/responses @@ -296,20 +369,49 @@ function preprocessData(data: unknown): unknown { typeof data === "object" && !Array.isArray(data) && "tools" in data && - (("messages" in data && !("output" in data)) || "output" in data) + (("messages" in data && !("output" in data) && !("input" in data)) || + looksLikeResponsesInput || + "output" in data) ) { const obj = data as Record; - const messagesArray = (obj.messages ?? obj.output) as unknown[]; + const messagesArray = obj.messages ?? obj.input ?? obj.output; + + if ( + (Array.isArray(messagesArray) || typeof messagesArray === "string") && + Array.isArray(obj.tools) + ) { + const normalizedMessages = Array.isArray(messagesArray) + ? "input" in obj + ? messagesArray.map(normalizeResponsesInputItem) + : messagesArray.map(normalizeMessage) + : [normalizeResponsesInputItem(messagesArray)]; - if (Array.isArray(messagesArray) && Array.isArray(obj.tools)) { // Attach tools to all messages - return messagesArray.map((msg) => ({ - ...normalizeMessage(msg), + return normalizedMessages.map((msg) => ({ + ...msg, tools: (obj.tools as unknown[]).map(flattenToolDefinition), })); } } + // Responses API request without tools: {input: [...] | "text"} + if ( + typeof data === "object" && + !Array.isArray(data) && + looksLikeResponsesInput && + !("messages" in data) && + !("tools" in data) && + !("output" in data) + ) { + const obj = data as Record; + if (Array.isArray(obj.input)) { + return (obj.input as unknown[]).map(normalizeResponsesInputItem); + } + if (typeof obj.input === "string") { + return [normalizeResponsesInputItem(obj.input)]; + } + } + // Responses API without tools: {output: [...]} if ( typeof data === "object" && @@ -447,6 +549,7 @@ export const openAIAdapter: ProviderAdapter = { // STRUCTURAL: Schema-based detection on metadata if (OpenAIInputChatCompletionsSchema.safeParse(ctx.metadata).success) return true; + if (OpenAIInputResponsesSchema.safeParse(ctx.metadata).success) return true; if (OpenAIInputMessagesSchema.safeParse(ctx.metadata).success) return true; if (OpenAIOutputResponsesSchema.safeParse(ctx.metadata).success) return true; @@ -458,6 +561,7 @@ export const openAIAdapter: ProviderAdapter = { // data into metadata. we only do this last due to performance concerns. if (OpenAIInputChatCompletionsSchema.safeParse(ctx.data).success) return true; + if (OpenAIInputResponsesSchema.safeParse(ctx.data).success) return true; if (OpenAIInputMessagesSchema.safeParse(ctx.data).success) return true; if (OpenAIOutputResponsesSchema.safeParse(ctx.data).success) return true; if (OpenAIOutputChoicesSchema.safeParse(ctx.data).success) return true; diff --git a/packages/shared/src/utils/chatml/core.ts b/packages/shared/src/utils/chatml/core.ts index cbffa163755b..4cd7e9f8e14c 100644 --- a/packages/shared/src/utils/chatml/core.ts +++ b/packages/shared/src/utils/chatml/core.ts @@ -87,7 +87,9 @@ export function extractAdditionalInput( const additionalInput = typeof input === "object" && input !== null && !Array.isArray(input) ? Object.fromEntries( - Object.entries(input as object).filter(([key]) => key !== "messages"), + Object.entries(input as object).filter( + ([key]) => key !== "messages" && key !== "input", + ), ) : undefined; diff --git a/web/src/utils/chatml/jumptoplayground.clienttest.ts b/web/src/utils/chatml/jumptoplayground.clienttest.ts index 84a9759947e0..9b759bf3acba 100644 --- a/web/src/utils/chatml/jumptoplayground.clienttest.ts +++ b/web/src/utils/chatml/jumptoplayground.clienttest.ts @@ -763,6 +763,27 @@ describe("Playground Jump Full Pipeline", () => { } }); + it("should autodetect plain Responses string input for playground conversion", () => { + const input = { + input: "Hello from responses", + model: "gpt-4.1", + }; + + const inResult = normalizeInput(input); + expect(inResult.success).toBe(true); + + const playgroundMessages = inResult + .data!.map(convertChatMlToPlayground) + .filter((msg) => msg !== null); + + expect(playgroundMessages).toHaveLength(1); + expect(playgroundMessages[0]?.type).toBe("public-api-created"); + if (playgroundMessages[0]?.type === "public-api-created") { + expect(playgroundMessages[0].role).toBe("user"); + expect(playgroundMessages[0].content).toBe("Hello from responses"); + } + }); + it("should handle VAPI camelCase toolCalls and preserve IDs", () => { // VAPI uses camelCase toolCalls instead of tool_calls // Critical: Tool call IDs must be preserved for OpenAI API compatibility diff --git a/worker/src/__tests__/chatml/adapters/openai.test.ts b/worker/src/__tests__/chatml/adapters/openai.test.ts index 3c7c3fa406a7..f19987207872 100644 --- a/worker/src/__tests__/chatml/adapters/openai.test.ts +++ b/worker/src/__tests__/chatml/adapters/openai.test.ts @@ -48,6 +48,16 @@ describe("OpenAI Adapter", () => { }), ).toBe(true); + // Responses API request format with {input, tools} + expect( + openAIAdapter.detect({ + metadata: { + input: [{ role: "user", content: "test" }], + tools: [{ type: "function", function: { name: "test" } }], + }, + }), + ).toBe(true); + // Response format with nested tool_calls (via data field) expect( openAIAdapter.detect({ @@ -99,6 +109,43 @@ describe("OpenAI Adapter", () => { // Should still successfully detect as OpenAI format despite null items expect(openAIAdapter.detect({ metadata: messagesWithNull })).toBe(true); }); + + it("should not treat embedding-style input payloads as Responses chat", () => { + expect( + openAIAdapter.detect({ + metadata: { + input: ["hello world"], + model: "text-embedding-3-small", + }, + }), + ).toBe(false); + + expect( + openAIAdapter.detect({ + metadata: { + input: "hello world", + model: "text-embedding-3-small", + }, + }), + ).toBe(false); + }); + + it("should autodetect plain string Responses requests when model is non-embedding", () => { + const input = { + input: "Hello from responses", + model: "gpt-4.1", + }; + + expect(openAIAdapter.detect({ metadata: input })).toBe(true); + + const result = normalizeInput(input); + + expect(result.success).toBe(true); + expect(result.data?.[0]).toMatchObject({ + role: "user", + content: "Hello from responses", + }); + }); }); it("should normalize tool_calls arguments to JSON strings", () => { @@ -182,6 +229,61 @@ describe("OpenAI Adapter", () => { expect(Array.isArray(result.data?.[0].content)).toBe(true); }); + it("should normalize Responses API input requests", () => { + const input = { + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Hello from responses" }], + }, + ], + tools: [ + { + type: "function", + function: { + name: "lookup_weather", + description: "Weather lookup", + }, + }, + ], + }; + + const result = normalizeInput(input, { framework: "openai" }); + + expect(result.success).toBe(true); + expect(result.data?.[0].role).toBe("user"); + expect(result.data?.[0].content).toEqual([ + { type: "input_text", text: "Hello from responses" }, + ]); + expect(result.data?.[0].tools?.[0].name).toBe("lookup_weather"); + }); + + it("should normalize string Responses API input requests", () => { + const input = { + input: "Hello from responses", + max_output_tokens: 256, + }; + + const result = normalizeInput(input, { framework: "openai" }); + + expect(result.success).toBe(true); + expect(result.data?.[0]).toMatchObject({ + role: "user", + content: "Hello from responses", + }); + }); + + it("should not normalize embedding-style input payloads as chat", () => { + const input = { + input: ["hello world"], + model: "text-embedding-3-small", + }; + + const result = normalizeInput(input, { framework: "openai" }); + + expect(result.success).toBe(false); + }); + it("should remove null fields from messages", () => { const input = { messages: [ diff --git a/worker/src/__tests__/chatml/integration.test.ts b/worker/src/__tests__/chatml/integration.test.ts index 495ccf067a98..c19f2832d13d 100644 --- a/worker/src/__tests__/chatml/integration.test.ts +++ b/worker/src/__tests__/chatml/integration.test.ts @@ -45,6 +45,27 @@ describe("ChatML Integration", () => { }); }); + it("should exclude Responses API input from additionalInput", () => { + const input = { + input: [{ role: "user", content: "Hello from responses" }], + model: "gpt-5.4", + max_output_tokens: 2048, + }; + + const inResult = normalizeInput(input, { framework: "openai" }); + const additionalInput = extractAdditionalInput(input); + + expect(inResult.success).toBe(true); + expect(inResult.data?.[0]).toMatchObject({ + role: "user", + content: "Hello from responses", + }); + expect(additionalInput).toEqual({ + model: "gpt-5.4", + max_output_tokens: 2048, + }); + }); + it("should handle nested array format [[ChatML...]]", () => { const input = [ [