fix(ai,coding-agent): preserve non-vision image placeholders closes #3429

This commit is contained in:
Mario Zechner
2026-04-20 16:52:27 +02:00
parent 74139c3f66
commit 2f4f283cc2
11 changed files with 184 additions and 123 deletions

View File

@@ -783,8 +783,7 @@ function convertMessages(
};
}
});
let filteredBlocks = !model?.input.includes("image") ? blocks.filter((b) => b.type !== "image") : blocks;
filteredBlocks = filteredBlocks.filter((b) => {
const filteredBlocks = blocks.filter((b) => {
if (b.type === "text") {
return b.text.trim().length > 0;
}

View File

@@ -116,11 +116,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
};
}
});
const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts;
if (filteredParts.length === 0) continue;
if (parts.length === 0) continue;
contents.push({
role: "user",
parts: filteredParts,
parts,
});
}
} else if (msg.role === "assistant") {

View File

@@ -97,8 +97,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const client = createClient(model, context, apiKey, options?.headers);
let params = buildParams(model, context, options);
const compat = getCompat(model);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat);
let params = buildParams(model, context, options, compat, cacheRetention);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
@@ -350,6 +353,8 @@ function createClient(
context: Context,
apiKey?: string,
optionsHeaders?: Record<string, string>,
sessionId?: string,
compat: Required<OpenAICompletionsCompat> = getCompat(model),
) {
if (!apiKey) {
if (!process.env.OPENAI_API_KEY) {
@@ -370,6 +375,12 @@ function createClient(
Object.assign(headers, copilotHeaders);
}
if (sessionId && compat.sendSessionAffinityHeaders) {
headers.session_id = sessionId;
headers["x-client-request-id"] = sessionId;
headers["x-session-affinity"] = sessionId;
}
// Merge options headers last so they can override defaults
if (optionsHeaders) {
Object.assign(headers, optionsHeaders);
@@ -383,12 +394,16 @@ function createClient(
});
}
function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
const compat = getCompat(model);
function buildParams(
model: Model<"openai-completions">,
context: Context,
options?: OpenAICompletionsOptions,
compat: Required<OpenAICompletionsCompat> = getCompat(model),
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention),
) {
const messages = convertMessages(model, context, compat);
maybeAddOpenRouterAnthropicCacheControl(model, messages);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
messages,
@@ -580,13 +595,10 @@ export function convertMessages(
} satisfies ChatCompletionContentPartImage;
}
});
const filteredContent = !model.input.includes("image")
? content.filter((c) => c.type !== "image_url")
: content;
if (filteredContent.length === 0) continue;
if (content.length === 0) continue;
params.push({
role: "user",
content: filteredContent,
content,
});
}
} else if (msg.role === "assistant") {
@@ -878,6 +890,7 @@ function detectCompat(model: Model<"openai-completions">): Required<OpenAIComple
vercelGatewayRouting: {},
zaiToolStream: false,
supportsStrictMode: true,
sendSessionAffinityHeaders: false,
};
}
@@ -905,5 +918,6 @@ function getCompat(model: Model<"openai-completions">): Required<OpenAICompletio
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
};
}

View File

@@ -153,13 +153,10 @@ export function convertResponsesMessages<TApi extends Api>(
image_url: `data:${item.mimeType};base64,${item.data}`,
} satisfies ResponseInputImage;
});
const filteredContent = !model.input.includes("image")
? content.filter((c) => c.type !== "input_image")
: content;
if (filteredContent.length === 0) continue;
if (content.length === 0) continue;
messages.push({
role: "user",
content: filteredContent,
content,
});
}
} else if (msg.role === "assistant") {

View File

@@ -1,4 +1,60 @@
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types.js";
import type {
Api,
AssistantMessage,
ImageContent,
Message,
Model,
TextContent,
ToolCall,
ToolResultMessage,
} from "../types.js";
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] {
const result: TextContent[] = [];
let previousWasPlaceholder = false;
for (const block of content) {
if (block.type === "image") {
if (!previousWasPlaceholder) {
result.push({ type: "text", text: placeholder });
}
previousWasPlaceholder = true;
continue;
}
result.push(block);
previousWasPlaceholder = block.text === placeholder;
}
return result;
}
function downgradeUnsupportedImages<TApi extends Api>(messages: Message[], model: Model<TApi>): Message[] {
if (model.input.includes("image")) {
return messages;
}
return messages.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
};
}
if (msg.role === "toolResult") {
return {
...msg,
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER),
};
}
return msg;
});
}
/**
* Normalize tool call ID for cross-provider compatibility.
@@ -12,9 +68,10 @@ export function transformMessages<TApi extends Api>(
): Message[] {
// Build a map of original tool call IDs to normalized IDs
const toolCallIdMap = new Map<string, string>();
const imageAwareMessages = downgradeUnsupportedImages(messages, model);
// First pass: transform messages (thinking blocks, tool call ID normalization)
const transformed = messages.map((msg) => {
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
const transformed = imageAwareMessages.map((msg) => {
// User messages pass through unchanged
if (msg.role === "user") {
return msg;