From f763d4ffedd97d2c8650b28d7839d5ab79463f32 Mon Sep 17 00:00:00 2001 From: Cengizhan <40668822+cngznNN@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:38:14 +0300 Subject: [PATCH] fix: resolve GitHub Copilot 400 error for Claude models in Cursor IDE (#220) - github.js: add sanitizeMessages() to convert unsupported content types (tool_use, tool_result, thinking) to plain text before sending to GitHub /chat/completions endpoint - openai-responses.js: skip pushing message blocks with empty content (e.g. assistant messages that only contain tool_calls) - providerModels.js: revert targetFormat changes (not needed with sanitize fix) Fixes: https://github.com/decolua/9router/issues/219 --- open-sse/executors/github.js | 46 ++++++++++++++++++- .../translator/request/openai-responses.js | 21 ++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index 5beb391..fdb94cb 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -34,15 +34,59 @@ export class GithubExecutor extends BaseExecutor { }; } + // Sanitize messages for GitHub Copilot /chat/completions endpoint. + // The endpoint only accepts 'text' and 'image_url' content part types. + // Tool-related content (tool_use, tool_result, thinking) must be serialized as text. + sanitizeMessagesForChatCompletions(body) { + if (!body?.messages) return body; + + const sanitized = { ...body }; + sanitized.messages = body.messages.map(msg => { + // assistant messages with only tool_calls have content: null — leave as-is + if (!msg.content) return msg; + + // String content is always fine + if (typeof msg.content === "string") return msg; + + // Array content: filter/convert unsupported part types + if (Array.isArray(msg.content)) { + const cleanContent = msg.content + .map(part => { + if (part.type === "text") return part; + if (part.type === "image_url") return part; + // Serialize tool_use, tool_result, thinking, etc. as text + const text = part.text || part.content || JSON.stringify(part); + return { type: "text", text: typeof text === "string" ? text : JSON.stringify(text) }; + }) + .filter(part => part.text !== ""); // remove empty text parts + + // If all content was stripped (e.g. only tool_result with no text), drop content + return { ...msg, content: cleanContent.length > 0 ? cleanContent : null }; + } + + return msg; + }); + + return sanitized; + } + async execute(options) { const { model, log } = options; + // Only use /responses for models that are explicitly known to need it (e.g. gpt codex models) if (this.knownCodexModels.has(model)) { log?.debug("GITHUB", `Using cached /responses route for ${model}`); return this.executeWithResponsesEndpoint(options); } - const result = await super.execute(options); + // Sanitize messages before sending to /chat/completions + // This handles Claude models on GitHub Copilot which reject non-text/image_url content types + const sanitizedOptions = { + ...options, + body: this.sanitizeMessagesForChatCompletions(options.body) + }; + + const result = await super.execute(sanitizedOptions); if (result.response.status === HTTP_STATUS.BAD_REQUEST) { const errorBody = await result.response.clone().text(); diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index a47d27f..ba0ef7b 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -175,16 +175,23 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) : Array.isArray(msg.content) ? msg.content.map(c => { if (c.type === "text") return { type: contentType, text: c.text }; - if (c.type === "image_url") return { type: contentType, text: "[Image content]" }; - return c; + if (c.type === "image_url") return { type: "image_url", image_url: c.image_url }; + // Serialize any unknown type (tool_use, tool_result, thinking, etc.) as text + const text = c.text || c.content || JSON.stringify(c); + return { type: contentType, text: typeof text === "string" ? text : JSON.stringify(text) }; }) : []; - result.input.push({ - type: "message", - role: msg.role, - content - }); + // Only push a message block if content is non-empty. + // Assistant messages with only tool_calls have content: null — skip the + // message block in that case; the tool_calls are pushed separately below. + if (content.length > 0) { + result.input.push({ + type: "message", + role: msg.role, + content + }); + } } // Convert tool calls