Skip to content
Closed
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---
---
85 changes: 60 additions & 25 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 []

Expand Down
36 changes: 36 additions & 0 deletions tests/test_custom_provider_bare_model_reasoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
the underlying models fully support thinking/reasoning.
"""

import pytest

import api.config as cfg


Expand All @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
Loading