diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed8ef1584..84e34931b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) --- diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 98a965f17e..6fcdd2e18c 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -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; diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index 95cf3bcc2f..dd2c1a4b3c 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -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(