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
This commit is contained in:
Cengizhan
2026-03-01 11:38:14 +03:00
committed by GitHub
parent a7365c5a4e
commit f763d4ffed
2 changed files with 59 additions and 8 deletions

View File

@@ -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();

View File

@@ -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