Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ _In development — bullets added per PR; finalized at release._
- **fix(capabilities): resolve models.dev-synced vision metadata for Mistral `-latest` aliases** — root cause behind the #4071 heuristic: `getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision` resolved `null` (vision came only from the #4071 model-id heuristic, with `attachment` still `null`) even though models.dev exposes the model as multimodal. Confirmed against the live models.dev API: it catalogs Pixtral 12B under the **short** id `pixtral-12b` (with `attachment: true`, `modalities.input: ["text","image"]`), while requests use the Mistral API alias `pixtral-12b-latest`. The synced lookup tried the exact / raw / static-spec-canonical ids — all of which miss the short form — so it fell through to the heuristic. `getSyncedCapabilityForResolved` now adds a last-resort fallback that retries with a trailing `-latest` stripped, so synced metadata (`attachment` / image modalities) wins for these aliases; models whose `-latest` id is stored verbatim (e.g. `pixtral-large-latest`) keep resolving directly. Note: the models.dev sync is currently manual-only (Settings → models.dev) with no scheduled refresh, so a fresh instance still relies on the #4071 heuristic until that sync runs — a periodic-refresh cadence is left as a separate follow-up. ([#4073](https://github.com/diegosouzapw/OmniRoute/issues/4073) — thanks @diego-anselmo)
- **fix(sse): map Xiaomi MiMo reasoning control to its native `thinking:{type}` shape** — MiMo (`api.xiaomimimo.com`) controls chain-of-thought **only** via top-level `thinking:{type:"enabled"|"disabled"}` and does not understand OpenAI's `reasoning_effort`/`reasoning`, while its request validator is strict (`400 Param Incorrect`). OmniRoute's OpenAI path carried reasoning intent as `reasoning_effort`, and the claude→openai translator can leave a Claude-shaped `thinking:{type, budget_tokens}` — so the client's on/off choice was silently dropped and `budget_tokens`/`reasoning_effort` rode along as extra params the validator can reject. New `open-sse/services/mimoThinking.ts::normalizeMimoThinking` (wired in `chatCore` for `provider==="xiaomi-mimo"`) reduces any thinking object to just `{type}` (`disabled` stays; `enabled`/`adaptive`/other → `enabled`) and drops `reasoning_effort`/`reasoning`. It deliberately does **not** synthesize thinking from a bare `reasoning_effort` — `mimo-v2-omni` is non-thinking, so that could turn a silently-ignored param into a hard error. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224))
- **fix(capabilities): Xiaomi MiMo `*-pro` chat models are text-only (no vision)** — only `mimo-v2.5` and `mimo-v2-omni` accept images per Xiaomi's docs; `mimo-v2.5-pro`/`mimo-v2-pro` are text-only, but `modelSpecs` marked them vision-capable and models.dev mislabels them ([hermes-agent#18884](https://github.com/NousResearch/hermes-agent/issues/18884)). Since `resolveVisionCapability` lets a synced `attachment:true` win first, an image request could be routed to a blind model (the #4071 failure mode). Corrected the specs **and** added a hard override in `resolveVisionCapability` (checked before the synced branch, anchored so `mimo-v2.5-pro` never matches the multimodal `mimo-v2.5`) that beats the wrong synced attachment. Also registered the missing native `mimo-v2-pro` chat model and the missing `mimo-v2-tts` speech model. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224))
- **fix(sse): Claude Opus 4.7+/Fable 5 use adaptive thinking only (no more manual-budget 400s)** — Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: `thinking.type:"enabled"` or **any** `thinking.budget_tokens` now returns `400` ("Any request that tries to set a fixed thinking budget gets a 400" — Anthropic migration guide). Reasoning is adaptive-only, steered by `output_config.effort`. OmniRoute's OpenAI→Claude translator mapped `reasoning_effort` low/medium/high to a manual `thinking:{type:"enabled", budget_tokens}`, so those requests hard-400'd on the most-used provider (and a Claude-native passthrough client sending the legacy shape did too). A new `adaptiveThinkingOnly` model flag now drives two fixes: the translator maps `reasoning_effort` of **every** level to `{type:"adaptive"}` + `output_config.effort` (preserving the requested level, never a budget) for these models, and a `normalizeClaudeAdaptiveThinking` catch-all at the existing post-translation thinking-normalization chokepoint collapses any residual manual thinking (passthrough legacy shape, per-model defaults) to `{type:"adaptive"}`, keyed on the resolved upstream model so it covers every routing mode. Pre-4.7 models (Opus 4.6/4.5, Sonnet, Haiku) keep manual budgets unchanged. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230))
- **fix(providers): strip non-default temperature/top_p/top_k for Claude Opus 4.7+/Fable 5 (fixed sampling → no 400)** — Opus 4.7 and later reject non-default `temperature`/`top_p`/`top_k` with a `400` (sampling is fixed; reasoning moved to `output_config.effort`). The translator forwarded client-supplied `temperature`/`top_p` unconditionally and the Claude registry models carried no `unsupportedParams`, so a plain OpenAI-format request with `temperature: 0.7` to `claude-opus-4-8` hard-400'd. Added `unsupportedParams: ["temperature","top_p","top_k"]` to the Opus 4.7+/Fable 5 ids in both the `claude` (dashed `claude-opus-4-8`) and `anthropic` (dotted `claude-opus-4.7`) registries, so they're stripped at the existing `getUnsupportedParams` dispatch chokepoint. Pre-4.7 Claude models still accept sampling params. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230))

