From 79cac0c6a3cf869ae1408312cea0332a5ff6b09f Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 19 Sep 2026 00:05:10 +0800 Subject: [PATCH] fix(reasoning): apply explicit per-call levels without runner config caching --- pyproject.toml | 2 +- .../pkg/agent/runner/model_reasoning.py | 85 ------- src/langbot/pkg/agent/runner/orchestrator.py | 4 - .../pkg/agent/runner/session_registry.py | 4 - .../api/http/service/pipeline_migration.py | 4 - src/langbot/pkg/plugin/handler.py | 13 +- .../pkg/provider/modelmgr/reasoning.py | 31 +++ .../agent/test_runner_model_reasoning.py | 208 ++++-------------- .../plugin/test_runner_reasoning_override.py | 63 ++---- uv.lock | 4 +- .../dynamic-form/DynamicFormItemComponent.tsx | 15 +- .../models-dialog/components/ModelItem.tsx | 10 +- .../reasoning/ReasoningLevelPicker.tsx | 52 ++--- .../components/reasoning/model-reasoning.ts | 18 ++ web/src/i18n/locales/en-US.ts | 3 +- web/src/i18n/locales/es-ES.ts | 3 +- web/src/i18n/locales/ja-JP.ts | 3 +- web/src/i18n/locales/ru-RU.ts | 3 +- web/src/i18n/locales/th-TH.ts | 3 +- web/src/i18n/locales/vi-VN.ts | 3 +- web/src/i18n/locales/zh-Hans.ts | 3 +- web/src/i18n/locales/zh-Hant.ts | 3 +- .../e2e/reasoning-edit-semantics.spec.ts | 145 +++++++----- .../unit/reasoning-edit-semantics.test.mjs | 7 +- 24 files changed, 262 insertions(+), 427 deletions(-) delete mode 100644 src/langbot/pkg/agent/runner/model_reasoning.py create mode 100644 web/src/app/home/components/reasoning/model-reasoning.ts diff --git a/pyproject.toml b/pyproject.toml index c93df3414..59492ccea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,4 +235,4 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.uv.sources] -langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" } +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "ab06fd2daac8d2f9493377ffee9721a814b34e44" } diff --git a/src/langbot/pkg/agent/runner/model_reasoning.py b/src/langbot/pkg/agent/runner/model_reasoning.py deleted file mode 100644 index 1e4e83ee1..000000000 --- a/src/langbot/pkg/agent/runner/model_reasoning.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Host-owned, run-scoped reasoning policy for schema-declared model selectors. - -This policy is not an SDK resource or a provider kwarg. Only the Host binding -assembler supplies it; model actions consult it after resource authorization. -""" - -from __future__ import annotations - -import copy -import typing - -from ...provider.modelmgr.reasoning import normalize_reasoning_config, validate_reasoning_config -from .config_schema import NONE_SENTINELS, iter_schema_items -from .descriptor import RunnerDescriptor - -if typing.TYPE_CHECKING: - from ...provider.modelmgr.requester import RuntimeLLMModel - - -ModelReasoningOverrides = dict[str, dict[str, str]] - - -def extract_model_reasoning_overrides( - descriptor: RunnerDescriptor, - runner_config: dict[str, typing.Any], - resources: typing.Mapping[str, typing.Any], -) -> ModelReasoningOverrides: - """Normalize explicit UUID-to-level mappings, intersecting selection and grants. - - An absent entry preserves persisted model defaults. Explicit provider_default - overrides them using the native requester's semantics. Descriptor defaults and - undeclared config fields cannot silently introduce reasoning overrides. - """ - authorized = {model.get('model_id') for model in resources.get('models', [])} - overrides: ModelReasoningOverrides = {} - for item in iter_schema_items(descriptor, {'model-fallback-selector'}): - field_name = item.get('name') - if not isinstance(field_name, str): - continue - selection = runner_config.get(field_name) - if not isinstance(selection, dict) or 'reasoning' not in selection: - continue - configured = selection['reasoning'] - if not isinstance(configured, dict): - raise ValueError('Invalid runner model reasoning configuration') - candidates = [selection.get('primary')] - fallbacks = selection.get('fallbacks') - if isinstance(fallbacks, list): - candidates.extend(fallbacks) - selected = {value for value in candidates if isinstance(value, str) and value not in NONE_SENTINELS} - for model_id, level in configured.items(): - # Validate with Core, but do not echo arbitrary config/secret values. - try: - if not isinstance(model_id, str) or not isinstance(level, str): - raise ValueError - config = normalize_reasoning_config({'level': level}) - except (TypeError, ValueError): - raise ValueError('Invalid runner model reasoning configuration') from None - if model_id not in selected or model_id not in authorized: - continue - if model_id in overrides and overrides[model_id] != config: - raise ValueError('Conflicting runner model reasoning overrides') - overrides[model_id] = config - return overrides - - -def model_with_reasoning_override( - model: RuntimeLLMModel, - model_id: str, - session: typing.Mapping[str, typing.Any] | None, -) -> RuntimeLLMModel: - """Clone only the runtime wrapper, after the caller has authorized the model. - - Do not mutate shared model entities or providers. Requesters retain ownership - of ability/capability validation and provider-specific argument translation. - """ - if session is None: - return model - overrides = session.get('authorization', {}).get('model_reasoning_overrides', {}) - if model_id not in overrides: - return model - config = validate_reasoning_config(overrides[model_id], model.model_entity.abilities, model.model_entity.extra_args) - scoped_model = copy.copy(model) - scoped_model.reasoning_config_override = copy.deepcopy(config) - return scoped_model diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index b1e1d18ef..c2cf5f9cb 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -28,7 +28,6 @@ from .execution_context import ( ) from .host_models import AgentBinding, AgentEventEnvelope from .invoker import RunnerInvoker -from .model_reasoning import extract_model_reasoning_overrides from .interaction_manager import InteractionManager from .query_bridge import QueryRunBridge from .registry import RunnerRegistry @@ -143,7 +142,6 @@ class AgentRunOrchestrator: binding=binding, descriptor=descriptor, ) - model_reasoning_overrides = extract_model_reasoning_overrides(descriptor, binding.runner_config, resources) context = await self.context_builder.build_context_from_event( event=execution_event, @@ -209,7 +207,6 @@ class AgentRunOrchestrator: 'state_scopes': list(binding.state_policy.state_scopes), }, 'state_context': state_context, - 'model_reasoning_overrides': model_reasoning_overrides, } seen_sequences: set[int] = set() @@ -246,7 +243,6 @@ class AgentRunOrchestrator: execution_query=execution_query, platform_context=freeze_platform_context(event), reply_streams=reply_streams, - model_reasoning_overrides=model_reasoning_overrides, ) event_log_id = await self.journal.write_event_log( diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index fc12b9b40..d7e2910d8 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -13,7 +13,6 @@ import threading from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from .context_builder import AgentResources -from .model_reasoning import ModelReasoningOverrides from ...provider.tools.toolmgr import ToolSourceRef @@ -53,7 +52,6 @@ class RunAuthorizationSnapshot(typing.TypedDict): platform_context: dict[str, typing.Any] authorized_ids: dict[str, set[str]] authorized_operations: dict[str, dict[str, set[str]]] - model_reasoning_overrides: ModelReasoningOverrides SteeringQueueItem = dict[str, typing.Any] @@ -121,7 +119,6 @@ class AgentRunSessionRegistry: execution_query: pipeline_query.Query | None = None, platform_context: dict[str, typing.Any] | None = None, reply_streams: typing.Any = None, - model_reasoning_overrides: ModelReasoningOverrides | None = None, ) -> None: """Register a new agent run session. @@ -167,7 +164,6 @@ class AgentRunSessionRegistry: 'platform_context': copy.deepcopy(platform_context or {}), 'authorized_ids': self._build_authorized_ids(resources_snapshot), 'authorized_operations': self._build_authorized_operations(resources_snapshot), - 'model_reasoning_overrides': copy.deepcopy(model_reasoning_overrides or {}), } session: AgentRunSession = { diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index 734107f84..f51e5b609 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -28,7 +28,6 @@ from ....agent.runner.config_resolver import RunnerConfigResolver from ....agent.runner import config_schema from ....agent.runner.resource_builder import AgentResourceBuilder from ....agent.runner.resource_policy import ResourcePolicyProjector -from ....agent.runner.model_reasoning import extract_model_reasoning_overrides from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel from ....entity.persistence.rag import KnowledgeBase from ....entity.persistence.mcp import MCPServer @@ -706,18 +705,15 @@ class PipelineMigrationService: raise MigrationError('runner_resource_unavailable') permissions = getattr(descriptor, 'permissions', None) - model_resources = [] for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config): allowed = set(getattr(permissions, 'models', [])) if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})): raise MigrationError('runner_resource_unavailable') await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid) - model_resources.append({'model_id': model_uuid}) for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}): value = runner_config.get(field['name']) if value and value not in config_schema.NONE_SENTINELS: await require_row(EmbeddingModel, value) - extract_model_reasoning_overrides(descriptor, runner_config, {'models': model_resources}) kb_ids = runner_config.get('knowledge-bases', []) if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids): diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index a1d1cdec4..90e9e9a17 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -52,7 +52,7 @@ from ..entity.persistence import model as persistence_model from ..core import app from ..utils import constants from ..agent.runner.session_registry import get_session_registry -from ..agent.runner.model_reasoning import model_with_reasoning_override +from ..provider.modelmgr.reasoning import model_with_reasoning_level from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner import config_schema from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call @@ -1129,6 +1129,7 @@ class RuntimeConnectionHandler(handler.Handler): return handler.ActionResponse.success( data={ 'version': constants.semantic_version, + 'api_features': ['llm.reasoning_level'], }, ) @@ -1362,7 +1363,7 @@ class RuntimeConnectionHandler(handler.Handler): caller_plugin_identity = data.get('caller_plugin_identity') if run_id: - session, error = await _validate_run_authorization( + _, error = await _validate_run_authorization( run_id, 'model', llm_model_uuid, @@ -1373,8 +1374,6 @@ class RuntimeConnectionHandler(handler.Handler): ) if error: return error - else: - session = None if not await self._resource_exists( persistence_model.LLMModel, @@ -1397,7 +1396,7 @@ class RuntimeConnectionHandler(handler.Handler): if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid): return handler.ActionResponse.error(message='LLM model belongs to another Workspace') - llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) + llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level')) messages_obj = [provider_message.Message.model_validate(message) for message in messages] async def _placeholder_func(**kwargs): @@ -1479,7 +1478,7 @@ class RuntimeConnectionHandler(handler.Handler): message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', ) - llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) + llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level')) messages_obj = [provider_message.Message.model_validate(message) for message in messages] # The func field is excluded during model_dump() in plugin side (marked as exclude=True), @@ -1576,7 +1575,7 @@ class RuntimeConnectionHandler(handler.Handler): if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid): yield handler.ActionResponse.error(message='LLM model belongs to another Workspace') return - llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session) + llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level')) messages_obj = [provider_message.Message.model_validate(message) for message in messages] # The func field is excluded during model_dump() in plugin side diff --git a/src/langbot/pkg/provider/modelmgr/reasoning.py b/src/langbot/pkg/provider/modelmgr/reasoning.py index de44af01f..ecc6c38e2 100644 --- a/src/langbot/pkg/provider/modelmgr/reasoning.py +++ b/src/langbot/pkg/provider/modelmgr/reasoning.py @@ -1,6 +1,10 @@ from __future__ import annotations import typing +import copy + +if typing.TYPE_CHECKING: + from .requester import RuntimeLLMModel ReasoningLevel = typing.Literal[ @@ -123,3 +127,30 @@ def default_reasoning_capabilities( 'levels': ['provider_default'], 'source': source, } + + +def model_with_reasoning_level( + model: RuntimeLLMModel, + level: ReasoningLevel | None, +) -> RuntimeLLMModel: + """Clone only the runtime wrapper, after the caller has authorized the model. + + Do not mutate shared model entities or providers. Requesters retain ownership + of ability/capability validation and provider-specific argument translation. + """ + if level is None: + return model + abilities = set(model.model_entity.abilities or []) + # Managed Space models are read-only. Their detected capability is also what + # the model API and UI expose, even when the catalog omits the ability flag. + # Custom providers still require the user's explicit ability setting. + if ( + 'reasoning' not in abilities + and model.provider.provider_entity.requester == 'space-chat-completions' + and model.provider.requester.get_reasoning_capabilities(model).get('supported') is True + ): + abilities.add('reasoning') + config = validate_reasoning_config({'level': level}, abilities, model.model_entity.extra_args) + scoped_model = copy.copy(model) + scoped_model.reasoning_config_override = copy.deepcopy(config) + return scoped_model diff --git a/tests/unit_tests/agent/test_runner_model_reasoning.py b/tests/unit_tests/agent/test_runner_model_reasoning.py index ff365834f..bae5afec9 100644 --- a/tests/unit_tests/agent/test_runner_model_reasoning.py +++ b/tests/unit_tests/agent/test_runner_model_reasoning.py @@ -1,129 +1,26 @@ -"""Host-only, descriptor-driven Runner reasoning policy and durable snapshot tests.""" +"""Explicit model call options preserve legacy defaults and shared state.""" from __future__ import annotations -import importlib -from types import SimpleNamespace - import pytest - -from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry from langbot.pkg.provider.modelmgr import errors, reasoning from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model - PRIMARY = '00000000-0000-4000-8000-000000000011' FALLBACK = '00000000-0000-4000-8000-000000000012' OTHER = '00000000-0000-4000-8000-000000000013' -def policy(): - return importlib.import_module('langbot.pkg.agent.runner.model_reasoning') - - -def descriptor(*names): - return SimpleNamespace(config_schema=[{'name': name, 'type': 'model-fallback-selector'} for name in names]) - - -def resources(*ids): - return {'models': [{'model_id': model_id} for model_id in ids]} - - -def selection(level='high'): - return {'primary': PRIMARY, 'fallbacks': [FALLBACK], 'reasoning': {PRIMARY: level, FALLBACK: 'low'}} - - -def test_generic_descriptor_extracts_only_selected_authorized_models(): - value = selection() - value['reasoning'][OTHER] = 'max' - result = policy().extract_model_reasoning_overrides( - descriptor('arbitrary'), {'arbitrary': value}, resources(PRIMARY, FALLBACK, OTHER) - ) - assert result == {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}} - assert policy().extract_model_reasoning_overrides( - descriptor('arbitrary'), {'arbitrary': value}, resources(FALLBACK) - ) == {FALLBACK: {'level': 'low'}} - value['reasoning'][PRIMARY] = 'disabled' - assert result[PRIMARY] == {'level': 'high'} - - -@pytest.mark.parametrize('level', reasoning.REASONING_LEVELS) -def test_all_canonical_levels_use_core_normalization(level): - result = policy().extract_model_reasoning_overrides( - descriptor('models'), {'models': selection(level)}, resources(PRIMARY) - ) - assert result == {PRIMARY: reasoning.normalize_reasoning_config({'level': level})} - - -@pytest.mark.parametrize('value', ['plain-model', {}, {'primary': PRIMARY}, {'primary': PRIMARY, 'reasoning': {}}]) -def test_absent_map_does_not_create_default_override(value): - assert policy().extract_model_reasoning_overrides(descriptor('models'), {'models': value}, resources(PRIMARY)) == {} - - -def test_undeclared_fields_and_descriptor_defaults_do_not_supply_overrides(): - desc = descriptor('declared') - desc.config_schema[0]['default'] = selection() - assert policy().extract_model_reasoning_overrides(desc, {'model': selection()}, resources(PRIMARY)) == {} - - -@pytest.mark.parametrize( - 'value', [None, [], 'high', {PRIMARY: None}, {PRIMARY: {}}, {PRIMARY: 'secret-invalid-level'}, {PRIMARY: ['high']}] -) -def test_invalid_explicit_maps_fail_with_safe_error(value): - with pytest.raises(ValueError, match='Invalid runner model reasoning configuration') as exc: - policy().extract_model_reasoning_overrides( - descriptor('models'), {'models': {'primary': PRIMARY, 'reasoning': value}}, resources(PRIMARY) - ) - assert 'secret-invalid-level' not in str(exc.value) - assert PRIMARY not in str(exc.value) - - -@pytest.mark.parametrize('reverse', [False, True]) -def test_multiple_selectors_reject_conflicting_overrides_deterministically(reverse): - names = ['one', 'two'] - if reverse: - names.reverse() - config = {'one': selection('high'), 'two': selection('provider_default')} - with pytest.raises(ValueError, match='Conflicting runner model reasoning overrides'): - policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) - config['two'] = selection('high') - assert policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) == { - PRIMARY: {'level': 'high'} - } - - -@pytest.mark.asyncio -async def test_session_deepcopies_reasoning_without_granting_models(): - registry = AgentRunSessionRegistry() - overrides = {PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}} - await registry.register( - run_id='frozen', - runner_id='plugin:test/runner/main', - query_id=None, - plugin_identity='test/runner', - resources=resources(PRIMARY), - model_reasoning_overrides=overrides, - ) - overrides[PRIMARY]['level'] = 'low' - session = await registry.get('frozen') - assert session['authorization']['model_reasoning_overrides'][PRIMARY] == {'level': 'high'} - assert not registry.is_resource_allowed(session, 'model', OTHER, 'invoke') - - def test_request_local_clone_preserves_shared_model_and_absent_default(): model = _runtime_model(_requester('openai'), 'high', name='gpt-5') - assert policy().model_with_reasoning_override(model, PRIMARY, None) is model - assert policy().model_with_reasoning_override(model, PRIMARY, {'authorization': {}}) is model - overrides = {PRIMARY: {'level': 'provider_default'}} - clone = policy().model_with_reasoning_override( - model, PRIMARY, {'authorization': {'model_reasoning_overrides': overrides}} - ) + assert reasoning.model_with_reasoning_level(model, None) is model + assert reasoning.model_with_reasoning_level(model, None) is model + clone = reasoning.model_with_reasoning_level(model, 'provider_default') assert clone is not model assert clone.provider is model.provider assert clone.model_entity is model.model_entity assert clone.reasoning_config_override == {'level': 'provider_default'} clone.reasoning_config_override['level'] = 'disabled' - assert overrides[PRIMARY]['level'] == 'provider_default' assert model.reasoning_config_override is None assert model.model_entity.reasoning_config == {'level': 'high'} @@ -141,9 +38,7 @@ def test_real_requester_reasoning_boundary(provider, name, expected, monkeypatch monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) model = _runtime_model(request, 'low', name=name) - clone = policy().model_with_reasoning_override( - model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}} - ) + clone = reasoning.model_with_reasoning_level(model, 'high') assert request._build_reasoning_args(clone) == expected clone.reasoning_config_override = {'level': 'provider_default'} assert request._build_reasoning_args(clone) == {} @@ -158,58 +53,15 @@ def test_real_requester_still_rejects_ability_and_capability_mismatches(name, ab monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) model = _runtime_model(request, name=name, abilities=abilities) - session = {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}} if not abilities: with pytest.raises(ValueError, match='reasoning ability'): - policy().model_with_reasoning_override(model, PRIMARY, session) + reasoning.model_with_reasoning_level(model, level) else: - clone = policy().model_with_reasoning_override(model, PRIMARY, session) + clone = reasoning.model_with_reasoning_level(model, level) with pytest.raises(errors.RequesterError): request._build_reasoning_args(clone) -@pytest.mark.asyncio -async def test_orchestrator_freezes_host_policy_and_persistent_reload(tmp_path): - from sqlalchemy.ext.asyncio import create_async_engine - from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator - from langbot.pkg.entity.persistence.base import Base - from langbot.pkg.plugin.agent_run_support import _load_persistent_agent_run_session - from tests.unit_tests.agent.test_orchestrator_integration import ( - FakeApplication, - FakePluginConnector, - FakeRegistry, - make_descriptor, - make_query, - ) - - engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "reasoning.db"}') - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - connector = FakePluginConnector(results=[{'type': 'run.completed', 'data': {}}]) - ap = FakeApplication(connector, engine) - desc = make_descriptor() - query = make_query() - query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning'] = { - 'model_primary': 'high', - 'model_fallback': 'low', - } - expected = {'model_primary': {'level': 'high'}, 'model_fallback': {'level': 'low'}} - try: - orchestrator = AgentRunOrchestrator(ap, FakeRegistry(desc)) - _ = [value async for value in orchestrator.run_from_query(query)] - session = connector.sessions_during_run[0] - assert session['authorization']['model_reasoning_overrides'] == expected - wire = connector.contexts[0] - assert 'model_reasoning_overrides' not in wire - assert 'model_reasoning_overrides' not in wire['resources'] - query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning']['model_primary'] = 'disabled' - assert session['authorization']['model_reasoning_overrides'] == expected - restored = await _load_persistent_agent_run_session(wire['run_id'], ap, 'test') - assert restored['authorization']['model_reasoning_overrides'] == expected - finally: - await engine.dispose() - - @pytest.mark.asyncio @pytest.mark.parametrize( ('provider', 'name', 'level', 'expected'), @@ -229,9 +81,7 @@ async def test_real_completion_and_count_tokens_build_boundary(provider, name, l monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) model = _runtime_model(request, name=name) model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret' - clone = policy().model_with_reasoning_override( - model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}} - ) + clone = reasoning.model_with_reasoning_level(model, level) messages = [provider_message.Message(role='user', content='hello')] for stream in (False, True): built = await request._build_completion_args(clone, messages, extra_args={'temperature': 0.7}, stream=stream) @@ -259,10 +109,46 @@ async def test_real_requester_rejects_caller_reasoning_conflicts(monkeypatch): monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) model = _runtime_model(request, name='gpt-5') model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret' - clone = policy().model_with_reasoning_override( - model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}} - ) + clone = reasoning.model_with_reasoning_level(model, 'high') with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'): await request._build_completion_args( clone, [provider_message.Message(role='user', content='hello')], extra_args={'reasoning_effort': 'low'} ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stream', [False, True]) +async def test_space_detected_reasoning_without_catalog_flag_reaches_completion(stream, monkeypatch): + from langbot_plugin.api.entities.builtin.provider import message as provider_message + + request = _requester('openai', 'space-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, name='gpt-5.6-sol', abilities=['vision', 'func_call']) + model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret' + scoped = reasoning.model_with_reasoning_level(model, 'medium') + built = await request._build_completion_args( + scoped, [provider_message.Message(role='user', content='hello')], stream=stream + ) + assert built['reasoning_effort'] == 'medium' + assert built.get('stream', False) is stream + assert model.model_entity.abilities == ['vision', 'func_call'] + assert model.reasoning_config_override is None + + +def test_space_unknown_model_does_not_gain_reasoning(monkeypatch): + request = _requester('openai', 'space-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + model = _runtime_model(request, name='unknown-model', abilities=[]) + with pytest.raises(ValueError, match='reasoning ability'): + reasoning.model_with_reasoning_level(model, 'medium') + + +def test_space_detected_reasoning_still_validates_conflicting_parameters(monkeypatch): + request = _requester('openai', 'space-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + model = _runtime_model(request, name='gpt-5.6-sol', abilities=[]) + model.model_entity.extra_args = {'reasoning_effort': 'low'} + with pytest.raises(ValueError, match='conflicts with advanced parameters'): + reasoning.model_with_reasoning_level(model, 'medium') diff --git a/tests/unit_tests/plugin/test_runner_reasoning_override.py b/tests/unit_tests/plugin/test_runner_reasoning_override.py index 75e27c012..d0bf068dc 100644 --- a/tests/unit_tests/plugin/test_runner_reasoning_override.py +++ b/tests/unit_tests/plugin/test_runner_reasoning_override.py @@ -1,4 +1,4 @@ -"""Actual secured Host actions consume frozen policy, not plugin payload hints.""" +"""Secured Host actions accept explicit per-call options after authorization.""" from __future__ import annotations @@ -29,6 +29,7 @@ class RecordingProvider: def __init__(self, request): self.requester = request + self.provider_entity = SimpleNamespace(requester='openai') self.calls = [] async def record(self, kwargs): @@ -74,9 +75,7 @@ async def host(monkeypatch): ) runtime = make_handler(ap) - async def register( - run_id='run', overrides=None, workspace='workspace-a', plugin='test-author/test-plugin', operations=None - ): + async def register(run_id='run', workspace='workspace-a', plugin='test-author/test-plugin', operations=None): await registry.register( run_id=run_id, runner_id='plugin:test-author/test-plugin/arbitrary', @@ -89,7 +88,6 @@ async def host(monkeypatch): for model_id in (PRIMARY, FALLBACK) ] }, - model_reasoning_overrides=overrides, ) return await registry.get(run_id) @@ -114,10 +112,10 @@ async def call(host, action, model_id=PRIMARY, run_id='run', **extra): @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) -async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_policy(host, action): - await host.register(overrides={PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}) +async def test_primary_fallback_and_repeated_tool_round_use_explicit_options(host, action): + await host.register() for model_id, level in [(PRIMARY, 'high'), (FALLBACK, 'low'), (FALLBACK, 'low')]: - responses = await call(host, action, model_id) + responses = await call(host, action, model_id, reasoning_level=level) assert all(response.code == 0 for response in responses) kwargs, built = host.provider.calls[-1] assert built == {'reasoning_effort': level} @@ -132,8 +130,8 @@ async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_pol @pytest.mark.parametrize('action', ACTIONS) @pytest.mark.parametrize('level', [None, 'provider_default']) async def test_absent_and_explicit_provider_default_are_distinct(host, action, level): - await host.register(overrides={PRIMARY: {'level': level}} if level else None) - assert all(response.code == 0 for response in await call(host, action)) + await host.register() + assert all(response.code == 0 for response in await call(host, action, reasoning_level=level)) kwargs, built = host.provider.calls[-1] assert built == ({} if level else {'reasoning_effort': 'medium'}) assert (kwargs['model'] is host.models[PRIMARY]) is (level is None) @@ -158,8 +156,8 @@ async def test_regular_plugin_without_run_keeps_model_defaults_and_ignores_forge @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) -async def test_plugin_cannot_replace_host_map(host, action): - await host.register(overrides={PRIMARY: {'level': 'high'}}) +async def test_obsolete_hidden_policy_fields_do_not_override_explicit_api(host, action): + await host.register() responses = await call( host, action, @@ -167,7 +165,7 @@ async def test_plugin_cannot_replace_host_map(host, action): reasoning_config_override={'level': 'disabled'}, ) assert all(response.code == 0 for response in responses) - assert host.provider.calls[-1][1] == {'reasoning_effort': 'high'} + assert host.provider.calls[-1][1] == {'reasoning_effort': 'medium'} @pytest.mark.asyncio @@ -175,7 +173,6 @@ async def test_plugin_cannot_replace_host_map(host, action): @pytest.mark.parametrize('denial', ['workspace', 'plugin', 'unselected', 'operation', 'expired']) async def test_authorization_denial_happens_before_model_access(host, action, denial): await host.register( - overrides={PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}}, workspace='workspace-b' if denial == 'workspace' else 'workspace-a', plugin='other/plugin' if denial == 'plugin' else 'test-author/test-plugin', operations=['rerank'] if denial == 'operation' else None, @@ -191,9 +188,14 @@ async def test_authorization_denial_happens_before_model_access(host, action, de @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(host, action): - await host.register('high-run', {PRIMARY: {'level': 'high'}}) - await host.register('low-run', {PRIMARY: {'level': 'low'}}) - results = await asyncio.gather(*(call(host, action, run_id=run_id) for run_id in ['high-run', 'low-run'] * 3)) + await host.register('high-run') + await host.register('low-run') + results = await asyncio.gather( + *( + call(host, action, run_id=run_id, reasoning_level=run_id.split('-')[0]) + for run_id in ['high-run', 'low-run'] * 3 + ) + ) assert all(response.code == 0 for result in results for response in result) assert sorted(built['reasoning_effort'] for _, built in host.provider.calls) == ['high'] * 3 + ['low'] * 3 assert len({id(kwargs['model']) for kwargs, _ in host.provider.calls}) == 6 @@ -203,7 +205,7 @@ async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(ho @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) async def test_model_runtime_workspace_mismatch_denies(host, action): - await host.register(overrides={PRIMARY: {'level': 'high'}}) + await host.register() host.models[PRIMARY].model_entity.workspace_uuid = 'workspace-b' responses = await call(host, action) assert all(response.code != 0 for response in responses) @@ -213,33 +215,16 @@ async def test_model_runtime_workspace_mismatch_denies(host, action): @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) async def test_host_reuses_core_ability_validation_before_provider_call(host, action): - await host.register(overrides={PRIMARY: {'level': 'high'}}) + await host.register() host.models[PRIMARY].model_entity.abilities = [] with pytest.raises(ValueError, match='reasoning ability'): - await call(host, action) + await call(host, action, reasoning_level='high') assert not host.provider.calls @pytest.mark.asyncio @pytest.mark.parametrize('action', ACTIONS) -async def test_snapshot_survives_configuration_edits_and_tool_followup(host, action): - config = {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}} - await host.register(overrides=config) - config[PRIMARY]['level'] = 'disabled' - config[FALLBACK]['level'] = 'max' - responses = await call( - host, - action, - FALLBACK, - messages=[ - {'role': 'user', 'content': 'search'}, - { - 'role': 'assistant', - 'content': '', - 'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}], - }, - {'role': 'tool', 'content': 'search result', 'tool_call_id': 'call-1'}, - ], - ) +async def test_regular_plugins_can_explicitly_set_level_without_runner_session(host, action): + responses = await call(host, action, run_id=None, reasoning_level='low') assert all(response.code == 0 for response in responses) assert host.provider.calls[-1][1] == {'reasoning_effort': 'low'} diff --git a/uv.lock b/uv.lock index 102b33527..e30722849 100644 --- a/uv.lock +++ b/uv.lock @@ -2180,7 +2180,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=ab06fd2daac8d2f9493377ffee9721a814b34e44" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2251,7 +2251,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.6.0b2" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9#a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=ab06fd2daac8d2f9493377ffee9721a814b34e44#ab06fd2daac8d2f9493377ffee9721a814b34e44" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index dc47a7f30..b74c9f8e7 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -1,3 +1,4 @@ +import { hasModelReasoningAbility } from '@/app/home/components/reasoning/model-reasoning'; import { DynamicFormItemType, IDynamicFormItemSchema, @@ -193,8 +194,7 @@ export default function DynamicFormItemComponent({ {model.abilities?.includes('func_call') && ( )} - {(model.reasoning_capabilities?.supported === true || - model.abilities?.includes('reasoning')) && ( + {hasModelReasoningAbility(model) && ( { if (!modelUuid) return; const updated = { ...modelValue.reasoning }; - if (level === undefined) { - delete updated[modelUuid]; - } else { - updated[modelUuid] = level; - } + updated[modelUuid] = level; updateValue({ reasoning: updated }); }; @@ -1297,6 +1293,7 @@ export default function DynamicFormItemComponent({ const model = llmModels.find( (candidate) => candidate.uuid === modelUuid, ); + if (!hasModelReasoningAbility(model)) return null; const currentLevel = modelValue.reasoning[modelUuid] || 'provider_default'; const availableLevels = model?.reasoning_capabilities?.levels || [ @@ -1310,8 +1307,6 @@ export default function DynamicFormItemComponent({ updateModelReasoning(modelUuid, undefined)} onChange={(level) => updateModelReasoning(modelUuid, level)} /> ); diff --git a/web/src/app/home/components/models-dialog/components/ModelItem.tsx b/web/src/app/home/components/models-dialog/components/ModelItem.tsx index 469f193cf..c8c25bde1 100644 --- a/web/src/app/home/components/models-dialog/components/ModelItem.tsx +++ b/web/src/app/home/components/models-dialog/components/ModelItem.tsx @@ -1,3 +1,4 @@ +import { hasModelReasoningAbility } from '../../reasoning/model-reasoning'; import { useState, useEffect } from 'react'; import { Trash2, Eye, Wrench, Check, BrainCircuit } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -175,8 +176,7 @@ export default function ModelItem({ const supportsReasoning = modelType === 'llm' && - ((model as LLMModel).reasoning_capabilities?.supported === true || - (model as LLMModel).abilities?.includes('reasoning')); + hasModelReasoningAbility(model as LLMModel, isLangBotModels); const canSaveModel = !isLangBotModels; // Check if popover should be disabled (space models when not logged in) @@ -353,7 +353,11 @@ export default function ModelItem({
toggleAbility('reasoning', checked as boolean) diff --git a/web/src/app/home/components/reasoning/ReasoningLevelPicker.tsx b/web/src/app/home/components/reasoning/ReasoningLevelPicker.tsx index 852e690c6..04efc4bfd 100644 --- a/web/src/app/home/components/reasoning/ReasoningLevelPicker.tsx +++ b/web/src/app/home/components/reasoning/ReasoningLevelPicker.tsx @@ -37,8 +37,6 @@ interface ReasoningLevelPickerProps { value: ReasoningLevel; levels: ReasoningLevel[]; disabled?: boolean; - inherited?: boolean; - onInherit?: () => void; onChange: (value: ReasoningLevel) => void; } @@ -46,8 +44,6 @@ export default function ReasoningLevelPicker({ value, levels, disabled = false, - inherited = false, - onInherit, onChange, }: ReasoningLevelPickerProps) { const { t } = useTranslation(); @@ -56,14 +52,8 @@ export default function ReasoningLevelPicker({ const safeValue: ReasoningLevel = safeLevels.includes(value) ? value : safeLevels[0]; - const isInherited = Boolean(onInherit && inherited); - const currentLabel = t( - isInherited - ? 'models.reasoningLevels.useModelSetting' - : REASONING_LEVEL_LABEL_KEYS[safeValue], - ); - const isExplicit = - !isInherited && (Boolean(onInherit) || safeValue !== 'provider_default'); + const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]); + const isExplicit = safeValue !== 'provider_default'; const currentIndex = Math.max(0, safeLevels.indexOf(safeValue)); return ( @@ -73,9 +63,9 @@ export default function ReasoningLevelPicker({ type="button" variant="outline" size="sm" - disabled={disabled || (safeLevels.length <= 1 && !onInherit)} + disabled={disabled || safeLevels.length <= 1} aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`} - className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36" + className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-52" > - {onInherit && ( -
- - {safeLevels.includes('provider_default') && ( - - )} -
+ {safeLevels.includes('provider_default') && ( + )}
{currentLabel} diff --git a/web/src/app/home/components/reasoning/model-reasoning.ts b/web/src/app/home/components/reasoning/model-reasoning.ts new file mode 100644 index 000000000..56715da00 --- /dev/null +++ b/web/src/app/home/components/reasoning/model-reasoning.ts @@ -0,0 +1,18 @@ +import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../models-dialog/types'; + +interface ModelReasoningInfo { + abilities?: string[]; + reasoning_capabilities?: { supported?: boolean }; + provider?: { requester?: string }; +} + +export function hasModelReasoningAbility( + model: ModelReasoningInfo | undefined, + isLangBotModels = model?.provider?.requester === + LANGBOT_MODELS_PROVIDER_REQUESTER, +): boolean { + return Boolean( + model?.abilities?.includes('reasoning') || + (isLangBotModels && model?.reasoning_capabilities?.supported === true), + ); +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index d7645a125..343a06209 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -264,8 +264,7 @@ const enUS = { reasoningAbility: 'Reasoning', reasoningLevel: 'Reasoning level', reasoningLevels: { - useModelSetting: 'Use model setting', - providerDefault: 'Provider default', + providerDefault: 'Use provider default', disabled: 'Off', enabled: 'On', minimal: 'Minimal', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index fae6ed0a6..d393f25ab 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -265,8 +265,7 @@ const esES = { reasoningAbility: 'Razonamiento', reasoningLevel: 'Nivel de razonamiento', reasoningLevels: { - useModelSetting: 'Usar ajuste del modelo', - providerDefault: 'Predeterminado del proveedor', + providerDefault: 'Usar valor predeterminado del proveedor', disabled: 'Desactivado', enabled: 'Activado', minimal: 'Mínimo', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 02e729fc2..874996391 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -269,8 +269,7 @@ const jaJP = { reasoningAbility: '推論', reasoningLevel: '推論レベル', reasoningLevels: { - useModelSetting: 'モデル設定を使用', - providerDefault: 'Provider デフォルト', + providerDefault: 'プロバイダーの既定値を使用', disabled: 'オフ', enabled: 'オン', minimal: '最小', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 5c0ae0394..0329ba233 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -261,8 +261,7 @@ const ruRU = { reasoningAbility: 'Рассуждение', reasoningLevel: 'Уровень рассуждений', reasoningLevels: { - useModelSetting: 'Настройка модели', - providerDefault: 'По умолчанию провайдера', + providerDefault: 'Использовать настройки поставщика', disabled: 'Выключено', enabled: 'Включено', minimal: 'Минимальный', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 9d58e6053..fc5a55b8f 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -256,8 +256,7 @@ const thTH = { reasoningAbility: 'ความสามารถในการให้เหตุผล', reasoningLevel: 'ระดับการให้เหตุผล', reasoningLevels: { - useModelSetting: 'ใช้การตั้งค่าโมเดล', - providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ', + providerDefault: 'ใช้ค่าเริ่มต้นของผู้ให้บริการ', disabled: 'ปิด', enabled: 'เปิด', minimal: 'ต่ำสุด', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 21f277c9b..e922e7197 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -260,8 +260,7 @@ const viVN = { reasoningAbility: 'Khả năng suy luận', reasoningLevel: 'Mức độ suy luận', reasoningLevels: { - useModelSetting: 'Dùng cài đặt mô hình', - providerDefault: 'Mặc định của nhà cung cấp', + providerDefault: 'Dùng giá trị mặc định của nhà cung cấp', disabled: 'Tắt', enabled: 'Bật', minimal: 'Tối thiểu', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1ca3b448e..eaa767481 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -254,8 +254,7 @@ const zhHans = { reasoningAbility: '思考能力', reasoningLevel: '思考档位', reasoningLevels: { - useModelSetting: '使用模型设置', - providerDefault: 'Provider 默认', + providerDefault: '使用供应商默认值', disabled: '关闭', enabled: '开启', minimal: '最低', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 4203387d6..246eab410 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -248,8 +248,7 @@ const zhHant = { reasoningAbility: '思考能力', reasoningLevel: '思考等級', reasoningLevels: { - useModelSetting: '使用模型設定', - providerDefault: '供應商預設', + providerDefault: '使用供應商預設值', disabled: '關閉', enabled: '開啟', minimal: '最低', diff --git a/web/tests/e2e/reasoning-edit-semantics.spec.ts b/web/tests/e2e/reasoning-edit-semantics.spec.ts index 98fe489f8..738137291 100644 --- a/web/tests/e2e/reasoning-edit-semantics.spec.ts +++ b/web/tests/e2e/reasoning-edit-semantics.spec.ts @@ -14,6 +14,7 @@ async function setup( name = 'WeKnoraAgent', agentId: null | 'absent' = null, converted?: typeof canonical, + reasoningEnabled = true, ) { const schema = contract.plugins[plugin].component; const id = `plugin:langbot-team/${name}/default`; @@ -127,8 +128,10 @@ async function setup( name: uuid, provider_uuid: 'provider-valid', provider: { uuid: 'provider-valid', name: 'Mock Provider' }, + abilities: reasoningEnabled ? ['reasoning'] : [], reasoning: { level: 'high' }, reasoning_capabilities: { + supported: true, levels: ['provider_default', 'low', 'high'], }, })), @@ -141,14 +144,19 @@ async function setup( return { writes, raw, id }; } -test('reasoning edit distinguishes provider default from model inheritance and preserves fallbacks', async ({ +test('explicit reasoning choices persist independently for primary and fallback models', async ({ page, }) => { const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent'); await page .getByRole('button', { name: 'Reasoning level: High', exact: true }) .click(); - await page.getByRole('slider').press('Home'); + await expect( + page.getByRole('button', { name: 'Use model setting', exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Use provider default', exact: true }) + .click(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect.poll(() => writes.length).toBe(1); @@ -161,67 +169,37 @@ test('reasoning edit distinguishes provider default from model inheritance and p }); await page.reload(); await page.getByRole('tab', { name: 'AI', exact: true }).click(); - await page - .getByRole('button', { - name: 'Reasoning level: Provider default', - exact: true, - }) - .click(); - await page - .getByRole('button', { name: 'Use model setting', exact: true }) - .click(); - await page.keyboard.press('Escape'); - await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect.poll(() => writes.length).toBe(2); - expect(writes[1].config.ai.runner_config[id]).toEqual({ - ...raw, - model: { ...(raw.model as object), reasoning: { 'llm-fallback': 'low' } }, - }); - expect(writes[1].config.output).toEqual(writes[0].config.output); - await page.reload(); - await page.getByRole('tab', { name: 'AI', exact: true }).click(); await expect( page.getByRole('button', { - name: 'Reasoning level: Use model setting', + name: 'Reasoning level: Use provider default', exact: true, }), ).toBeVisible(); await expect( page.getByRole('button', { name: 'Save', exact: true }), ).toBeDisabled(); - // The reverse transition must not require moving through another level. - await page - .getByRole('button', { - name: 'Reasoning level: Use model setting', - exact: true, - }) - .click(); - await page - .getByRole('button', { name: 'Provider default', exact: true }) - .click(); - await page.keyboard.press('Escape'); - await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect.poll(() => writes.length).toBe(3); - expect(writes[2].config.ai.runner_config[id]).toEqual( - writes[0].config.ai.runner_config[id], - ); - // Clearing a fallback override must not clear the primary override. await page .getByRole('button', { name: 'Reasoning level: Low', exact: true }) .click(); - await page - .getByRole('button', { name: 'Use model setting', exact: true }) - .click(); + await page.getByRole('slider').press('End'); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect.poll(() => writes.length).toBe(4); - expect(writes[3].config.ai.runner_config[id]).toEqual({ - ...raw, - model: { - ...(raw.model as object), - reasoning: { 'llm-valid': 'provider_default' }, - }, + await expect.poll(() => writes.length).toBe(2); + expect(writes[1].config.ai.runner_config[id].model.reasoning).toEqual({ + 'llm-valid': 'provider_default', + 'llm-fallback': 'high', }); + expect(writes[1].config.output).toEqual(writes[0].config.output); +}); + +test('custom models without reasoning enabled hide budget controls despite inferred support', async ({ + page, +}) => { + await setup(page, 'LocalAgent', 'LocalAgent', null, undefined, false); + await expect(page.getByRole('combobox').first()).toBeVisible(); + await expect( + page.getByRole('button', { name: /^Reasoning level:/ }), + ).toHaveCount(0); }); test('standalone picker keeps its original levels and provider default API', async ({ @@ -267,7 +245,7 @@ test('standalone picker keeps its original levels and provider default API', asy }); await page .getByRole('button', { - name: 'Reasoning level: Provider default', + name: 'Reasoning level: Use provider default', exact: true, }) .click(); @@ -285,3 +263,70 @@ test('standalone picker keeps its original levels and provider default API', asy 'provider_default', ); }); + +test('LangBot Models reasoning checkbox matches the capability icon', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/user/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + account_uuid: 'account-playwright', + user: 'admin@example.com', + account_type: 'space', + has_password: true, + }, + }, + }), + ); + const provider = { + uuid: 'space-provider', + name: 'LangBot Models', + requester: 'space-chat-completions', + base_url: '', + api_keys: [], + llm_count: 1, + embedding_count: 0, + rerank_count: 0, + }; + await page.route('**/api/v1/provider/**', (route) => { + const path = new URL(route.request().url()).pathname; + const ok = (data: unknown) => route.fulfill({ json: { code: 0, data } }); + if (path.endsWith('/providers')) return ok({ providers: [provider] }); + if (path.endsWith('/requesters')) return ok({ requesters: [] }); + if (path.includes('/models/')) + return ok({ + models: path.endsWith('/llm') + ? [ + { + uuid: 'space-reasoning', + name: 'Space reasoning model', + provider_uuid: provider.uuid, + provider, + abilities: ['vision', 'func_call'], + extra_args: {}, + reasoning_capabilities: { + supported: true, + levels: ['provider_default', 'low', 'high'], + }, + }, + ] + : [], + }); + return ok({ provider }); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'LangBot Models' }); + await card.getByText('Space reasoning model', { exact: true }).click(); + const checkbox = page.getByRole('checkbox', { + name: 'Reasoning', + exact: true, + }); + await expect(checkbox).toBeChecked(); + await expect(checkbox).toBeDisabled(); +}); diff --git a/web/tests/unit/reasoning-edit-semantics.test.mjs b/web/tests/unit/reasoning-edit-semantics.test.mjs index 622e18e97..c44e93e22 100644 --- a/web/tests/unit/reasoning-edit-semantics.test.mjs +++ b/web/tests/unit/reasoning-edit-semantics.test.mjs @@ -33,10 +33,9 @@ test('provider default is an explicit override, not inheritance', () => { b: 'low', }); }); -test('inherit deletes only the selected override without mutating input', () => { +test('changing one model preserves other overrides without mutating input', () => { const input = Object.freeze({ a: 'provider_default', b: 'high' }); - const output = edit(input, 'a', undefined); - assert.equal(Object.hasOwn(output, 'a'), false); - assert.deepEqual(output, { b: 'high' }); + const output = edit(input, 'a', 'low'); + assert.deepEqual(output, { a: 'low', b: 'high' }); assert.deepEqual(input, { a: 'provider_default', b: 'high' }); });