fix(ai): ported OpenAI API fixes from pi-mono

- Fixed OpenAI Responses API 400 error when switching between models
  (same provider, different model). Omits fc_ prefixed IDs from different
  models to avoid reasoning/function_call pairing validation errors.
  (ported from d327b9c7)

- Fixed 400 errors when reading multiple images via GitHub Copilot's
  Claude models. Batches images from consecutive tool results into a
  single user message to maintain tool_use -> tool_result adjacency.
  (ported from 6289c144)
This commit is contained in:
can1357
2026-01-23 12:40:24 +01:00
parent bb59279705
commit 64048b2b06
4 changed files with 172 additions and 42 deletions
+5
View File
@@ -2,6 +2,11 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses API 400 error "function_call without required reasoning item" when switching between models (same provider, different model). The fix omits the `id` field for function_calls from different models to avoid triggering OpenAI's reasoning/function_call pairing validation
- Fixed 400 errors when reading multiple images via GitHub Copilot's Claude models. Claude requires tool_use -> tool_result adjacency with no user messages interleaved. Images from consecutive tool results are now batched into a single user message
## [7.0.0] - 2026-01-21
### Added
+60 -40
View File
@@ -23,6 +23,7 @@ import type {
ThinkingContent,
Tool,
ToolCall,
ToolResultMessage,
} from "$ai/types";
import { AssistantMessageEventStream } from "$ai/utils/event-stream";
import { parseStreamingJson } from "$ai/utils/json-parse";
@@ -464,7 +465,7 @@ function maybeAddOpenRouterAnthropicCacheControl(
}
}
function convertMessages(
export function convertMessages(
model: Model<"openai-completions">,
context: Context,
compat: Required<OpenAICompat>,
@@ -481,7 +482,8 @@ function convertMessages(
let lastRole: string | null = null;
for (const msg of transformedMessages) {
for (let i = 0; i < transformedMessages.length; i++) {
const msg = transformedMessages[i];
// Some providers (e.g. Mistral/Devstral) don't allow user messages directly after tool results
// Insert a synthetic assistant message to bridge the gap
if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") {
@@ -605,55 +607,73 @@ function convertMessages(
}
params.push(assistantMsg);
} else if (msg.role === "toolResult") {
// Extract text and image content
const textResult = msg.content
.filter((c) => c.type === "text")
.map((c) => (c as any).text)
.join("\n");
const hasImages = msg.content.some((c) => c.type === "image");
// Batch consecutive tool results and collect all images
const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
let j = i;
// Always send tool result with text (or placeholder if only images)
const hasText = textResult.length > 0;
// Some providers (e.g. Mistral) require the 'name' field in tool results
const toolResultMsg: ChatCompletionToolMessageParam = {
role: "tool",
content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
tool_call_id: normalizeMistralToolId(msg.toolCallId, compat.requiresMistralToolIds),
};
if (compat.requiresToolResultName && msg.toolName) {
(toolResultMsg as any).name = msg.toolName;
}
params.push(toolResultMsg);
for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
const toolMsg = transformedMessages[j] as ToolResultMessage;
// If there are images and model supports them, send a follow-up user message with images
if (hasImages && model.input.includes("image")) {
const contentBlocks: Array<
{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
> = [];
// Extract text and image content
const textResult = toolMsg.content
.filter((c) => c.type === "text")
.map((c) => (c as any).text)
.join("\n");
const hasImages = toolMsg.content.some((c) => c.type === "image");
// Add text prefix
contentBlocks.push({
type: "text",
text: "Attached image(s) from tool result:",
});
// Always send tool result with text (or placeholder if only images)
const hasText = textResult.length > 0;
// Some providers (e.g. Mistral) require the 'name' field in tool results
const toolResultMsg: ChatCompletionToolMessageParam = {
role: "tool",
content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
tool_call_id: normalizeMistralToolId(toolMsg.toolCallId, compat.requiresMistralToolIds),
};
if (compat.requiresToolResultName && toolMsg.toolName) {
(toolResultMsg as any).name = toolMsg.toolName;
}
params.push(toolResultMsg);
// Add images
for (const block of msg.content) {
if (block.type === "image") {
contentBlocks.push({
type: "image_url",
image_url: {
url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
},
});
if (hasImages && model.input.includes("image")) {
for (const block of toolMsg.content) {
if (block.type === "image") {
imageBlocks.push({
type: "image_url",
image_url: {
url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
},
});
}
}
}
}
i = j - 1;
// After all consecutive tool results, add a single user message with all images
if (imageBlocks.length > 0) {
if (compat.requiresAssistantAfterToolResult) {
params.push({
role: "assistant",
content: "I have processed the tool results.",
});
}
params.push({
role: "user",
content: contentBlocks,
content: [
{
type: "text",
text: "Attached image(s) from tool result:",
},
...imageBlocks,
],
});
lastRole = "user";
} else {
lastRole = "toolResult";
}
continue;
}
lastRole = msg.role;
+19 -2
View File
@@ -507,6 +507,15 @@ function convertMessages(
}
} else if (msg.role === "assistant") {
const output: ResponseInput = [];
const assistantMsg = msg as AssistantMessage;
// Check if this message is from a different model (same provider, different model ID).
// For such messages, tool call IDs with fc_ prefix need to be stripped to avoid
// OpenAI's reasoning/function_call pairing validation errors.
const isDifferentModel =
assistantMsg.model !== model.id &&
assistantMsg.provider === model.provider &&
assistantMsg.api === model.api;
for (const block of msg.content) {
// Do not submit thinking blocks if the completion had an error (i.e. abort)
@@ -535,11 +544,19 @@ function convertMessages(
} else if (block.type === "toolCall" && msg.stopReason !== "error") {
const toolCall = block as ToolCall;
const normalized = normalizeResponsesToolCallId(toolCall.id);
const callId = normalized.callId;
// For different-model messages, set id to undefined to avoid pairing validation.
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
// By omitting the id, we avoid triggering that validation (like cross-provider does).
let itemId: string | undefined = normalized.itemId;
if (isDifferentModel && itemId?.startsWith("fc_")) {
itemId = undefined;
}
knownCallIds.add(normalized.callId);
output.push({
type: "function_call",
id: normalized.itemId,
call_id: normalized.callId,
id: itemId,
call_id: callId,
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
});
@@ -0,0 +1,88 @@
import { describe, expect, it } from "bun:test";
import { getModel } from "$ai/models";
import { convertMessages } from "$ai/providers/openai-completions";
import type { AssistantMessage, Context, Model, OpenAICompat, ToolResultMessage, Usage } from "$ai/types";
const emptyUsage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const compat: Required<OpenAICompat> = {
supportsStore: true,
supportsDeveloperRole: true,
supportsReasoningEffort: true,
supportsUsageInStreaming: true,
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresMistralToolIds: false,
thinkingFormat: "openai",
};
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName: "read",
content: [
{ type: "text", text: "Read image file [image/png]" },
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
],
isError: false,
timestamp,
};
}
describe("openai-completions convertMessages", () => {
it("batches tool-result images after consecutive tool results", () => {
const baseModel = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
input: ["text", "image"],
};
const now = Date.now();
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "img-1.png" } },
{ type: "toolCall", id: "tool-2", name: "read", arguments: { path: "img-2.png" } },
],
api: model.api,
provider: model.provider,
model: model.id,
usage: emptyUsage,
stopReason: "toolUse",
timestamp: now,
};
const context: Context = {
messages: [
{ role: "user", content: "Read the images", timestamp: now - 2 },
assistantMessage,
buildToolResult("tool-1", now + 1),
buildToolResult("tool-2", now + 2),
],
};
const messages = convertMessages(model, context, compat);
const roles = messages.map((message) => message.role);
expect(roles).toEqual(["user", "assistant", "tool", "tool", "user"]);
const imageMessage = messages[messages.length - 1];
expect(imageMessage.role).toBe("user");
expect(Array.isArray(imageMessage.content)).toBe(true);
const imageParts = (imageMessage.content as Array<{ type?: string }>).filter(
(part) => part?.type === "image_url",
);
expect(imageParts.length).toBe(2);
});
});