### 🧪 Tests

Expand Down
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_2026_06_18_claude_adaptive_thinking": "PR (Claude Opus 4.7+ adaptive-thinking flow) own growth: chatCore.ts 5095->5102 (+7 = wire normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream) at the existing post-translation thinking-normalization chokepoint, right after normalizeThinkingForModel — its import line + a 5-line explanatory comment + the call). Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:\"enabled\"` or any `thinking.budget_tokens` is a hard 400, so this collapses any manual thinking that reached dispatch (passthrough legacy shape, reasoning_effort buckets, per-model defaults) to `{type:\"adaptive\"}`, keyed on the resolved upstream model. The reusable guard lives in the new pure leaf open-sse/services/claudeAdaptiveThinking.ts (<cap), mirroring normalizeMimoThinking (#4224). Cohesive at the existing thinking-normalization chokepoint, next to normalizeThinkingForModel/normalizeMimoThinking; not extractable without hiding the normalization boundary. Structural shrink of chatCore.ts tracked in #3501.",
"_rebaseline_2026_06_18_4221_tool_cardinality": "PR #4221 own growth: server.ts 1458->1468 (+10 at the existing registerTool override in createMcpServer = F4.3 opt-in tool-cardinality wiring). Reads readMcpToolProfileFromEnv(process.env) once (MCP_TOOL_DENY/MCP_TOOL_ALLOW; null = no filter, the default), and when a registered tool is denied by the profile calls registered.disable() so it is not announced in tools/list (token savings). The default (null) profile never enters the branch — existing behavior byte-identical. The reusable parser + reduceToolManifest decision live in the (non-frozen) toolCardinality.ts. Cohesive opt-in feature at the registration chokepoint; not extractable without hiding the register boundary.",
"_rebaseline_2026_06_18_4217_compression_step_streaming": "PR #4217 own growth: chatCore.ts 5063->5086 (+23 at the existing compression-apply chokepoint = the best-effort onEngineStep callback threaded into applyCompressionAsync). The callback builds a compression.step payload and fires emit(\"compression.step\", …) + forwardDashboardEventToLiveWs(…) once per stacked engine as it completes (F3.3 live per-engine streaming), wrapped in try/catch so it never fails the request. It closes over the same emit/traceId/mode locals as the compression.completed emit right below it (line 1749); the reusable per-engine emission lives in strategySelector.ts (reportEngineStep + StackedCompressionStep) and the studio reducers in compressionFlowModel.ts (both <cap). Not extractable without hiding the emit boundary, mirroring the prior compression rebaselines (#4210/#4004). Structural shrink of chatCore.ts tracked in #3501.",
"_rebaseline_2026_06_18_4210_engine_breakdown": "PR #4210 own growth: chatCore.ts 5060->5063 (+3 = wire ensureEngineBreakdown(result.stats) into the existing compression.completed emit + its import line). Single-engine modes (rtk/lite/standard/aggressive/ultra) leave stats.engineBreakdown empty, which made the dashboard studio render an empty Input->Output pipeline (no engine node); the synthesized 1-entry breakdown lives in the new pure leaf open-sse/services/compression/engineBreakdown.ts (<cap), mirroring seedLatestCompressionRunFromDb. The +3 is the import + a 2-line explanatory comment at the emit chokepoint; not extractable further without hiding the emit boundary.",
Expand Down Expand Up @@ -70,7 +71,7 @@
"open-sse/executors/muse-spark-web.ts": 1284,
"open-sse/executors/perplexity-web.ts": 1013,
"open-sse/handlers/audioSpeech.ts": 965,
"open-sse/handlers/chatCore.ts": 5095,
"open-sse/handlers/chatCore.ts": 5102,
"open-sse/handlers/imageGeneration.ts": 3777,
"open-sse/handlers/responseSanitizer.ts": 1103,
"open-sse/handlers/search.ts": 1546,
Expand Down
8 changes: 7 additions & 1 deletion open-sse/config/providers/registry/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export const anthropicProvider: RegistryEntry = {
"Anthropic-Beta": ANTHROPIC_BETA_API_KEY,
},
models: [
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
{
id: "claude-opus-4.7",
name: "Claude Opus 4.7",
// Opus 4.7+ rejects non-default temperature/top_p/top_k with a 400 (sampling fixed;
// reasoning via output_config.effort). Mirrors the dashed `claude` registry ids.
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.6" },
Expand Down
5 changes: 5 additions & 0 deletions open-sse/config/providers/registry/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,23 @@ export const claudeProvider: RegistryEntry = {
name: "Claude Fable 5",
contextLength: 1000000,
maxOutputTokens: 128000,
// Opus 4.7+/Fable 5 reject non-default temperature/top_p/top_k with a 400 (sampling
// is fixed; reasoning is steered by output_config.effort). Strip them before dispatch.
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
contextLength: 1000000,
maxOutputTokens: 128000,
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
contextLength: 1000000,
maxOutputTokens: 128000,
unsupportedParams: ["temperature", "top_p", "top_k"],
},
{
id: "claude-opus-4-6",
Expand Down
7 changes: 7 additions & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
} from "../services/modelStrip.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { normalizeMimoThinking } from "../services/mimoThinking.ts";
import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { supportsMaxTokens } from "@/lib/modelCapabilities.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
Expand Down Expand Up @@ -2535,6 +2536,12 @@ export async function handleChatCore({
// when the resolved target model rejects it; models that accept `disabled` are untouched.
if (typeof finalModelToUpstream === "string") {
translatedBody = normalizeThinkingForModel(translatedBody, finalModelToUpstream);
// Claude Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:"enabled"`
// or any `thinking.budget_tokens` is a hard 400. Collapse any manual thinking that
// reached this point (passthrough legacy shape, reasoning_effort buckets, per-model
// defaults) to `{type:"adaptive"}` — effort stays on `output_config.effort`. Keyed on
// the resolved upstream model, so it covers every routing mode. See claudeAdaptiveThinking.ts.
translatedBody = normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream);
}

// Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and
Expand Down
52 changes: 52 additions & 0 deletions open-sse/services/claudeAdaptiveThinking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isAdaptiveThinkingOnly } from "@/shared/constants/modelSpecs.ts";

type JsonRecord = Record<string, unknown>;

function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}

/**
* Collapse manual extended thinking to adaptive for Claude models that no longer accept it.
*
* Claude Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: the
* Messages API returns HTTP 400 for `thinking.type:"enabled"` and for ANY
* `thinking.budget_tokens`. Reasoning is steered exclusively by `output_config.effort`
* (Anthropic migration guide, 2026-05-19). OmniRoute can still produce a manual thinking
* block on these models from several paths — a Claude-native passthrough client sending the
* legacy shape, the OpenAI→Claude translator's reasoning_effort buckets, or a per-model
* thinking default — so this is the final, provider-agnostic guard keyed on the target model.
*
* Returns a NEW object only when it changes the body:
* - `thinking.type:"enabled"` → `"adaptive"` (the only supported mode);
* - `thinking.budget_tokens` / `thinking.max_tokens` → dropped (rejected extras).
* `thinking.type:"adaptive"` is left as-is (just stripped of any stray budget), and
* `thinking.type:"disabled"` is left untouched — that's handled separately by
* `normalizeThinkingForModel` for the models that reject `disabled` (#3554).
*
* No-op (returns the same reference) when the model is not adaptive-only, when there is no
* thinking object, or when the thinking object already carries no manual-budget signal —
* so adaptive defaults and effort hints reach the model unchanged.
*/
export function normalizeClaudeAdaptiveThinking<T extends Record<string, unknown>>(
body: T,
model: string | null | undefined
): T {
if (!isAdaptiveThinkingOnly(model)) return body;
const record = asRecord(body);
if (!record) return body;

const thinking = asRecord(record.thinking);
if (!thinking) return body;

const isManualType = thinking.type === "enabled";
const hasBudget = thinking.budget_tokens !== undefined || thinking.max_tokens !== undefined;
if (!isManualType && !hasBudget) return body;

const nextThinking: JsonRecord = { ...thinking };
if (nextThinking.type === "enabled") nextThinking.type = "adaptive";
delete nextThinking.budget_tokens;
delete nextThinking.max_tokens;

return { ...record, thinking: nextThinking } as T;
}
22 changes: 21 additions & 1 deletion open-sse/translator/request/openai-to-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts";
import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts";

// Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer
// adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget.
const ADAPTIVE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);

// Prefix for Claude OAuth tool names to avoid conflicts
// Can be disabled per-request via body._disableToolPrefix = true
Expand Down Expand Up @@ -458,7 +463,22 @@ export function openaiToClaudeRequest(model, body, stream) {
: requestedEffort === "xhigh" && !supportsXHighEffort("claude", model)
? "high"
: requestedEffort;
if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
if (isAdaptiveThinkingOnly(model)) {
// Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens`
// (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive +
// output_config.effort instead of the budget buckets below. Unrecognized levels
// leave thinking unset so the model keeps its adaptive default rather than 400ing
// on an invalid effort value.
if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) {
result.thinking = {
type: "adaptive",
};
result.output_config = {
...(result.output_config || {}),
effort: normalizedEffort,
};
}
} else if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
result.thinking = {
type: "adaptive",
};
Expand Down
Loading
Loading