fix(executors): sanitize Anthropic-shape content for Copilot /chat/completions (#4452)

Integrated into release/v3.8.32
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 20:26:08 -03:00
committed by GitHub
parent 9708feddc9
commit ce3909be86
3 changed files with 139 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(translator):** strip Claude output_config before MiniMax (rejected upstream). (thanks @hiepau1231)
- **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari)
- **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5)
- **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN)
---

View File

@@ -99,9 +99,51 @@ export class GithubExecutor extends BaseExecutor {
modifiedBody.tools = modifiedBody.tools.slice(0, 128);
}
// GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'}
// content parts. Clients like Cursor IDE pass through Anthropic-shape parts
// (tool_use, tool_result, thinking) untouched when using Claude models, which makes
// the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400).
// Serialize unknown part types as text, drop empty parts, and collapse to null when
// every part is stripped (assistant messages whose only content was tool_calls).
// Port from 9router#220 (fixes 9router#219).
if (Array.isArray(modifiedBody.messages)) {
modifiedBody.messages = modifiedBody.messages.map((msg: any) =>
this.sanitizeChatCompletionsMessage(msg)
);
}
return modifiedBody;
}
private sanitizeChatCompletionsMessage(msg: any): any {
if (!msg || typeof msg !== "object") return msg;
// String content and missing content (e.g. assistant w/ only tool_calls) pass through.
if (typeof msg.content === "string" || msg.content == null) return msg;
if (!Array.isArray(msg.content)) return msg;
const cleanContent = msg.content
.map((part: any) => {
if (!part || typeof part !== "object") return part;
if (part.type === "text") return part;
if (part.type === "image_url") return part;
// Serialize any unsupported part (tool_use, tool_result, thinking, etc.) as text.
// Try common text-carrying fields first; fall back to a JSON dump so nothing is
// silently dropped from the model's context.
const raw =
(typeof part.text === "string" && part.text) ||
(typeof part.thinking === "string" && part.thinking) ||
(typeof part.content === "string" && part.content) ||
(part.content != null && JSON.stringify(part.content)) ||
JSON.stringify(part);
return { type: "text", text: typeof raw === "string" ? raw : JSON.stringify(raw) };
})
.filter((part: any) => !(part && part.type === "text" && part.text === ""));
// If every part stripped to empty (e.g. tool_use with no text), collapse to null so
// GitHub does not reject an empty-array body. tool_calls ride alongside content.
return { ...msg, content: cleanContent.length > 0 ? cleanContent : null };
}
async execute(input: ExecuteInput) {
const result = await super.execute(input);
if (!result || !result.response) return result;

View File

@@ -97,6 +97,102 @@ test("GithubExecutor.transformRequest injects JSON response instructions for Cla
assert.equal(result.messages[2].reasoning_content, undefined);
});
test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (tool_use, tool_result, thinking) for /chat/completions (port from 9router#220)", () => {
// GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} content
// parts. Clients like Cursor IDE pass through Anthropic-shape parts (tool_use, tool_result,
// thinking) untouched when using Claude models, which makes the endpoint return:
// "type has to be either 'image_url' or 'text'" (HTTP 400)
// Port: serialize unknown part types as text, drop empty content, and skip assistant
// messages whose only content was tool_calls (content collapses to null).
const executor = new GithubExecutor();
const body = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "Search for X" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "let me search" },
{ type: "tool_use", id: "call_1", name: "search", input: { q: "X" } },
],
},
{
role: "tool",
tool_call_id: "call_1",
content: [{ type: "tool_result", tool_use_id: "call_1", content: "result" }],
},
],
};
const result = executor.transformRequest("claude-sonnet-4.6", body, true, {});
// user message keeps text + image_url parts untouched
assert.equal(result.messages[0].content[0].type, "text");
assert.equal(result.messages[0].content[0].text, "Search for X");
assert.equal(result.messages[0].content[1].type, "image_url");
assert.equal(result.messages[0].content[1].image_url?.url, "data:image/png;base64,AAAA");
// assistant: thinking + tool_use serialized to text type — no unknown type leaks to wire
for (const part of result.messages[1].content) {
assert.ok(part.type === "text" || part.type === "image_url", `unsupported type leaked: ${part.type}`);
}
assert.ok(result.messages[1].content.some((p: any) => /let me search/.test(p.text)));
assert.ok(result.messages[1].content.some((p: any) => /search/.test(p.text) && /"q":"X"/.test(p.text)));
// tool message: tool_result serialized to text — no unknown type leaks
for (const part of result.messages[2].content) {
assert.ok(part.type === "text" || part.type === "image_url", `unsupported type leaked: ${part.type}`);
}
});
test("GithubExecutor.transformRequest collapses assistant content to null when every part stripped to empty", () => {
// assistant messages whose only content was tool_use (no text) should not ship empty
// strings to /chat/completions — GitHub rejects "" parts. Mirror upstream by dropping
// empty parts and falling back to null when nothing meaningful remains.
const executor = new GithubExecutor();
const body = {
messages: [
{
role: "assistant",
content: [{ type: "tool_use", id: "call_x", name: "noop", input: {} }],
tool_calls: [{ id: "call_x", type: "function", function: { name: "noop", arguments: "{}" } }],
},
],
};
const result = executor.transformRequest("claude-sonnet-4.6", body, true, {});
// Either null or an array of {text:non-empty} — never an empty-text part.
const c = result.messages[0].content;
if (Array.isArray(c)) {
for (const part of c) {
assert.notEqual(part.text, "", "empty text part leaked to wire");
}
} else {
assert.equal(c, null);
}
// tool_calls must survive — they ride alongside content
assert.equal(result.messages[0].tool_calls[0].id, "call_x");
});
test("GithubExecutor.transformRequest leaves string content and missing content untouched", () => {
const executor = new GithubExecutor();
const body = {
messages: [
{ role: "user", content: "plain string" },
{ role: "assistant", tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }] },
],
};
const result = executor.transformRequest("claude-sonnet-4.6", body, true, {});
assert.equal(result.messages[0].content, "plain string");
assert.equal(result.messages[1].content, undefined);
assert.equal(result.messages[1].tool_calls[0].id, "c1");
});
test("GithubExecutor.buildHeaders prefers Copilot token and sets GitHub-specific headers", () => {
const executor = new GithubExecutor();
const headers = executor.buildHeaders(