Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 110 additions & 6 deletions packages/shared/src/utils/chatml/adapters/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
return "role" in obj || "type" in obj || "content" in obj;
})
);
}

function hasResponsesRequestMarkers(data: Record<string, unknown>): 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<string, unknown>): 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);
}
Comment thread
alexanderkreidich marked this conversation as resolved.

return (
hasResponsesInputItems(data.input) || hasResponsesRequestMarkers(data)
);
});

const OpenAIInputMessagesSchema = z
.array(
z.looseObject({
Expand Down Expand Up @@ -266,6 +317,17 @@ function normalizeMessage(msg: unknown): Record<string, unknown> {
return normalized;
}

function normalizeResponsesInputItem(item: unknown): Record<string, unknown> {
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, ...}
Expand All @@ -288,28 +350,68 @@ function flattenToolDefinition(tool: unknown): Record<string, unknown> {
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<string, unknown>).input) ||
hasStringResponsesInput(data as Record<string, unknown>) ||
hasResponsesRequestMarkers(data as Record<string, unknown>));

// 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
if (
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<string, unknown>;
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<string, unknown>;
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" &&
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/utils/chatml/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions web/src/utils/chatml/jumptoplayground.clienttest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions worker/src/__tests__/chatml/adapters/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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: [
Expand Down
21 changes: 21 additions & 0 deletions worker/src/__tests__/chatml/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
[
Expand Down
Loading