fix(provider): route reasoning controls by model family

This commit is contained in:
fdc310
2026-08-04 02:04:36 +08:00
parent 5461628eec
commit 098a15bd14
4 changed files with 553 additions and 156 deletions
@@ -718,7 +718,10 @@ class ModelManager:
provider_entity = self._coerce_provider(provider_info, context) provider_entity = self._coerce_provider(provider_info, context)
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester) requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest) litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
config = {'base_url': provider_entity.base_url} config = {
'base_url': provider_entity.base_url,
'requester_name': provider_entity.requester,
}
if litellm_provider: if litellm_provider:
from .requesters import litellmchat from .requesters import litellmchat
+14 -2
View File
@@ -28,8 +28,20 @@ REASONING_LEVELS: tuple[str, ...] = (
) )
DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'} DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'}
_CONFLICTING_TOP_LEVEL_ARGS = {'reasoning_effort', 'thinking', 'reasoning'} _CONFLICTING_TOP_LEVEL_ARGS = {
_CONFLICTING_EXTRA_BODY_ARGS = {'thinking', 'enable_thinking', 'thinking_budget', 'reasoning'} 'reasoning_effort',
'thinking',
'enable_thinking',
'thinking_budget',
'reasoning',
}
_CONFLICTING_EXTRA_BODY_ARGS = {
'reasoning_effort',
'thinking',
'enable_thinking',
'thinking_budget',
'reasoning',
}
def normalize_reasoning_config(value: typing.Any) -> dict[str, str]: def normalize_reasoning_config(value: typing.Any) -> dict[str, str]:
@@ -176,7 +176,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
'xai', 'xai',
} }
) )
_INFERRED_TOGGLE_PROVIDERS = frozenset({'deepseek', 'ollama', 'volcengine'}) _REQUESTER_REASONING_FAMILIES = {
'openai-chat-completions': 'openai',
'anthropic-messages': 'anthropic',
'deepseek-chat-completions': 'deepseek',
'moonshot-chat-completions': 'kimi',
'moonshot-cn-chat-completions': 'kimi',
'bailian-chat-completions': 'qwen',
'doubao-chat-completions': 'doubao',
'mimo-chat-completions': 'mimo',
}
default_config: dict[str, typing.Any] = { default_config: dict[str, typing.Any] = {
'base_url': '', 'base_url': '',
@@ -185,6 +194,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
'drop_params': False, 'drop_params': False,
'num_retries': 0, 'num_retries': 0,
'api_version': '', 'api_version': '',
'requester_name': '',
} }
async def initialize(self): async def initialize(self):
@@ -342,31 +352,144 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
def _supports_reasoning(self, model_name: str) -> bool: def _supports_reasoning(self, model_name: str) -> bool:
return self._safe_litellm_bool_helper('supports_reasoning', model_name) return self._safe_litellm_bool_helper('supports_reasoning', model_name)
def _reasoning_provider(self, model_name: str) -> str: def _requester_name(self, model: requester.RuntimeLLMModel | None = None) -> str:
if model is not None:
provider_entity = getattr(getattr(model, 'provider', None), 'provider_entity', None)
name = getattr(provider_entity, 'requester', None)
if isinstance(name, str) and name:
return name.lower()
return str(self.requester_cfg.get('requester_name') or '').lower()
@staticmethod
def _infer_reasoning_family_from_model_name(model_name: str) -> str:
normalized_name = (model_name or '').lower()
basename = normalized_name.rsplit('/', 1)[-1]
if basename.startswith(('gpt-', 'chatgpt-', 'o1', 'o3', 'o4')):
return 'openai'
if basename.startswith('claude-'):
return 'anthropic'
if basename.startswith('deepseek-'):
return 'deepseek'
if basename.startswith(('kimi-', 'moonshot-')):
return 'kimi'
if basename.startswith(('qwen-', 'qwen3', 'qwq')):
return 'qwen'
if basename.startswith(('doubao-', 'seed-')):
return 'doubao'
if basename.startswith('mimo-'):
return 'mimo'
return ''
def _reasoning_family(
self,
model_name: str,
model: requester.RuntimeLLMModel | None = None,
) -> str:
requester_name = self._requester_name(model)
if requester_name in {'new-api-chat-completions', 'volcark-chat-completions'}:
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
if inferred_family:
return inferred_family
return 'volcengine' if requester_name == 'volcark-chat-completions' else ''
requester_family = self._REQUESTER_REASONING_FAMILIES.get(requester_name)
if requester_family:
return requester_family
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
provider = (self._get_custom_llm_provider() or '').lower() provider = (self._get_custom_llm_provider() or '').lower()
if provider == 'openai':
return inferred_family or ('openai' if requester_name in {'', 'openai'} else '')
if provider: if provider:
return provider return provider
return inferred_family
normalized_name = (model_name or '').lower() @staticmethod
if '/' in normalized_name: def _is_anthropic_adaptive_model(model_name: str) -> bool:
prefix = normalized_name.split('/', 1)[0] basename = model_name.lower().rsplit('/', 1)[-1]
if prefix in { if 'mythos-preview' in basename:
'anthropic', return True
'deepseek',
'gemini',
'groq',
'mistral',
'ollama',
'openai',
'openrouter',
'together_ai',
'volcengine',
'xai',
}:
return prefix
candidates = self._metadata_provider_candidates(model_name) parts = basename.split('-')
return candidates[0] if candidates else '' if len(parts) < 3 or parts[0] != 'claude':
return False
model_families = {'opus', 'sonnet', 'fable', 'mythos'}
if parts[1] in model_families:
if parts[2] == '5':
return True
return len(parts) >= 4 and parts[2] == '4' and parts[3] in {'6', '7', '8'}
return parts[1] == '5' and parts[2] in model_families
@staticmethod
def _is_anthropic_always_thinking_model(model_name: str) -> bool:
normalized_name = model_name.lower()
return any(marker in normalized_name for marker in ('fable-5', 'mythos-5', 'mythos-preview'))
@staticmethod
def _is_dedicated_qwen_thinking_model(model_name: str) -> bool:
normalized_name = model_name.lower().rsplit('/', 1)[-1]
return normalized_name.startswith('qwq') or '-thinking' in normalized_name
def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None:
normalized_name = model_name.lower().rsplit('/', 1)[-1]
if family == 'deepseek' and normalized_name.startswith('deepseek-'):
if normalized_name.startswith('deepseek-v4-'):
return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
if 'reasoner' in normalized_name or '-r1' in normalized_name:
return ['provider_default']
return ['provider_default', 'disabled', 'enabled']
if family == 'kimi':
if normalized_name.startswith('kimi-k3'):
return ['provider_default', 'low', 'high', 'max']
if normalized_name.startswith('kimi-k2.7-code'):
return ['provider_default']
if normalized_name.startswith(('kimi-k2.5', 'kimi-k2.6')):
return ['provider_default', 'disabled', 'enabled']
if 'thinking' in normalized_name:
return ['provider_default']
if family == 'qwen' and normalized_name.startswith(('qwen-', 'qwen3', 'qwq')):
if self._is_dedicated_qwen_thinking_model(normalized_name):
return ['provider_default']
return ['provider_default', 'disabled', 'enabled']
if family == 'doubao' and normalized_name.startswith(('doubao-', 'seed-')):
return ['provider_default', 'disabled', 'low', 'medium', 'high']
if family == 'mimo' and normalized_name.startswith(('mimo-v2.5',)):
return ['provider_default', 'disabled', 'enabled']
if family == 'anthropic' and normalized_name.startswith('claude-'):
levels = ['provider_default']
adaptive = self._is_anthropic_adaptive_model(normalized_name)
if adaptive and not self._is_anthropic_always_thinking_model(normalized_name):
levels.append('disabled')
levels.extend(['low', 'medium', 'high'])
if adaptive:
levels.extend(['xhigh', 'max'])
return levels
if family == 'openai' and normalized_name.startswith(('gpt-5', 'o1', 'o3', 'o4')):
return ['provider_default', 'low', 'medium', 'high']
return None
def _openai_reasoning_levels(self, model_name: str) -> list[str]:
model_info = self._safe_model_info(model_name)
levels = ['provider_default']
if model_info.get('supports_none_reasoning_effort') is True:
levels.append('disabled')
if model_info.get('supports_minimal_reasoning_effort') is True:
levels.append('minimal')
for level in ('low', 'medium', 'high'):
if model_info.get(f'supports_{level}_reasoning_effort') is not False:
levels.append(level)
for level in ('xhigh', 'max'):
if model_info.get(f'supports_{level}_reasoning_effort') is True:
levels.append(level)
return levels
def _safe_model_info(self, model_name: str) -> dict[str, typing.Any]: def _safe_model_info(self, model_name: str) -> dict[str, typing.Any]:
helper = getattr(litellm, 'get_model_info', None) helper = getattr(litellm, 'get_model_info', None)
@@ -400,83 +523,91 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
abilities = model.model_entity.abilities or [] abilities = model.model_entity.abilities or []
detected = self._supports_reasoning(model_name) detected = self._supports_reasoning(model_name)
declared = 'reasoning' in abilities declared = 'reasoning' in abilities
provider = self._reasoning_provider(model_name) family = self._reasoning_family(model_name, model)
inferred = provider in self._INFERRED_EFFORT_PROVIDERS | self._INFERRED_TOGGLE_PROVIDERS known_levels = self._known_reasoning_levels(model_name, family)
supported = detected or declared or inferred supported = detected or declared or known_levels is not None
if not supported: if not supported:
return reasoning.default_reasoning_capabilities() return reasoning.default_reasoning_capabilities()
normalized_name = model_name.lower() normalized_name = model_name.lower()
levels = ['provider_default'] if family == 'openai':
levels = self._openai_reasoning_levels(model_name)
if provider == 'deepseek': elif known_levels is not None:
if 'reasoner' not in normalized_name and '-r1' not in normalized_name: levels = known_levels
levels.append('disabled') elif family == 'anthropic':
levels.append('enabled') levels = ['provider_default', 'low', 'medium', 'high']
elif provider == 'volcengine': elif family in {'deepseek', 'qwen', 'mimo', 'volcengine'}:
levels.extend(['disabled', 'enabled']) levels = ['provider_default', 'disabled', 'enabled']
elif provider == 'ollama': elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama':
levels = ['provider_default']
levels.append('disabled') levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
levels.extend(['low', 'medium', 'high']) levels.extend(['low', 'medium', 'high'])
else: else:
levels.append('enabled') levels.append('enabled')
elif not detected: elif family in self._INFERRED_EFFORT_PROVIDERS:
levels.extend(['low', 'medium', 'high']) levels = ['provider_default', 'low', 'medium', 'high']
else: else:
model_info = self._safe_model_info(model_name) levels = ['provider_default']
supports_none = model_info.get('supports_none_reasoning_effort') is True
if provider == 'anthropic':
supports_none = True
if provider == 'gemini' and 'gemini-3' in normalized_name:
supports_none = False
if supports_none:
levels.append('disabled')
for level in ('minimal', 'low', 'medium', 'high'):
flag = model_info.get(f'supports_{level}_reasoning_effort')
if flag is not False:
levels.append(level)
for level in ('xhigh', 'max'):
if model_info.get(f'supports_{level}_reasoning_effort') is True:
levels.append(level)
return { return {
'supported': True, 'supported': True,
'levels': list(dict.fromkeys(levels)), 'levels': list(dict.fromkeys(levels)),
'source': 'litellm' if detected else ('provider' if inferred else 'manual'), 'source': 'litellm' if detected else ('provider' if known_levels is not None else 'manual'),
} }
def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]: def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
raw_config = getattr(model, 'reasoning_config_override', None) level = self._reasoning_level(model)
if raw_config is None:
raw_config = getattr(model.model_entity, 'reasoning_config', None)
if not isinstance(raw_config, dict):
raw_config = None
config = reasoning.normalize_reasoning_config(raw_config)
level = config['level']
if level == 'provider_default': if level == 'provider_default':
return {} return {}
config = {'level': level}
capabilities = self.get_reasoning_capabilities(model) capabilities = self.get_reasoning_capabilities(model)
try: try:
reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name) reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name)
except ValueError as exc: except ValueError as exc:
raise errors.RequesterError(str(exc)) from exc raise errors.RequesterError(str(exc)) from exc
provider = self._reasoning_provider(model.model_entity.name) family = self._reasoning_family(model.model_entity.name, model)
if level == 'disabled': if level == 'disabled':
if provider == 'deepseek': if family in {'deepseek', 'kimi', 'mimo', 'doubao'}:
return {'extra_body': {'thinking': {'type': 'disabled'}}} return {'extra_body': {'thinking': {'type': 'disabled'}}}
if provider == 'volcengine': if family == 'qwen':
return {'extra_body': {'enable_thinking': False}}
if family == 'volcengine':
return {'extra_body': {'thinking': {'type': 'disabled'}}}
if family == 'anthropic':
return {'thinking': {'type': 'disabled'}} return {'thinking': {'type': 'disabled'}}
return {'reasoning_effort': 'none'} return {'reasoning_effort': 'none'}
if level == 'enabled': if level == 'enabled':
if provider in {'deepseek', 'volcengine'}: if family in {'deepseek', 'kimi', 'mimo', 'volcengine'}:
return {'thinking': {'type': 'enabled'}} return {'extra_body': {'thinking': {'type': 'enabled'}}}
if family == 'qwen':
return {'extra_body': {'enable_thinking': True}}
return {'reasoning_effort': 'low'} return {'reasoning_effort': 'low'}
if family == 'deepseek':
return {
'extra_body': {
'thinking': {'type': 'enabled'},
'reasoning_effort': level,
}
}
return {'reasoning_effort': level} return {'reasoning_effort': level}
@staticmethod
def _reasoning_config_value(model: requester.RuntimeLLMModel) -> typing.Any:
raw_config = getattr(model, 'reasoning_config_override', None)
if raw_config is None:
raw_config = getattr(model.model_entity, 'reasoning_config', None)
if not isinstance(raw_config, dict):
return None
return raw_config
def _reasoning_level(self, model: requester.RuntimeLLMModel) -> str:
return reasoning.normalize_reasoning_config(self._reasoning_config_value(model))['level']
def _infer_model_type(self, model_id: str) -> str: def _infer_model_type(self, model_id: str) -> str:
normalized_id = (model_id or '').lower() normalized_id = (model_id or '').lower()
if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS): if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS):
@@ -510,7 +641,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
supports_provider_reported_reasoning = bool( supports_provider_reported_reasoning = bool(
model_payload and model_payload.get('supports_reasoning') is True model_payload and model_payload.get('supports_reasoning') is True
) )
if supports_provider_reported_reasoning or self._supports_reasoning(model_id): family = self._reasoning_family(model_id)
supports_known_reasoning = self._known_reasoning_levels(model_id, family) is not None
if supports_provider_reported_reasoning or supports_known_reasoning or self._supports_reasoning(model_id):
abilities.append('reasoning') abilities.append('reasoning')
scanned_model['abilities'] = abilities scanned_model['abilities'] = abilities
@@ -522,13 +655,43 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return scanned_model return scanned_model
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]: def _convert_messages(
self,
messages: typing.List[provider_message.Message],
reasoning_family: str = '',
include_reasoning_context: bool = True,
) -> list[dict]:
"""Convert LangBot messages to LiteLLM/OpenAI format.""" """Convert LangBot messages to LiteLLM/OpenAI format."""
req_messages = [] req_messages = []
for m in messages: for m in messages:
msg_dict = m.dict(exclude_none=True) msg_dict = m.dict(exclude_none=True)
content = msg_dict.get('content') content = msg_dict.get('content')
if msg_dict.get('role') == 'assistant' and reasoning_family:
provider_fields = msg_dict.get('provider_specific_fields')
if isinstance(provider_fields, dict):
cleaned_provider_fields = dict(provider_fields)
reasoning_content = cleaned_provider_fields.pop('reasoning_content', None)
thinking_blocks = cleaned_provider_fields.pop('thinking_blocks', None)
if include_reasoning_context:
if reasoning_family == 'anthropic' and thinking_blocks:
msg_dict['thinking_blocks'] = thinking_blocks
elif reasoning_family in {
'deepseek',
'kimi',
'qwen',
'doubao',
'mimo',
'volcengine',
} and isinstance(reasoning_content, str):
msg_dict['reasoning_content'] = reasoning_content
if cleaned_provider_fields:
msg_dict['provider_specific_fields'] = cleaned_provider_fields
else:
msg_dict.pop('provider_specific_fields', None)
if isinstance(content, list): if isinstance(content, list):
converted_parts = [] converted_parts = []
for part in content: for part in content:
@@ -819,7 +982,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
stream: bool = False, stream: bool = False,
) -> dict: ) -> dict:
"""Build common completion arguments for invoke_llm and invoke_llm_stream.""" """Build common completion arguments for invoke_llm and invoke_llm_stream."""
req_messages = self._convert_messages(messages) reasoning_family = self._reasoning_family(model.model_entity.name, model)
reasoning_level = self._reasoning_level(model)
req_messages = self._convert_messages(
messages,
reasoning_family=reasoning_family,
include_reasoning_context=reasoning_level != 'disabled',
)
model_name = self._build_litellm_model_name(model.model_entity.name) model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token() api_key = model.provider.token_mgr.get_token()
@@ -855,7 +1024,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
args['extra_body'] = {**existing_extra_body, **reasoning_extra_body} args['extra_body'] = {**existing_extra_body, **reasoning_extra_body}
else: else:
args.update(reasoning_args) args.update(reasoning_args)
if 'reasoning_effort' in reasoning_args and self._reasoning_provider(model.model_entity.name) == 'openai': if 'reasoning_effort' in reasoning_args and self._get_custom_llm_provider() == 'openai':
allowed_openai_params = args.get('allowed_openai_params') or [] allowed_openai_params = args.get('allowed_openai_params') or []
if not isinstance(allowed_openai_params, (list, tuple, set)): if not isinstance(allowed_openai_params, (list, tuple, set)):
raise errors.RequesterError('allowed_openai_params must be an array') raise errors.RequesterError('allowed_openai_params must be an array')
@@ -19,6 +19,7 @@ def _runtime_model(
level: str = 'provider_default', level: str = 'provider_default',
name: str = 'reasoning-model', name: str = 'reasoning-model',
abilities: list[str] | None = None, abilities: list[str] | None = None,
requester_name: str | None = None,
) -> requester.RuntimeLLMModel: ) -> requester.RuntimeLLMModel:
execution_context = ExecutionContext( execution_context = ExecutionContext(
instance_uuid='instance-test', instance_uuid='instance-test',
@@ -40,7 +41,7 @@ def _runtime_model(
workspace_uuid='workspace-test', workspace_uuid='workspace-test',
uuid='provider-test', uuid='provider-test',
name='provider', name='provider',
requester='openai', requester=requester_name or request.requester_cfg.get('requester_name') or 'custom-requester',
base_url='https://example.com', base_url='https://example.com',
api_keys=[], api_keys=[],
), ),
@@ -50,8 +51,14 @@ def _runtime_model(
return requester.RuntimeLLMModel(execution_context, entity, provider) return requester.RuntimeLLMModel(execution_context, entity, provider)
def _requester(provider: str = '') -> LiteLLMRequester: def _requester(provider: str = '', requester_name: str = '') -> LiteLLMRequester:
return LiteLLMRequester(SimpleNamespace(), {'custom_llm_provider': provider}) return LiteLLMRequester(
SimpleNamespace(),
{
'custom_llm_provider': provider,
'requester_name': requester_name,
},
)
def test_reasoning_config_normalization_and_conflicts(): def test_reasoning_config_normalization_and_conflicts():
@@ -73,46 +80,35 @@ def test_reasoning_config_normalization_and_conflicts():
['reasoning'], ['reasoning'],
{'extra_body': {'thinking_budget': 1024}}, {'extra_body': {'thinking_budget': 1024}},
) )
assert reasoning.find_reasoning_arg_conflicts(
{
'enable_thinking': True,
'extra_body': {'reasoning_effort': 'high'},
}
) == ['enable_thinking', 'extra_body.reasoning_effort']
def test_manual_reasoning_model_exposes_conservative_effort_levels(monkeypatch): def test_manual_reasoning_model_without_known_protocol_stays_conservative(monkeypatch):
request = _requester() request = _requester()
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
capabilities = request.get_reasoning_capabilities(_runtime_model(request)) capabilities = request.get_reasoning_capabilities(_runtime_model(request))
assert capabilities == { assert capabilities == {
'supported': True, 'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high'], 'levels': ['provider_default'],
'source': 'manual', 'source': 'manual',
} }
def test_provider_protocol_exposes_reasoning_for_unknown_model(monkeypatch): def test_openai_protocol_does_not_mark_unknown_models_as_reasoning(monkeypatch):
request = _requester('openai') request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_safe_model_info', lambda _: pytest.fail('metadata should not be queried'))
capabilities = request.get_reasoning_capabilities( capabilities = request.get_reasoning_capabilities(
_runtime_model(request, name='future-reasoning-model', abilities=[]) _runtime_model(request, name='future-reasoning-model', abilities=[])
) )
assert capabilities == {
'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high'],
'source': 'provider',
}
def test_unknown_unmarked_model_without_provider_stays_safe(monkeypatch):
request = _requester()
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
capabilities = request.get_reasoning_capabilities(
_runtime_model(request, name='unknown-model', abilities=[])
)
assert capabilities == { assert capabilities == {
'supported': False, 'supported': False,
'levels': ['provider_default'], 'levels': ['provider_default'],
@@ -120,33 +116,34 @@ def test_unknown_unmarked_model_without_provider_stays_safe(monkeypatch):
} }
def test_mimo_native_model_uses_known_equivalent_litellm_metadata(monkeypatch): def test_unknown_unmarked_model_without_provider_stays_safe(monkeypatch):
request = _requester('openai') request = _requester()
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
def supports_reasoning(model: str, custom_llm_provider: str | None = None) -> bool: capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='unknown-model', abilities=[]))
return model == 'openrouter/xiaomi/mimo-v2.5'
def get_model_info(model: str) -> dict: assert capabilities == {
if model == 'openrouter/xiaomi/mimo-v2.5': 'supported': False,
return {'supports_reasoning': True} 'levels': ['provider_default'],
raise ValueError('unknown model') 'source': 'unknown',
}
monkeypatch.setattr(litellmchat.litellm, 'supports_reasoning', supports_reasoning)
monkeypatch.setattr(litellmchat.litellm, 'get_model_info', get_model_info)
capabilities = request.get_reasoning_capabilities( def test_mimo_exposes_off_on_without_fake_effort_levels(monkeypatch):
_runtime_model(request, name='mimo-v2.5', abilities=[]) request = _requester('openai', 'mimo-chat-completions')
) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='mimo-v2.5', abilities=[]))
assert capabilities == { assert capabilities == {
'supported': True, 'supported': True,
'levels': ['provider_default', 'minimal', 'low', 'medium', 'high'], 'levels': ['provider_default', 'disabled', 'enabled'],
'source': 'litellm', 'source': 'provider',
} }
def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch): def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch):
request = _requester('openai') request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr( monkeypatch.setattr(
request, request,
@@ -172,34 +169,199 @@ def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch):
] ]
def test_reasoning_argument_translation(monkeypatch): def test_anthropic_adaptive_and_always_on_profiles(monkeypatch):
openai_request = _requester('openai') request = _requester('anthropic', 'anthropic-messages')
monkeypatch.setattr(openai_request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(
openai_request, adaptive = request.get_reasoning_capabilities(_runtime_model(request, name='claude-sonnet-4-6', abilities=[]))
'_safe_model_info', assert adaptive['levels'] == [
lambda _: {'supports_none_reasoning_effort': True}, 'provider_default',
'disabled',
'low',
'medium',
'high',
'xhigh',
'max',
]
always_on = request.get_reasoning_capabilities(_runtime_model(request, name='claude-fable-5', abilities=[]))
assert 'disabled' not in always_on['levels']
legacy = request.get_reasoning_capabilities(_runtime_model(request, name='claude-3-5-sonnet', abilities=[]))
assert legacy['levels'] == ['provider_default', 'low', 'medium', 'high']
def test_deepseek_profiles_match_model_generation(monkeypatch):
request = _requester('deepseek', 'deepseek-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-v4-flash', abilities=[]))[
'levels'
] == ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-chat', abilities=[]))[
'levels'
] == ['provider_default', 'disabled', 'enabled']
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-r1', abilities=[]))['levels'] == [
'provider_default'
]
@pytest.mark.parametrize(
('model_name', 'expected_levels'),
[
('kimi-k3', ['provider_default', 'low', 'high', 'max']),
('kimi-k2.7-code', ['provider_default']),
('kimi-k2.6', ['provider_default', 'disabled', 'enabled']),
('kimi-k2.5', ['provider_default', 'disabled', 'enabled']),
],
)
def test_kimi_profiles(model_name, expected_levels, monkeypatch):
request = _requester('openai', 'moonshot-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
assert capabilities['levels'] == expected_levels
def test_qwen_mixed_and_dedicated_thinking_profiles(monkeypatch):
request = _requester('openai', 'bailian-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
mixed = request.get_reasoning_capabilities(_runtime_model(request, name='qwen-plus', abilities=[]))
dedicated = request.get_reasoning_capabilities(
_runtime_model(request, name='qwen3-235b-a22b-thinking-2507', abilities=[])
) )
assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'provider_default')) == {} assert mixed['levels'] == ['provider_default', 'disabled', 'enabled']
assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'disabled')) == { assert dedicated['levels'] == ['provider_default']
def test_doubao_exposes_documented_effort_range(monkeypatch):
request = _requester('openai', 'doubao-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
capabilities = request.get_reasoning_capabilities(
_runtime_model(request, name='doubao-seed-2-1-pro-260628', abilities=[])
)
assert capabilities['levels'] == ['provider_default', 'disabled', 'low', 'medium', 'high']
@pytest.mark.parametrize(
('model_name', 'expected_levels'),
[
('gpt-5', ['provider_default', 'low', 'medium', 'high']),
(
'claude-sonnet-4-6',
['provider_default', 'disabled', 'low', 'medium', 'high', 'xhigh', 'max'],
),
('deepseek-v4-flash', ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']),
('kimi-k2.6', ['provider_default', 'disabled', 'enabled']),
('qwen-plus', ['provider_default', 'disabled', 'enabled']),
('doubao-seed-2-1-pro-260628', ['provider_default', 'disabled', 'low', 'medium', 'high']),
('mimo-v2.5', ['provider_default', 'disabled', 'enabled']),
],
)
def test_new_api_infers_upstream_protocol_from_model_name(model_name, expected_levels, monkeypatch):
request = _requester('openai', 'new-api-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
assert capabilities['levels'] == expected_levels
@pytest.mark.parametrize(
('provider', 'requester_name', 'model_name'),
[
('openai', 'openai-chat-completions', 'gpt-5'),
('anthropic', 'anthropic-messages', 'claude-sonnet-4-6'),
('deepseek', 'deepseek-chat-completions', 'deepseek-v4-flash'),
('openai', 'mimo-chat-completions', 'mimo-v2.5'),
('openai', 'moonshot-chat-completions', 'kimi-k2.6'),
('openai', 'bailian-chat-completions', 'qwen-plus'),
('openai', 'doubao-chat-completions', 'doubao-seed-2-1-pro-260628'),
('openai', 'new-api-chat-completions', 'deepseek-v4-flash'),
],
)
def test_scanned_known_models_gain_reasoning_ability(provider, requester_name, model_name, monkeypatch):
request = _requester(provider, requester_name)
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_supports_function_calling', lambda _: False)
monkeypatch.setattr(request, '_supports_vision', lambda _: False)
monkeypatch.setattr(request, '_safe_context_length', lambda _: None)
scanned = request._enrich_scanned_model(model_name)
assert scanned['abilities'] == ['reasoning']
def test_new_api_unknown_alias_stays_conservative(monkeypatch):
request = _requester('openai', 'new-api-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
capabilities = request.get_reasoning_capabilities(
_runtime_model(request, name='company-internal-alias', abilities=[])
)
assert capabilities == {
'supported': False,
'levels': ['provider_default'],
'source': 'unknown',
}
def test_reasoning_argument_translation(monkeypatch):
openai_request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(openai_request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(openai_request, '_safe_model_info', lambda _: {'supports_none_reasoning_effort': True})
assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'disabled', name='gpt-5')) == {
'reasoning_effort': 'none' 'reasoning_effort': 'none'
} }
assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'high')) == {'reasoning_effort': 'high'}
deepseek_request = _requester('deepseek') anthropic_request = _requester('anthropic', 'anthropic-messages')
monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: False) assert anthropic_request._build_reasoning_args(
monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {}) _runtime_model(anthropic_request, 'disabled', name='claude-sonnet-4-6')
) == {'thinking': {'type': 'disabled'}}
deepseek_request = _requester('deepseek', 'deepseek-chat-completions')
assert deepseek_request._build_reasoning_args( assert deepseek_request._build_reasoning_args(
_runtime_model(deepseek_request, 'enabled', name='deepseek-chat') _runtime_model(deepseek_request, 'high', name='deepseek-v4-flash')
) == {'thinking': {'type': 'enabled'}} ) == {
assert deepseek_request._build_reasoning_args( 'extra_body': {
_runtime_model(deepseek_request, 'disabled', name='deepseek-chat') 'thinking': {'type': 'enabled'},
) == {'extra_body': {'thinking': {'type': 'disabled'}}} 'reasoning_effort': 'high',
}
}
kimi_request = _requester('openai', 'moonshot-chat-completions')
assert kimi_request._build_reasoning_args(_runtime_model(kimi_request, 'enabled', name='kimi-k2.6')) == {
'extra_body': {'thinking': {'type': 'enabled'}}
}
assert kimi_request._build_reasoning_args(_runtime_model(kimi_request, 'high', name='kimi-k3')) == {
'reasoning_effort': 'high'
}
qwen_request = _requester('openai', 'bailian-chat-completions')
assert qwen_request._build_reasoning_args(_runtime_model(qwen_request, 'disabled', name='qwen-plus')) == {
'extra_body': {'enable_thinking': False}
}
doubao_request = _requester('openai', 'doubao-chat-completions')
assert doubao_request._build_reasoning_args(
_runtime_model(doubao_request, 'high', name='doubao-seed-2-1-pro-260628')
) == {'reasoning_effort': 'high'}
mimo_request = _requester('openai', 'mimo-chat-completions')
assert mimo_request._build_reasoning_args(_runtime_model(mimo_request, 'disabled', name='mimo-v2.5')) == {
'extra_body': {'thinking': {'type': 'disabled'}}
}
def test_pipeline_reasoning_override_takes_precedence(monkeypatch): def test_pipeline_reasoning_override_takes_precedence(monkeypatch):
request = _requester('openai') request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, 'high', name='gpt-5') model = _runtime_model(request, 'high', name='gpt-5')
@@ -211,24 +373,14 @@ def test_pipeline_reasoning_override_takes_precedence(monkeypatch):
assert request._build_reasoning_args(model) == {'reasoning_effort': 'low'} assert request._build_reasoning_args(model) == {'reasoning_effort': 'low'}
def test_deepseek_provider_is_inferred_from_model_name(monkeypatch):
request = _requester()
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-chat'))
assert capabilities['levels'] == ['provider_default', 'disabled', 'enabled']
def test_always_on_reasoning_models_do_not_offer_disabled(monkeypatch): def test_always_on_reasoning_models_do_not_offer_disabled(monkeypatch):
deepseek_request = _requester('deepseek') deepseek_request = _requester('deepseek', 'deepseek-chat-completions')
monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {}) monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {})
deepseek_capabilities = deepseek_request.get_reasoning_capabilities( deepseek_capabilities = deepseek_request.get_reasoning_capabilities(
_runtime_model(deepseek_request, name='deepseek-r1') _runtime_model(deepseek_request, name='deepseek-r1')
) )
assert deepseek_capabilities['levels'] == ['provider_default', 'enabled'] assert deepseek_capabilities['levels'] == ['provider_default']
gemini_request = _requester('gemini') gemini_request = _requester('gemini')
monkeypatch.setattr(gemini_request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(gemini_request, '_supports_reasoning', lambda _: True)
@@ -243,8 +395,8 @@ def test_always_on_reasoning_models_do_not_offer_disabled(monkeypatch):
gemini_request._build_reasoning_args(_runtime_model(gemini_request, 'disabled', name='gemini-3-pro')) gemini_request._build_reasoning_args(_runtime_model(gemini_request, 'disabled', name='gemini-3-pro'))
def test_toggle_and_effort_provider_capabilities(monkeypatch): def test_non_target_provider_capabilities_remain_supported(monkeypatch):
ollama_request = _requester('ollama') ollama_request = _requester('ollama', 'ollama')
monkeypatch.setattr(ollama_request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(ollama_request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(ollama_request, '_safe_model_info', lambda _: {}) monkeypatch.setattr(ollama_request, '_safe_model_info', lambda _: {})
@@ -270,12 +422,12 @@ def test_toggle_and_effort_provider_capabilities(monkeypatch):
'reasoning_effort': 'high' 'reasoning_effort': 'high'
} }
volcengine_request = _requester('volcengine') volcengine_request = _requester('volcengine', 'volcark-chat-completions')
monkeypatch.setattr(volcengine_request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(volcengine_request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(volcengine_request, '_safe_model_info', lambda _: {}) monkeypatch.setattr(volcengine_request, '_safe_model_info', lambda _: {})
assert volcengine_request._build_reasoning_args( assert volcengine_request._build_reasoning_args(
_runtime_model(volcengine_request, 'disabled', name='doubao-seed') _runtime_model(volcengine_request, 'disabled', name='doubao-seed')
) == {'thinking': {'type': 'disabled'}} ) == {'extra_body': {'thinking': {'type': 'disabled'}}}
def test_explicit_unsupported_level_raises(monkeypatch): def test_explicit_unsupported_level_raises(monkeypatch):
@@ -288,19 +440,20 @@ def test_explicit_unsupported_level_raises(monkeypatch):
def test_provider_inference_rejects_levels_outside_conservative_profile(monkeypatch): def test_provider_inference_rejects_levels_outside_conservative_profile(monkeypatch):
request = _requester('openai') request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
with pytest.raises(errors.RequesterError, match='Available levels: provider_default, low, medium, high'): with pytest.raises(errors.RequesterError, match='Available levels: provider_default, low, medium, high'):
request._build_reasoning_args(_runtime_model(request, 'xhigh', abilities=[])) request._build_reasoning_args(_runtime_model(request, 'xhigh', name='gpt-5', abilities=[]))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_completion_args_reject_reasoning_extra_arg_conflicts(monkeypatch): async def test_completion_args_reject_reasoning_extra_arg_conflicts(monkeypatch):
request = _requester('openai') request = _requester('openai', 'openai-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, 'high') model = _runtime_model(request, 'high', name='gpt-5')
model.model_entity.extra_args = {'reasoning_effort': 'low'} model.model_entity.extra_args = {'reasoning_effort': 'low'}
model.provider.token_mgr.get_token = lambda: 'test-token' model.provider.token_mgr.get_token = lambda: 'test-token'
@@ -310,10 +463,8 @@ async def test_completion_args_reject_reasoning_extra_arg_conflicts(monkeypatch)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypatch): async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypatch):
request = _requester('openai') request = _requester('openai', 'moonshot-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) model = _runtime_model(request, 'high', name='kimi-k3')
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, 'high', name='deepseek-v4-flash')
model.model_entity.extra_args = {'allowed_openai_params': ['custom_extension']} model.model_entity.extra_args = {'allowed_openai_params': ['custom_extension']}
model.provider.token_mgr.get_token = lambda: 'test-token' model.provider.token_mgr.get_token = lambda: 'test-token'
@@ -325,7 +476,7 @@ async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypa
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_default_does_not_allow_or_send_reasoning_effort(): async def test_provider_default_does_not_allow_or_send_reasoning_effort():
request = _requester('openai') request = _requester('openai', 'new-api-chat-completions')
model = _runtime_model(request, 'provider_default', name='deepseek-v4-flash') model = _runtime_model(request, 'provider_default', name='deepseek-v4-flash')
model.provider.token_mgr.get_token = lambda: 'test-token' model.provider.token_mgr.get_token = lambda: 'test-token'
@@ -337,7 +488,7 @@ async def test_provider_default_does_not_allow_or_send_reasoning_effort():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deepseek_disabled_thinking_is_merged_into_extra_body(monkeypatch): async def test_deepseek_disabled_thinking_is_merged_into_extra_body(monkeypatch):
request = _requester('deepseek') request = _requester('deepseek', 'deepseek-chat-completions')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
model = _runtime_model(request, 'disabled', name='deepseek-chat') model = _runtime_model(request, 'disabled', name='deepseek-chat')
model.model_entity.extra_args = {'extra_body': {'custom_extension': True}} model.model_entity.extra_args = {'extra_body': {'custom_extension': True}}
@@ -351,6 +502,68 @@ async def test_deepseek_disabled_thinking_is_merged_into_extra_body(monkeypatch)
} }
@pytest.mark.asyncio
async def test_openai_compatible_reasoning_history_is_promoted_for_tool_continuity():
request = _requester('openai', 'mimo-chat-completions')
model = _runtime_model(request, 'enabled', name='mimo-v2.5')
model.provider.token_mgr.get_token = lambda: 'test-token'
history = [
provider_message.Message(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': 'prior reasoning'},
)
]
args = await request._build_completion_args(model, history)
assert args['messages'][0]['reasoning_content'] == 'prior reasoning'
assert 'provider_specific_fields' not in args['messages'][0]
@pytest.mark.asyncio
async def test_disabling_reasoning_removes_previous_reasoning_context():
request = _requester('openai', 'mimo-chat-completions')
model = _runtime_model(request, 'disabled', name='mimo-v2.5')
model.provider.token_mgr.get_token = lambda: 'test-token'
history = [
provider_message.Message(
role='assistant',
content='answer',
provider_specific_fields={'reasoning_content': 'prior reasoning'},
)
]
args = await request._build_completion_args(model, history)
assert 'reasoning_content' not in args['messages'][0]
assert 'provider_specific_fields' not in args['messages'][0]
@pytest.mark.asyncio
async def test_anthropic_history_promotes_thinking_blocks_instead_of_reasoning_content():
request = _requester('anthropic', 'anthropic-messages')
model = _runtime_model(request, 'high', name='claude-sonnet-4-6')
model.provider.token_mgr.get_token = lambda: 'test-token'
thinking_blocks = [{'type': 'thinking', 'thinking': 'prior reasoning', 'signature': 'sig'}]
history = [
provider_message.Message(
role='assistant',
content='',
provider_specific_fields={
'reasoning_content': 'prior reasoning',
'thinking_blocks': thinking_blocks,
},
)
]
args = await request._build_completion_args(model, history)
assert args['messages'][0]['thinking_blocks'] == thinking_blocks
assert 'reasoning_content' not in args['messages'][0]
assert 'provider_specific_fields' not in args['messages'][0]
class _Dumpable: class _Dumpable:
def __init__(self, data: dict): def __init__(self, data: dict):
self.data = data self.data = data