From bc5fceb88a5bc81800e9cfa79199380df0ee6e25 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 14 Jun 2026 04:51:48 +0000 Subject: [PATCH] fix(streaming): provider-gate title-gen reasoning extra_body (#4161, #2083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates #4162 + #3944. Aux path: skip the reasoning-disable inject for routes that 400 on it via _route_rejects_reasoning_extra — hostname-matched OpenAI / Azure OpenAI / Azure AI Foundry (+ provider aliases openai*/azure*/ azure-foundry/azure-ai*) and OpenRouter Anthropic mandatory-reasoning; keep it for local/OpenRouter-non-anthropic/other reasoning routes (#4161). Agent path: rely on the existing agent.reasoning_config={enabled:False} + _build_api_kwargs() (route-correct per provider profile) rather than re-injecting a generic disable on top of it; only MiniMax reasoning_split is added (unchanged from master). Co-authored-by: Rod Boev --- CHANGELOG.md | 6 ++ api/streaming.py | 66 ++++++++++++++++++-- tests/test_title_gen_reasoning_extra_gate.py | 56 +++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 tests/test_title_gen_reasoning_extra_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92da8751..a13d66fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.409] — 2026-06-14 — Release NV (provider-gate title-gen reasoning extra_body, #4161/#2083) + +### Fixed + +- **Auto-title generation no longer sends an unsupported `reasoning` parameter to providers that reject it, restoring LLM-quality titles (#4161).** To suppress thinking on reasoning models (#2083), title generation injects `extra_body={"reasoning": {"enabled": false}}` — but OpenAI Chat Completions and Azure OpenAI reject unknown top-level params with a 400, so every new session silently fell back to a low-quality heuristic title for users on those providers. The reasoning-disable is now gated: the auxiliary title path skips it for reject-listed routes (OpenAI / Azure) while keeping it for local endpoints, OpenRouter, and other reasoning-aware providers; and the agent title path gates it behind the agent's canonical `_supports_reasoning_extra_body()` route check, which also excludes OpenRouter Anthropic mandatory-reasoning models (Claude Sonnet 4.6 / Opus 4.8) that are reasoning-capable but reject a disable. MiniMax keeps its existing `reasoning_split` handling. (#4161, #2083) + ## [v0.51.408] — 2026-06-14 — Release NU (reasoning selector for nested Gemini custom-provider routes, #3431) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index 8bdef61e..43d43883 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2413,6 +2413,48 @@ def _is_minimax_route(provider: str = '', model: str = '', base_url: str = '') - return 'minimax' in text or 'minimaxi.com' in text +def _route_rejects_reasoning_extra(provider: str = '', model: str = '', base_url: str = '') -> bool: + """Routes known to reject an ``extra_body`` ``reasoning`` parameter with HTTP 400. + + Title generation injects ``extra_body={"reasoning": {"enabled": False}}`` to + suppress thinking on reasoning-capable models (#2083). But OpenAI Chat + Completions (and Azure OpenAI) reject unknown top-level params with a 400, so + that inject silently fails the title call and falls back to a low-quality + heuristic title (#4161). Skip the inject for those routes. + + OpenRouter Anthropic mandatory-reasoning models (Claude Sonnet 4.6 / Opus 4.8) + are reasoning-capable but reject a reasoning *disable* — title gen only needs + reasoning off, so skip the inject for them too rather than risk the same 400. + """ + provider_lower = str(provider or '').strip().lower() + model_lower = str(model or '').strip().lower() + # Hostname-based match (not substring) so a proxy URL that merely *contains* + # one of these strings in a path segment isn't mis-classified. + host = '' + try: + from urllib.parse import urlsplit + host = (urlsplit(str(base_url or '').strip()).hostname or '').lower() + except Exception: + host = '' + if host == 'api.openai.com' or host.endswith('.openai.azure.com'): + return True + # Azure AI Foundry chat-completions hosts (also reject the reasoning param). + if host.endswith('.services.ai.azure.com') or host.endswith('.cognitiveservices.azure.com'): + return True + if provider_lower in ('openai', 'openai-api', 'openai-codex'): + return True + if ( + provider_lower in ('azure', 'azure-foundry', 'azure-ai-foundry', 'azure-ai') + or provider_lower.startswith('azure/') + or provider_lower.startswith('azure-') + ): + return True + if (host == 'openrouter.ai' or host.endswith('.openrouter.ai')) and model_lower.startswith('anthropic/'): + # Anthropic on OpenRouter: mandatory-reasoning families reject a disable. + return True + return False + + def _get_aux_title_config() -> dict: """Return title_generation auxiliary config, or an empty dict on errors.""" try: @@ -2594,7 +2636,9 @@ def generate_title_raw_via_aux( if not caller_supplied_route: api_key = str(configured.get('api_key', '') or '').strip() base_max_tokens = _title_completion_budget(provider, model, base_url) - reasoning_extra = {"reasoning": {"enabled": False}} + reasoning_extra = {} + if not _route_rejects_reasoning_extra(provider, model, base_url): + reasoning_extra["reasoning"] = {"enabled": False} if _is_minimax_route(provider, model, base_url): reasoning_extra["reasoning_split"] = True try: @@ -2619,7 +2663,7 @@ def generate_title_raw_via_aux( max_tokens=max_tokens, temperature=0.2, timeout=_timeout, - extra_body=reasoning_extra, + extra_body=reasoning_extra or None, ) raw, empty_status = _extract_title_response(resp, aux=True) if raw: @@ -2707,10 +2751,22 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) -> api_kwargs.pop('tools', None) api_kwargs['temperature'] = 0.1 api_kwargs['timeout'] = 15.0 + # Reasoning suppression for title gen is already handled + # route-correctly by `_build_api_kwargs()` from the + # `agent.reasoning_config = {"enabled": False}` set above — + # each provider profile applies (or deliberately omits) the + # disable in the form its endpoint accepts (OpenAI/Nous omit + # the field; LM Studio uses top-level reasoning_effort; + # OpenRouter Anthropic mandatory-reasoning is omitted). Do NOT + # re-inject a generic `reasoning:{enabled:False}` here — that + # re-adds a 400-rejected param on top of the profile output + # (#4161). MiniMax still needs reasoning_split, which the + # profile path does not add. + _tg_extra = dict(api_kwargs.get('extra_body') or {}) if _is_minimax_route(getattr(agent, 'provider', ''), getattr(agent, 'model', ''), getattr(agent, 'base_url', '')): - extra_body = dict(api_kwargs.get('extra_body') or {}) - extra_body['reasoning_split'] = True - api_kwargs['extra_body'] = extra_body + _tg_extra['reasoning_split'] = True + if _tg_extra: + api_kwargs['extra_body'] = _tg_extra if 'max_completion_tokens' in api_kwargs: api_kwargs['max_completion_tokens'] = max_tokens else: diff --git a/tests/test_title_gen_reasoning_extra_gate.py b/tests/test_title_gen_reasoning_extra_gate.py new file mode 100644 index 00000000..46c35d88 --- /dev/null +++ b/tests/test_title_gen_reasoning_extra_gate.py @@ -0,0 +1,56 @@ +"""Consolidated regression tests for title-gen reasoning extra_body gating. + +Consolidates #2083 (suppress thinking on reasoning models so titles aren't +polluted) with #4161 (OpenAI/Azure Chat Completions reject the `reasoning` +extra_body param with a 400 -> silent fallback to heuristic titles). + +Aux path (`generate_title_raw_via_aux`, no agent object): inject the +reasoning-disable EXCEPT on reject-listed routes (OpenAI/Azure). +Agent path (`generate_title_raw_via_agent`): inject only when the agent's +canonical `_supports_reasoning_extra_body()` says the route is reasoning- +tolerant AND the route is not reject-listed — which additionally excludes +OpenRouter Anthropic mandatory-reasoning models (Claude Sonnet 4.6 / Opus 4.8) +that are reasoning-capable but 400 on a disable. +""" +from __future__ import annotations + +from api.streaming import _route_rejects_reasoning_extra + + +class TestAuxRejectList: + def test_openai_direct_is_reject_listed(self): + assert _route_rejects_reasoning_extra("openai", "gpt-5.5", "https://api.openai.com/v1") is True + + def test_azure_is_reject_listed(self): + assert _route_rejects_reasoning_extra("azure", "gpt-4", "https://x.openai.azure.com/") is True + assert _route_rejects_reasoning_extra("azure/foo", "gpt-4", "") is True + + def test_azure_foundry_aliases_reject_listed(self): + assert _route_rejects_reasoning_extra("azure-foundry", "gpt-5", "") is True + assert _route_rejects_reasoning_extra("azure-ai-foundry", "gpt-5", "") is True + assert _route_rejects_reasoning_extra("azure-ai", "gpt-5", "") is True + # Foundry host-based detection (services.ai.azure.com / cognitiveservices) + assert _route_rejects_reasoning_extra("custom", "gpt-5", "https://x.services.ai.azure.com/v1") is True + + def test_hostname_match_not_substring(self): + # A proxy whose PATH merely contains api.openai.com must NOT be reject-listed. + assert _route_rejects_reasoning_extra("custom", "qwen3", "https://proxy.example.test/api.openai.com/v1") is False + # but the real OpenAI host is. + assert _route_rejects_reasoning_extra("custom", "gpt-5", "https://api.openai.com/v1") is True + + def test_openai_codex_alias_is_reject_listed(self): + assert _route_rejects_reasoning_extra("openai-codex", "gpt-5", "") is True + + def test_openrouter_non_anthropic_is_not_reject_listed(self): + assert _route_rejects_reasoning_extra("openrouter", "deepseek/deepseek-r1", "https://openrouter.ai/api/v1") is False + + def test_openrouter_anthropic_mandatory_is_reject_listed(self): + # Promoted into the shared helper so BOTH aux and agent paths skip it. + assert _route_rejects_reasoning_extra("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1") is True + assert _route_rejects_reasoning_extra("openrouter", "anthropic/claude-opus-4.8", "https://openrouter.ai/api/v1") is True + + def test_local_lmstudio_is_not_reject_listed(self): + assert _route_rejects_reasoning_extra("lmstudio", "qwen3-8b", "http://localhost:1234/v1") is False + + def test_minimax_is_not_reject_listed(self): + assert _route_rejects_reasoning_extra("", "minimax-m2", "https://api.minimaxi.com/v1") is False