diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index 204f0624b..16441cf18 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -718,7 +718,10 @@ class ModelManager: provider_entity = self._coerce_provider(provider_info, context) requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester) 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: from .requesters import litellmchat diff --git a/src/langbot/pkg/provider/modelmgr/reasoning.py b/src/langbot/pkg/provider/modelmgr/reasoning.py index dc76ea5a4..3de7536cc 100644 --- a/src/langbot/pkg/provider/modelmgr/reasoning.py +++ b/src/langbot/pkg/provider/modelmgr/reasoning.py @@ -28,8 +28,20 @@ REASONING_LEVELS: tuple[str, ...] = ( ) DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'} -_CONFLICTING_TOP_LEVEL_ARGS = {'reasoning_effort', 'thinking', 'reasoning'} -_CONFLICTING_EXTRA_BODY_ARGS = {'thinking', 'enable_thinking', 'thinking_budget', 'reasoning'} +_CONFLICTING_TOP_LEVEL_ARGS = { + '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]: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 68dd50aa1..ae1904b25 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -176,7 +176,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester): '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] = { 'base_url': '', @@ -185,6 +194,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 'drop_params': False, 'num_retries': 0, 'api_version': '', + 'requester_name': '', } async def initialize(self): @@ -342,31 +352,144 @@ class LiteLLMRequester(requester.ProviderAPIRequester): def _supports_reasoning(self, model_name: str) -> bool: 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() + if provider == 'openai': + return inferred_family or ('openai' if requester_name in {'', 'openai'} else '') if provider: return provider + return inferred_family - normalized_name = (model_name or '').lower() - if '/' in normalized_name: - prefix = normalized_name.split('/', 1)[0] - if prefix in { - 'anthropic', - 'deepseek', - 'gemini', - 'groq', - 'mistral', - 'ollama', - 'openai', - 'openrouter', - 'together_ai', - 'volcengine', - 'xai', - }: - return prefix + @staticmethod + def _is_anthropic_adaptive_model(model_name: str) -> bool: + basename = model_name.lower().rsplit('/', 1)[-1] + if 'mythos-preview' in basename: + return True - candidates = self._metadata_provider_candidates(model_name) - return candidates[0] if candidates else '' + parts = basename.split('-') + 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]: helper = getattr(litellm, 'get_model_info', None) @@ -400,83 +523,91 @@ class LiteLLMRequester(requester.ProviderAPIRequester): abilities = model.model_entity.abilities or [] detected = self._supports_reasoning(model_name) declared = 'reasoning' in abilities - provider = self._reasoning_provider(model_name) - inferred = provider in self._INFERRED_EFFORT_PROVIDERS | self._INFERRED_TOGGLE_PROVIDERS - supported = detected or declared or inferred + family = self._reasoning_family(model_name, model) + known_levels = self._known_reasoning_levels(model_name, family) + supported = detected or declared or known_levels is not None if not supported: return reasoning.default_reasoning_capabilities() normalized_name = model_name.lower() - levels = ['provider_default'] - - if provider == 'deepseek': - if 'reasoner' not in normalized_name and '-r1' not in normalized_name: - levels.append('disabled') - levels.append('enabled') - elif provider == 'volcengine': - levels.extend(['disabled', 'enabled']) - elif provider == 'ollama': + if family == 'openai': + levels = self._openai_reasoning_levels(model_name) + elif known_levels is not None: + levels = known_levels + elif family == 'anthropic': + levels = ['provider_default', 'low', 'medium', 'high'] + elif family in {'deepseek', 'qwen', 'mimo', 'volcengine'}: + levels = ['provider_default', 'disabled', 'enabled'] + elif family == 'doubao': + levels = ['provider_default', 'disabled', 'low', 'medium', 'high'] + elif family == 'ollama': + levels = ['provider_default'] levels.append('disabled') if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: levels.extend(['low', 'medium', 'high']) else: levels.append('enabled') - elif not detected: - levels.extend(['low', 'medium', 'high']) + elif family in self._INFERRED_EFFORT_PROVIDERS: + levels = ['provider_default', 'low', 'medium', 'high'] else: - model_info = self._safe_model_info(model_name) - 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) + levels = ['provider_default'] return { 'supported': True, '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]: - 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): - raw_config = None - config = reasoning.normalize_reasoning_config(raw_config) - level = config['level'] + level = self._reasoning_level(model) if level == 'provider_default': return {} + config = {'level': level} capabilities = self.get_reasoning_capabilities(model) try: reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name) except ValueError as 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 provider == 'deepseek': + if family in {'deepseek', 'kimi', 'mimo', 'doubao'}: 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 {'reasoning_effort': 'none'} if level == 'enabled': - if provider in {'deepseek', 'volcengine'}: - return {'thinking': {'type': 'enabled'}} + if family in {'deepseek', 'kimi', 'mimo', 'volcengine'}: + return {'extra_body': {'thinking': {'type': 'enabled'}}} + if family == 'qwen': + return {'extra_body': {'enable_thinking': True}} return {'reasoning_effort': 'low'} + if family == 'deepseek': + return { + 'extra_body': { + 'thinking': {'type': 'enabled'}, + '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: normalized_id = (model_id or '').lower() 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( 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') scanned_model['abilities'] = abilities @@ -522,13 +655,43 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 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.""" req_messages = [] for m in messages: msg_dict = m.dict(exclude_none=True) 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): converted_parts = [] for part in content: @@ -819,7 +982,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): stream: bool = False, ) -> dict: """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) 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} else: 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 [] if not isinstance(allowed_openai_params, (list, tuple, set)): raise errors.RequesterError('allowed_openai_params must be an array') diff --git a/tests/unit_tests/provider/test_reasoning_control.py b/tests/unit_tests/provider/test_reasoning_control.py index 6e7e769cc..dabc642ad 100644 --- a/tests/unit_tests/provider/test_reasoning_control.py +++ b/tests/unit_tests/provider/test_reasoning_control.py @@ -19,6 +19,7 @@ def _runtime_model( level: str = 'provider_default', name: str = 'reasoning-model', abilities: list[str] | None = None, + requester_name: str | None = None, ) -> requester.RuntimeLLMModel: execution_context = ExecutionContext( instance_uuid='instance-test', @@ -40,7 +41,7 @@ def _runtime_model( workspace_uuid='workspace-test', uuid='provider-test', name='provider', - requester='openai', + requester=requester_name or request.requester_cfg.get('requester_name') or 'custom-requester', base_url='https://example.com', api_keys=[], ), @@ -50,8 +51,14 @@ def _runtime_model( return requester.RuntimeLLMModel(execution_context, entity, provider) -def _requester(provider: str = '') -> LiteLLMRequester: - return LiteLLMRequester(SimpleNamespace(), {'custom_llm_provider': provider}) +def _requester(provider: str = '', requester_name: str = '') -> LiteLLMRequester: + return LiteLLMRequester( + SimpleNamespace(), + { + 'custom_llm_provider': provider, + 'requester_name': requester_name, + }, + ) def test_reasoning_config_normalization_and_conflicts(): @@ -73,46 +80,35 @@ def test_reasoning_config_normalization_and_conflicts(): ['reasoning'], {'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() monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) - monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) capabilities = request.get_reasoning_capabilities(_runtime_model(request)) assert capabilities == { 'supported': True, - 'levels': ['provider_default', 'low', 'medium', 'high'], + 'levels': ['provider_default'], 'source': 'manual', } -def test_provider_protocol_exposes_reasoning_for_unknown_model(monkeypatch): - request = _requester('openai') +def test_openai_protocol_does_not_mark_unknown_models_as_reasoning(monkeypatch): + request = _requester('openai', 'openai-chat-completions') 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( _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 == { 'supported': False, '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): - request = _requester('openai') +def test_unknown_unmarked_model_without_provider_stays_safe(monkeypatch): + request = _requester() + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) - def supports_reasoning(model: str, custom_llm_provider: str | None = None) -> bool: - return model == 'openrouter/xiaomi/mimo-v2.5' + capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='unknown-model', abilities=[])) - def get_model_info(model: str) -> dict: - if model == 'openrouter/xiaomi/mimo-v2.5': - return {'supports_reasoning': True} - raise ValueError('unknown model') + assert capabilities == { + 'supported': False, + 'levels': ['provider_default'], + '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( - _runtime_model(request, name='mimo-v2.5', abilities=[]) - ) +def test_mimo_exposes_off_on_without_fake_effort_levels(monkeypatch): + 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 == { 'supported': True, - 'levels': ['provider_default', 'minimal', 'low', 'medium', 'high'], - 'source': 'litellm', + 'levels': ['provider_default', 'disabled', 'enabled'], + 'source': 'provider', } 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, @@ -172,34 +169,199 @@ def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch): ] -def test_reasoning_argument_translation(monkeypatch): - openai_request = _requester('openai') - monkeypatch.setattr(openai_request, '_supports_reasoning', lambda _: True) - monkeypatch.setattr( - openai_request, - '_safe_model_info', - lambda _: {'supports_none_reasoning_effort': True}, +def test_anthropic_adaptive_and_always_on_profiles(monkeypatch): + request = _requester('anthropic', 'anthropic-messages') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + adaptive = request.get_reasoning_capabilities(_runtime_model(request, name='claude-sonnet-4-6', abilities=[])) + assert adaptive['levels'] == [ + '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 openai_request._build_reasoning_args(_runtime_model(openai_request, 'disabled')) == { + assert mixed['levels'] == ['provider_default', 'disabled', 'enabled'] + 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' } - assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'high')) == {'reasoning_effort': 'high'} - deepseek_request = _requester('deepseek') - monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: False) - monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {}) + anthropic_request = _requester('anthropic', 'anthropic-messages') + assert anthropic_request._build_reasoning_args( + _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( - _runtime_model(deepseek_request, 'enabled', name='deepseek-chat') - ) == {'thinking': {'type': 'enabled'}} - assert deepseek_request._build_reasoning_args( - _runtime_model(deepseek_request, 'disabled', name='deepseek-chat') - ) == {'extra_body': {'thinking': {'type': 'disabled'}}} + _runtime_model(deepseek_request, 'high', name='deepseek-v4-flash') + ) == { + 'extra_body': { + 'thinking': {'type': 'enabled'}, + '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): - request = _requester('openai') + request = _requester('openai', 'openai-chat-completions') monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) 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'} -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): - deepseek_request = _requester('deepseek') + deepseek_request = _requester('deepseek', 'deepseek-chat-completions') monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {}) deepseek_capabilities = deepseek_request.get_reasoning_capabilities( _runtime_model(deepseek_request, name='deepseek-r1') ) - assert deepseek_capabilities['levels'] == ['provider_default', 'enabled'] + assert deepseek_capabilities['levels'] == ['provider_default'] gemini_request = _requester('gemini') 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')) -def test_toggle_and_effort_provider_capabilities(monkeypatch): - ollama_request = _requester('ollama') +def test_non_target_provider_capabilities_remain_supported(monkeypatch): + ollama_request = _requester('ollama', 'ollama') monkeypatch.setattr(ollama_request, '_supports_reasoning', lambda _: False) monkeypatch.setattr(ollama_request, '_safe_model_info', lambda _: {}) @@ -270,12 +422,12 @@ def test_toggle_and_effort_provider_capabilities(monkeypatch): '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, '_safe_model_info', lambda _: {}) assert volcengine_request._build_reasoning_args( _runtime_model(volcengine_request, 'disabled', name='doubao-seed') - ) == {'thinking': {'type': 'disabled'}} + ) == {'extra_body': {'thinking': {'type': 'disabled'}}} 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): - request = _requester('openai') + request = _requester('openai', 'openai-chat-completions') 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'): - request._build_reasoning_args(_runtime_model(request, 'xhigh', abilities=[])) + request._build_reasoning_args(_runtime_model(request, 'xhigh', name='gpt-5', abilities=[])) @pytest.mark.asyncio 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, '_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.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 async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypatch): - request = _requester('openai') - monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) - monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) - model = _runtime_model(request, 'high', name='deepseek-v4-flash') + request = _requester('openai', 'moonshot-chat-completions') + model = _runtime_model(request, 'high', name='kimi-k3') model.model_entity.extra_args = {'allowed_openai_params': ['custom_extension']} 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 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.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 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) model = _runtime_model(request, 'disabled', name='deepseek-chat') 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: def __init__(self, data: dict): self.data = data