diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c6a2a2515..f1d5ce1b984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Fixed +- Reasoning-effort detection for named `custom:*` providers now normalizes non-slash model ids before applying its fallback family heuristics, so separator variants such as `deepseek.v3.2`, `deepseek_v4_flash`, and vendor-namespaced ids like `vendor.deepseek.v3.2` resolve the same way as `deepseek-v4-flash`. The keyword fallback is now token-aware rather than substring-based, which preserves support for names like `model-thinking-preview` without falsely enabling reasoning for unrelated prefixes such as `thinkinghub.llama-3.1-70b`. + ## [v0.51.195] — 2026-06-01 — Release FO (stage-batch7 — hide attachment path markers in chat UI) ### Fixed @@ -7309,4 +7312,4 @@ Critical regressions introduced during the server.py split, caught by users and - **SSE loop did not break on `cancel` event** -- connection hung after cancel - **Regression test file added** (`tests/test_regressions.py`): 10 tests, one per introduced bug. These form a permanent regression gate so each class of error can never silently return. ---- \ No newline at end of file +--- diff --git a/api/config.py b/api/config.py index c0ca41fe305..e9d7e71d7db 100644 --- a/api/config.py +++ b/api/config.py @@ -2094,6 +2094,61 @@ def _strip_provider_hint_for_reasoning(model_id: str) -> str: return model +def _reasoning_name_candidates(model_id: str) -> list[str]: + """Return normalized model-name candidates for heuristic capability checks.""" + bare = str(model_id or "").strip().lower().rsplit("/", 1)[-1] + if not bare: + return [] + + candidates: list[str] = [] + + def _add(value: str) -> None: + candidate = str(value or "").strip().lower() + if candidate and candidate not in candidates: + candidates.append(candidate) + + _add(bare) + + dot_parts = [part for part in bare.split(".") if part] + if len(dot_parts) > 1: + # Try progressively stripping dot-separated vendor namespaces so inputs like + # "moonshotai.kimi-k2.5" and "vendor.deepseek.v3.2" both surface the real + # model family rather than treating every dot as part of the provider slug. + for index in range(1, len(dot_parts)): + suffix = ".".join(dot_parts[index:]) + if any(ch.isalpha() for ch in suffix): + _add(suffix) + + for candidate in list(candidates): + normalized = re.sub(r"[^a-z0-9]+", "-", candidate).strip("-") + _add(normalized) + + return candidates + + +def _candidate_supports_reasoning(candidate: str) -> bool: + normalized = re.sub(r"[^a-z0-9]+", "-", str(candidate or "").strip().lower()).strip("-") + if not normalized: + return False + + tokens = [token for token in normalized.split("-") if token] + token_set = set(tokens) + + if "thinking" in token_set or "reasoning" in token_set: + return True + if normalized in {"o1", "o3", "o4"} or normalized.startswith(("o1-", "o3-", "o4-")): + return True + if normalized.startswith(("kimi-k2", "kimi-thinking", "claude-3", "claude-4")): + return True + if normalized.startswith("qwen3") or "qwen3" in token_set: + return True + if normalized.startswith(("deepseek-v3", "deepseek-v4", "deepseek-r1", "deepseek-r2")): + return True + if len(tokens) >= 2 and tokens[0] == "deepseek" and tokens[1] in {"v3", "v4", "r1", "r2"}: + return True + return False + + def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: """Fallback when hermes_cli is unavailable.""" model = _strip_provider_hint_for_reasoning(model_id).lower() @@ -2123,31 +2178,11 @@ def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: ) if any(model.startswith(prefix) for prefix in prefixes): return list(VALID_REASONING_EFFORTS) - # Custom API aggregators (e.g. New API, One API) use non-standard model naming: - # bare names like "deepseek-v4-flash" or dot-separated "moonshotai.kimi-k2.5" - # rather than the OpenRouter-style "vendor/model" that the prefix list targets. - # Strip a dot-vendor prefix (e.g. "moonshotai.kimi-k2.5" → "kimi-k2.5") and - # check both the original bare name and the stripped suffix. - bare_after_dot = bare.split(".", 1)[-1] if "." in bare else bare - thinking_bare_prefixes = ( - "deepseek-v4", - "deepseek-r1", - "deepseek-r2", - "kimi-k2", - "kimi-thinking", - "qwen3", - "claude-3", - "claude-4", - "o1-", - "o3-", - "o4-", - ) - if any( - bare.startswith(p) or bare_after_dot.startswith(p) - for p in thinking_bare_prefixes - ): - return list(VALID_REASONING_EFFORTS) - if "thinking" in bare or "reasoning" in bare: + # Named custom providers often rewrite model ids with dots, underscores, or + # extra vendor namespaces. Normalize those shapes before applying family-level + # reasoning heuristics so "deepseek.v3.2", "deepseek_v4_flash", and + # "vendor.deepseek.v3.2" are treated consistently. + if any(_candidate_supports_reasoning(candidate) for candidate in _reasoning_name_candidates(bare)): return list(VALID_REASONING_EFFORTS) return [] diff --git a/tests/test_custom_provider_bare_model_reasoning.py b/tests/test_custom_provider_bare_model_reasoning.py index 951854fa27d..46fbf3f1d44 100644 --- a/tests/test_custom_provider_bare_model_reasoning.py +++ b/tests/test_custom_provider_bare_model_reasoning.py @@ -10,6 +10,8 @@ the underlying models fully support thinking/reasoning. """ +import pytest + import api.config as cfg @@ -33,6 +35,26 @@ def test_deepseek_r1_bare_name_custom_provider(): assert set(efforts) >= {"low", "medium", "high"} +@pytest.mark.parametrize( + "model_id", + [ + "deepseek.v3.2", + "deepseek_v3_2", + "vendor.deepseek.v3.2", + "deepseek.v4-flash", + "deepseek_v4_flash", + ], +) +def test_deepseek_separator_variants_custom_provider(model_id): + efforts = cfg.resolve_model_reasoning_efforts( + model_id, + provider_id="custom:newapi", + ) + assert set(efforts) >= {"low", "medium", "high"}, ( + f"{model_id} via custom provider should expose reasoning efforts" + ) + + # ── dot-separated model names (vendor.model) ───────────────────────────────── def test_kimi_dot_separated_custom_provider(): @@ -91,6 +113,20 @@ def test_plain_llm_dot_separated_custom_provider_no_reasoning(): ) == [] +@pytest.mark.parametrize( + "model_id", + [ + "thinkinghub.llama-3.1-70b", + "reasoninghub.llama-3.1-70b", + ], +) +def test_vendor_prefix_keyword_does_not_trigger_reasoning(model_id): + assert cfg.resolve_model_reasoning_efforts( + model_id, + provider_id="custom:newapi", + ) == [] + + # ── slash-prefixed names must still work (no regression) ───────────────────── def test_deepseek_slash_prefix_still_works():