From d6f01adb8a78ac1968fe454b6f04555c93e1cb13 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Thu, 6 Aug 2026 01:00:34 +0800 Subject: [PATCH] fix(provider): map qwen reasoning levels to budgets --- .../pkg/provider/modelmgr/reasoning.py | 7 ++- .../modelmgr/requesters/litellmchat.py | 27 +++++++++++- .../provider/test_reasoning_control.py | 43 ++++++++++++++++++- web/src/app/infra/entities/api/index.ts | 1 + 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/langbot/pkg/provider/modelmgr/reasoning.py b/src/langbot/pkg/provider/modelmgr/reasoning.py index 3de7536cc..daae55e9c 100644 --- a/src/langbot/pkg/provider/modelmgr/reasoning.py +++ b/src/langbot/pkg/provider/modelmgr/reasoning.py @@ -104,7 +104,12 @@ def validate_reasoning_capabilities( available_levels = capabilities.get('levels') if not isinstance(available_levels, list): available_levels = [] - if capabilities.get('supported') is not True or level not in available_levels: + legacy_levels = capabilities.get('legacy_levels') + if not isinstance(legacy_levels, list): + legacy_levels = [] + if capabilities.get('supported') is not True or ( + level not in available_levels and level not in legacy_levels + ): available_text = ', '.join(str(item) for item in available_levels) or 'provider_default' raise ValueError( f'Reasoning level "{level}" is not supported by model {model_name}. Available levels: {available_text}' diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 5fc7b8834..002027f0b 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -170,6 +170,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 'qwen3.7-max-2026-05-17', } ) + _QWEN_REASONING_BUDGETS = { + 'low': 1024, + 'medium': 4096, + 'high': 8192, + } _INFERRED_EFFORT_PROVIDERS = frozenset( { 'anthropic', @@ -448,6 +453,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester): or '-thinking' in normalized_name ) + @staticmethod + def _supports_qwen_thinking_budget(model_name: str) -> bool: + """Return whether the documented Qwen3 family supports thinking_budget.""" + normalized_name = model_name.lower().rsplit('/', 1)[-1] + return normalized_name.startswith('qwen3') + def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None: normalized_name = model_name.lower().rsplit('/', 1)[-1] @@ -470,7 +481,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if family == 'qwen' and normalized_name.startswith(('qwen-', 'qwen3', 'qwq')): if self._is_dedicated_qwen_thinking_model(normalized_name): + if self._supports_qwen_thinking_budget(normalized_name): + return ['provider_default', 'low', 'medium', 'high'] return ['provider_default'] + if self._supports_qwen_thinking_budget(normalized_name): + return ['provider_default', 'disabled', 'low', 'medium', 'high'] return ['provider_default', 'disabled', 'enabled'] if family == 'doubao' and normalized_name.startswith(('doubao-', 'seed-')): @@ -570,11 +585,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester): else: levels = ['provider_default'] - return { + capabilities = { 'supported': True, 'levels': list(dict.fromkeys(levels)), 'source': 'litellm' if detected else ('provider' if known_levels is not None else 'manual'), } + if family == 'qwen' and 'disabled' in capabilities['levels'] and 'enabled' not in capabilities['levels']: + capabilities['legacy_levels'] = ['enabled'] + return capabilities def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]: level = self._reasoning_level(model) @@ -605,6 +623,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if family == 'qwen': return {'extra_body': {'enable_thinking': True}} return {'reasoning_effort': 'low'} + if family == 'qwen' and level in self._QWEN_REASONING_BUDGETS: + return { + 'extra_body': { + 'enable_thinking': True, + 'thinking_budget': self._QWEN_REASONING_BUDGETS[level], + } + } if family == 'deepseek': return { 'extra_body': { diff --git a/tests/unit_tests/provider/test_reasoning_control.py b/tests/unit_tests/provider/test_reasoning_control.py index 2281037b1..35056a86c 100644 --- a/tests/unit_tests/provider/test_reasoning_control.py +++ b/tests/unit_tests/provider/test_reasoning_control.py @@ -234,7 +234,46 @@ def test_qwen_mixed_and_dedicated_thinking_profiles(monkeypatch): ) assert mixed['levels'] == ['provider_default', 'disabled', 'enabled'] - assert dedicated['levels'] == ['provider_default'] + assert dedicated['levels'] == ['provider_default', 'low', 'medium', 'high'] + + +def test_qwen3_exposes_budget_based_reasoning_levels(monkeypatch): + request = _requester('openai', 'bailian-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + mixed = request.get_reasoning_capabilities(_runtime_model(request, name='qwen3.8-max', abilities=[])) + dedicated = request.get_reasoning_capabilities( + _runtime_model(request, name='qwen3.7-max-preview', abilities=[]) + ) + + assert mixed['levels'] == ['provider_default', 'disabled', 'low', 'medium', 'high'] + assert mixed['legacy_levels'] == ['enabled'] + assert dedicated['levels'] == ['provider_default', 'low', 'medium', 'high'] + + +def test_qwen3_legacy_enabled_config_remains_supported(monkeypatch): + request = _requester('openai', 'bailian-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + assert request._build_reasoning_args(_runtime_model(request, 'enabled', name='qwen3.8-max')) == { + 'extra_body': {'enable_thinking': True} + } + + +@pytest.mark.parametrize( + ('level', 'budget'), + [('low', 1024), ('medium', 4096), ('high', 8192)], +) +def test_qwen3_reasoning_levels_translate_to_thinking_budget(level, budget, monkeypatch): + request = _requester('openai', 'bailian-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + assert request._build_reasoning_args(_runtime_model(request, level, name='qwen3.8-max')) == { + 'extra_body': { + 'enable_thinking': True, + 'thinking_budget': budget, + } + } @pytest.mark.parametrize('model_name', ['qwen3.7-max-preview', 'qwen3.7-max-2026-05-17']) @@ -244,7 +283,7 @@ def test_qwen_dedicated_thinking_release_models_are_not_toggleable(model_name, m capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[])) - assert capabilities['levels'] == ['provider_default'] + assert capabilities['levels'] == ['provider_default', 'low', 'medium', 'high'] @pytest.mark.parametrize( diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 0d15f19fc..a9f84e025 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -122,6 +122,7 @@ export interface ReasoningConfig { export interface ReasoningCapabilities { supported: boolean; levels: ReasoningLevel[]; + legacy_levels?: ReasoningLevel[]; source: 'litellm' | 'provider' | 'manual' | 'unknown'; }