From 214e944b8d52604712d5bef186765a282acd4d8a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 20 Jun 2026 19:16:15 -0300 Subject: [PATCH] fix(executors): granular reasoning_effort handling for Claude models on Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniRoute's GitHub Copilot sanitizer stripped reasoning_effort for every Claude model (regex /(claude|haiku|oswe)/i), so Claude Opus 4.6 and Sonnet 4.6 via Copilot never received extended-thinking configuration even though their backend honors it (3× token increase between low/medium/high, verified upstream). Add a granular opt-in for Copilot's Claude routing: - Pass through reasoning_effort for Claude Opus 4.6 / Sonnet 4.6 - Still strip for Haiku 4.5 / Opus 4.7 (rejected upstream), older Sonnet/Opus variants, and the oswe-* family Order matters: the opt-in match runs BEFORE the broad strip pattern. Notes: - OmniRoute's openai→claude translator already maps reasoning_effort → thinking.budget_tokens far more richly than upstream's tiny mapping table (handles max, xhigh, adaptive models, fits to max_tokens via fitThinkingToMaxTokens), so only the github-executor half of upstream PR #791 is ported here. - "none" is intentionally NOT stripped universally: GPT-5.x treats reasoning_effort=none as a real value (the non-reasoning mode that unlocks sampling params, see gpt5SamplingGuard.ts) — stripping it would break GPT-5 callers. Co-authored-by: Manuel Inspired-by: https://github.com/decolua/9router/pull/791 --- CHANGELOG.md | 1 + open-sse/executors/base.ts | 16 ++- .../base-executor-sanitize-effort.test.ts | 35 +++++- ...b-claude-reasoning-effort-granular.test.ts | 116 ++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/unit/github-claude-reasoning-effort-granular.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae256d01cb..24180fbd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2) - **fix(usage):** reuse Gemini CLI project ID for quota checks (avoid re-discovery). (thanks @Delcado19) - **fix(dashboard):** surface manual config CTA when Claude CLI detection fails (remote deployments). (thanks @anuragg-saxenaa) +- **fix(executors):** granular reasoning_effort handling for Claude models on GitHub Copilot. (thanks @baslr) - **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) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 475e948166..fea719bd92 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -242,6 +242,18 @@ function hasActiveClaudeThinking(body: Record): boolean { * xhigh by default and falls back to high only for explicit xhigh opt-outs. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; +// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791): +// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to +// Anthropic's chat/completions surface, which honors reasoning_effort and +// emits visible reasoning tokens (verified upstream: 3× token increase +// between low/medium/high). +// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by +// Copilot's Claude backend), older Claude variants, all `haiku`-named +// models, and the `oswe-*` family (Raptor) which still rejects +// reasoning_effort. +// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip. +const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = + /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; function supportsMaxEffortForProvider(provider: string, model: string): boolean { @@ -269,9 +281,11 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; const modelStr = model || ""; + const githubOptIn = + provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr); const rejecting = (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || - (provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); + (provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); if (rejecting) { log?.info?.( "REASONING_SANITIZE", diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index bcdbacff60..369fe275c2 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -250,13 +250,25 @@ test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning_effo ); }); -test("sanitizeReasoningEffortForProvider: github/claude-opus strips reasoning_effort entirely", () => { +test("sanitizeReasoningEffortForProvider: github/claude-opus-4.6 preserves reasoning_effort (#791)", () => { + // Upstream PR decolua/9router#791 (port): Copilot now honors reasoning_effort + // on Claude Opus 4.6 and Sonnet 4.6. Older Opus variants and Haiku still strip. const body = { model: "claude-opus-4-6", reasoning_effort: "high", messages: [], }; const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4-6", null); + assert.equal((result as any).reasoning_effort, "high"); +}); + +test("sanitizeReasoningEffortForProvider: github/claude-opus-4.7 still strips (#791)", () => { + const body = { + model: "claude-opus-4.7", + reasoning_effort: "high", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4.7", null); assert.equal((result as any).reasoning_effort, undefined); }); @@ -274,6 +286,10 @@ test("sanitizeReasoningEffortForProvider: rejecting providers strip max before n ); assert.equal((mistralResult as any).reasoning_effort, undefined); + // Pre-#791: github stripped reasoning_effort entirely for every Claude model. + // Post-#791: Opus 4.6 keeps reasoning_effort; `max` downgrades to `high` + // because github is not Claude/CC-compatible (so supportsMax=false) and + // the canonical Claude Opus 4.6 model opts out of xhigh. const githubBody = { model: "claude-opus-4-6", reasoning_effort: "max", @@ -285,7 +301,22 @@ test("sanitizeReasoningEffortForProvider: rejecting providers strip max before n "claude-opus-4-6", null ); - assert.equal((githubResult as any).reasoning_effort, undefined); + assert.equal((githubResult as any).reasoning_effort, "high"); + + // Pre-#791 strip is preserved for github Claude models that DO NOT opt in + // (Haiku 4.5, Opus 4.7, older Sonnet, etc.). + const githubHaiku = { + model: "claude-haiku-4.5", + reasoning_effort: "max", + messages: [], + }; + const githubHaikuResult = sanitizeReasoningEffortForProvider( + githubHaiku, + "github", + "claude-haiku-4.5", + null + ); + assert.equal((githubHaikuResult as any).reasoning_effort, undefined); }); test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning object when only effort present", () => { diff --git a/tests/unit/github-claude-reasoning-effort-granular.test.ts b/tests/unit/github-claude-reasoning-effort-granular.test.ts new file mode 100644 index 0000000000..6646c71709 --- /dev/null +++ b/tests/unit/github-claude-reasoning-effort-granular.test.ts @@ -0,0 +1,116 @@ +// Regression tests for granular reasoning_effort handling on GitHub Copilot +// Claude models (upstream port: decolua/9router#791 by @baslr). +// +// Pre-port behaviour: the github branch of sanitizeReasoningEffortForProvider +// stripped reasoning_effort for ANY model whose name matched /(claude|haiku|oswe)/i, +// so Claude Opus 4.6 and Claude Sonnet 4.6 via GitHub Copilot never received +// extended-thinking configuration even though both backends support it. +// +// Post-port behaviour: reasoning_effort is preserved on Claude Opus 4.6 and +// Claude Sonnet 4.6 (Copilot routes both to Anthropic's chat/completions +// surface where reasoning_effort is honored), and continues to be stripped on +// Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream). +// +// Note: OmniRoute's openai→claude translator already maps reasoning_effort → +// thinking.budget_tokens far more richly than upstream's tiny effortToBudget +// table (handles `max`, `xhigh`, adaptive models, and fits to max_tokens), so +// only the github-executor half of upstream PR #791 needs porting. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts"); + +test("github/claude-opus-4.6: preserves reasoning_effort (#791)", () => { + const body = { + model: "claude-opus-4.6", + reasoning_effort: "high", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4.6", null); + assert.equal((result as any).reasoning_effort, "high", "Opus 4.6 must keep reasoning_effort"); +}); + +test("github/claude-sonnet-4.6: preserves reasoning_effort (#791)", () => { + const body = { + model: "claude-sonnet-4.6", + reasoning_effort: "medium", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-sonnet-4.6", null); + assert.equal((result as any).reasoning_effort, "medium", "Sonnet 4.6 must keep reasoning_effort"); +}); + +test("github/claude-haiku-4.5: still strips reasoning_effort (#791)", () => { + const body = { + model: "claude-haiku-4.5", + reasoning_effort: "high", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-haiku-4.5", null); + assert.equal( + (result as any).reasoning_effort, + undefined, + "Haiku 4.5 rejects reasoning_effort upstream — must strip" + ); +}); + +test("github/claude-opus-4.7: still strips reasoning_effort (#791)", () => { + const body = { + model: "claude-opus-4.7", + reasoning_effort: "high", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4.7", null); + assert.equal( + (result as any).reasoning_effort, + undefined, + "Opus 4.7 rejects reasoning_effort upstream — must strip" + ); +}); + +test("github/claude-opus-4.6: preserves nested reasoning.effort (#791)", () => { + const body = { + model: "claude-opus-4.6", + reasoning: { effort: "high", summary: "auto" }, + input: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4.6", null); + assert.equal((result as any).reasoning.effort, "high"); + assert.equal((result as any).reasoning.summary, "auto", "other reasoning fields preserved"); +}); + +test("github/claude-sonnet-4.5: still strips reasoning_effort (older Sonnet)", () => { + // Upstream PR #791 explicitly opts in only Opus 4.6 and Sonnet 4.6. Older + // Sonnet variants (4.5) keep the historical strip — Copilot has not made + // reasoning_effort available for them. + const body = { + model: "claude-sonnet-4.5", + reasoning_effort: "high", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-sonnet-4.5", null); + assert.equal((result as any).reasoning_effort, undefined); +}); + +test("github/oswe-vscode-prime: still strips reasoning_effort", () => { + // Regression guard: the oswe branch of the rejection pattern must remain. + const body = { + model: "oswe-vscode-prime", + reasoning_effort: "high", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "oswe-vscode-prime", null); + assert.equal((result as any).reasoning_effort, undefined); +}); + +test("github/gpt-5.4: pass-through (non-Claude unchanged)", () => { + // Regression guard: non-Claude github models keep reasoning_effort. + const body = { + model: "gpt-5.4", + reasoning_effort: "high", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "gpt-5.4", null); + assert.equal((result as any).reasoning_effort, "high"); +});