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:
@@ -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) {
|
async execute(options) {
|
||||||
const { model, log } = 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)) {
|
if (this.knownCodexModels.has(model)) {
|
||||||
log?.debug("GITHUB", `Using cached /responses route for ${model}`);
|
log?.debug("GITHUB", `Using cached /responses route for ${model}`);
|
||||||
return this.executeWithResponsesEndpoint(options);
|
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) {
|
if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
|
||||||
const errorBody = await result.response.clone().text();
|
const errorBody = await result.response.clone().text();
|
||||||
|
|||||||
@@ -175,16 +175,23 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
|||||||
: Array.isArray(msg.content)
|
: Array.isArray(msg.content)
|
||||||
? msg.content.map(c => {
|
? msg.content.map(c => {
|
||||||
if (c.type === "text") return { type: contentType, text: c.text };
|
if (c.type === "text") return { type: contentType, text: c.text };
|
||||||
if (c.type === "image_url") return { type: contentType, text: "[Image content]" };
|
if (c.type === "image_url") return { type: "image_url", image_url: c.image_url };
|
||||||
return c;
|
// 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({
|
// Only push a message block if content is non-empty.
|
||||||
type: "message",
|
// Assistant messages with only tool_calls have content: null — skip the
|
||||||
role: msg.role,
|
// message block in that case; the tool_calls are pushed separately below.
|
||||||
content
|
if (content.length > 0) {
|
||||||
});
|
result.input.push({
|
||||||
|
type: "message",
|
||||||
|
role: msg.role,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tool calls
|
// Convert tool calls
|
||||||
|
|||||||
Reference in New Issue
Block a user