From 4bd899e77b5d925bb65fdc78303784486825c8db Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 7 Aug 2026 11:26:06 +0800 Subject: [PATCH 1/7] fix(cloud): track workspace membership provenance (#2406) * fix(cloud): converge legacy revision-zero members * fix(cloud): track workspace membership provenance * test(persistence): track current migration head --------- Co-authored-by: Chan --- src/langbot/pkg/cloud/directory_projection.py | 12 +- .../pkg/entity/persistence/workspace.py | 14 ++ .../0020_workspace_membership_source.py | 49 +++++++ src/langbot/pkg/workspace/collaboration.py | 3 + src/langbot/pkg/workspace/service.py | 3 + .../test_membership_source_migration.py | 70 +++++++++ .../persistence/test_migrations.py | 2 +- .../cloud/test_directory_projection.py | 138 +++++++++++++++--- .../workspace/test_workspace_collaboration.py | 1 + .../workspace/test_workspace_service.py | 27 ++++ 10 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0020_workspace_membership_source.py create mode 100644 tests/integration/persistence/test_membership_source_migration.py diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py index 46d06f153..47fad19fd 100644 --- a/src/langbot/pkg/cloud/directory_projection.py +++ b/src/langbot/pkg/cloud/directory_projection.py @@ -15,6 +15,7 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc from ..entity.persistence.user import AccountSource, AccountStatus, User from ..entity.persistence.workspace import ( MembershipRole, + MembershipSource, MembershipStatus, Workspace, WorkspaceExecutionSource, @@ -886,15 +887,15 @@ class DirectoryProjectionService: account_uuid=member.account_uuid, role=role, status=status, + source=MembershipSource.CLOUD_PROJECTION.value, joined_at=joined_at, projection_revision=member.projection_revision, ) ) continue - if membership.projection_revision == 0: - # Revision zero is Core-owned collaboration state. Directory - # projection seeds memberships, but must not overwrite later - # invitation, role, or removal decisions made by Core. + if membership.source != MembershipSource.CLOUD_PROJECTION.value: + # Core-owned collaboration state is never adopted based on + # account provenance, revision, or matching account identity. continue if membership.uuid != member.membership_uuid: raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account') @@ -906,11 +907,12 @@ class DirectoryProjectionService: raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents') membership.role = role membership.status = status + membership.source = MembershipSource.CLOUD_PROJECTION.value membership.joined_at = joined_at membership.projection_revision = member.projection_revision for account_uuid, membership in existing.items(): - if account_uuid not in included_accounts and membership.projection_revision != 0: + if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value: membership.status = MembershipStatus.REMOVED.value membership.projection_revision = max( int(membership.projection_revision), diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py index 822c8e9d4..d088ae7d6 100644 --- a/src/langbot/pkg/entity/persistence/workspace.py +++ b/src/langbot/pkg/entity/persistence/workspace.py @@ -40,6 +40,11 @@ class MembershipStatus(enum.StrEnum): REMOVED = 'removed' +class MembershipSource(enum.StrEnum): + LOCAL = 'local' + CLOUD_PROJECTION = 'cloud_projection' + + class InvitationStatus(enum.StrEnum): PENDING = 'pending' ACCEPTED = 'accepted' @@ -151,6 +156,11 @@ class WorkspaceMembership(Base): nullable=True, ) joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + source = sqlalchemy.Column( + sqlalchemy.String(32), + nullable=False, + server_default=MembershipSource.LOCAL.value, + ) projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( @@ -178,6 +188,10 @@ class WorkspaceMembership(Base): "status IN ('active', 'disabled', 'removed')", name='ck_workspace_memberships_status', ), + sqlalchemy.CheckConstraint( + "source IN ('local', 'cloud_projection')", + name='ck_workspace_memberships_source', + ), ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0020_workspace_membership_source.py b/src/langbot/pkg/persistence/alembic/versions/0020_workspace_membership_source.py new file mode 100644 index 000000000..03db6a72a --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0020_workspace_membership_source.py @@ -0,0 +1,49 @@ +"""add explicit Workspace membership source + +Revision ID: 0020_membership_source +Revises: 001a_pgvector_dimension_3072 +Create Date: 2026-08-06 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0020_membership_source' +down_revision = '001a_pgvector_dimension_3072' +branch_labels = None +depends_on = None + +_CONSTRAINT_NAME = 'ck_workspace_memberships_source' + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}: + return + + # No durable historical field distinguishes Directory-created revision-zero + # rows from Core invitations. Protect every existing row; production can + # reclassify separately after UUIDs have been verified against Space. + with op.batch_alter_table('workspace_memberships') as batch_op: + batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local')) + batch_op.create_check_constraint( + _CONSTRAINT_NAME, + "source IN ('local', 'cloud_projection')", + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'workspace_memberships' not in inspector.get_table_names(): + return + if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}: + return + with op.batch_alter_table('workspace_memberships') as batch_op: + batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check') + batch_op.drop_column('source') diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index 5d88935bf..c66ac3866 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -17,6 +17,7 @@ from ..entity.persistence.user import AccountStatus, User from ..entity.persistence.workspace import ( InvitationStatus, MembershipRole, + MembershipSource, MembershipStatus, Workspace, WorkspaceInvitation, @@ -483,6 +484,7 @@ class WorkspaceCollaborationService: account_uuid=account_uuid, role=invitation.role, status=MembershipStatus.ACTIVE.value, + source=MembershipSource.LOCAL.value, invited_by_account_uuid=invitation.created_by_account_uuid, joined_at=now, projection_revision=0, @@ -491,6 +493,7 @@ class WorkspaceCollaborationService: elif membership.status != MembershipStatus.ACTIVE.value: membership.role = invitation.role membership.status = MembershipStatus.ACTIVE.value + membership.source = MembershipSource.LOCAL.value membership.invited_by_account_uuid = invitation.created_by_account_uuid membership.joined_at = now diff --git a/src/langbot/pkg/workspace/service.py b/src/langbot/pkg/workspace/service.py index 96439b2af..c20fdc228 100644 --- a/src/langbot/pkg/workspace/service.py +++ b/src/langbot/pkg/workspace/service.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ..entity.persistence.workspace import ( MembershipRole, + MembershipSource, MembershipStatus, Workspace, WorkspaceExecutionSource, @@ -451,6 +452,7 @@ class WorkspaceService: account_uuid=account_uuid, role=MembershipRole.OWNER.value, status=MembershipStatus.ACTIVE.value, + source=MembershipSource.LOCAL.value, joined_at=joined_at, projection_revision=0, ) @@ -458,6 +460,7 @@ class WorkspaceService: else: membership.role = MembershipRole.OWNER.value membership.status = MembershipStatus.ACTIVE.value + membership.source = MembershipSource.LOCAL.value membership.joined_at = membership.joined_at or joined_at if workspace.created_by_account_uuid is None: diff --git a/tests/integration/persistence/test_membership_source_migration.py b/tests/integration/persistence/test_membership_source_migration.py new file mode 100644 index 000000000..f72c7a1b4 --- /dev/null +++ b/tests/integration/persistence/test_membership_source_migration.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade + + +@pytest.mark.asyncio +async def test_membership_source_migration_backfills_existing_rows_as_local_and_enforces_constraint(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "membership-source.db"}') + try: + async with engine.begin() as connection: + await connection.execute( + sa.text( + """ + CREATE TABLE workspace_memberships ( + uuid VARCHAR(36) PRIMARY KEY, + workspace_uuid VARCHAR(36) NOT NULL, + account_uuid VARCHAR(36) NOT NULL, + role VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + projection_revision BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + await connection.execute( + sa.text( + """ + INSERT INTO workspace_memberships + (uuid, workspace_uuid, account_uuid, role, status, projection_revision) + VALUES + ('00000000-0000-4000-8000-000000000001', 'workspace', 'local-account', + 'viewer', 'active', 0), + ('00000000-0000-4000-8000-000000000002', 'workspace', 'cloud-account', + 'viewer', 'active', 0) + """ + ) + ) + + await run_alembic_stamp(engine, '0019_single_workspace_owner') + await run_alembic_upgrade(engine, 'head') + + async with engine.connect() as connection: + rows = ( + await connection.execute(sa.text('SELECT uuid, source FROM workspace_memberships ORDER BY uuid')) + ).all() + columns = await connection.run_sync( + lambda sync_connection: { + column['name']: column + for column in sa.inspect(sync_connection).get_columns('workspace_memberships') + } + ) + assert rows == [ + ('00000000-0000-4000-8000-000000000001', 'local'), + ('00000000-0000-4000-8000-000000000002', 'local'), + ] + assert columns['source']['nullable'] is False + + with pytest.raises(sa.exc.IntegrityError): + async with engine.begin() as connection: + await connection.execute( + sa.text("UPDATE workspace_memberships SET source = 'guessed-from-user-source'") + ) + finally: + await engine.dispose() diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 6e2b723ff..e7106f939 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -105,7 +105,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '001a_pgvector_dimension_3072' + assert _get_script_head() == '0020_membership_source' @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 0256ae595..69619a109 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -1056,7 +1056,7 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context): await service.initialize() -async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context): +async def test_directory_revision_zero_membership_is_adopted(projection_context): application, session_factory = projection_context service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) await service.initialize() @@ -1067,29 +1067,125 @@ async def test_core_owned_membership_survives_directory_updates_and_omission(pro membership.role = 'viewer' membership.status = 'active' membership.projection_revision = 0 - session.add( - WorkspaceMembership( - uuid=SECOND_MEMBERSHIP_UUID, - workspace_uuid=WORKSPACE_UUID, - account_uuid='20000000-0000-0000-0000-000000000099', - role='viewer', - status='active', - joined_at=membership.joined_at, - projection_revision=0, - ) - ) projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'}) projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)}) await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace])) async with session_factory() as session: - memberships = { - membership.uuid: membership - for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all() - } - assert memberships[MEMBERSHIP_UUID].role == 'viewer' - assert memberships[MEMBERSHIP_UUID].status == 'active' - assert memberships[MEMBERSHIP_UUID].projection_revision == 0 - assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active' - assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0 + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + assert membership.source == 'cloud_projection' + assert membership.role == 'owner' + assert membership.status == 'removed' + assert membership.projection_revision == 2 + + +async def test_directory_revision_zero_membership_omitted_from_snapshot_is_removed(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + historical_account_uuid = '20000000-0000-0000-0000-000000000099' + async with session_factory() as session: + async with session.begin(): + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + session.add( + User( + uuid=historical_account_uuid, + user='Historical Space Member', + normalized_email='historical@example.com', + password='', + status='active', + source='cloud_projection', + projection_revision=1, + account_type='space', + space_account_uuid=historical_account_uuid, + ) + ) + session.add( + WorkspaceMembership( + uuid=SECOND_MEMBERSHIP_UUID, + workspace_uuid=WORKSPACE_UUID, + account_uuid=historical_account_uuid, + role='viewer', + status='active', + source='cloud_projection', + joined_at=membership.joined_at, + projection_revision=0, + ) + ) + + await service.apply_snapshot(_snapshot(2)) + + async with session_factory() as session: + historical = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) + assert historical.status == 'removed' + assert historical.projection_revision == 2 + + +async def test_cloud_account_core_invitation_membership_survives_directory_omission(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + invited_account_uuid = '20000000-0000-0000-0000-000000000098' + async with session_factory() as session: + async with session.begin(): + projected_membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + session.add( + User( + uuid=invited_account_uuid, + user='Invited Cloud Account', + normalized_email='invited-cloud@example.com', + password='', + status='active', + source='cloud_projection', + projection_revision=1, + account_type='space', + space_account_uuid=invited_account_uuid, + ) + ) + session.add( + WorkspaceMembership( + uuid=SECOND_MEMBERSHIP_UUID, + workspace_uuid=WORKSPACE_UUID, + account_uuid=invited_account_uuid, + role='viewer', + status='active', + source='local', + joined_at=projected_membership.joined_at, + projection_revision=0, + ) + ) + + await service.apply_snapshot(_snapshot(2)) + + async with session_factory() as session: + membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) + assert membership.source == 'local' + assert membership.status == 'active' + assert membership.projection_revision == 0 + + +async def test_directory_does_not_adopt_local_membership_with_different_uuid_for_same_cloud_account(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + async with session_factory() as session: + async with session.begin(): + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + membership.uuid = SECOND_MEMBERSHIP_UUID + membership.source = 'local' + membership.projection_revision = 0 + + projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'}) + projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)}) + await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace])) + + async with session_factory() as session: + membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) + assert membership.source == 'local' + assert membership.role == 'developer' + assert membership.status == 'active' + assert membership.projection_revision == 0 diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index 77974f4ff..487095639 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -99,6 +99,7 @@ async def test_invitation_secret_is_hashed_and_acceptance_is_one_time(collaborat membership = await service.accept_invitation(created.token, account.uuid) assert membership.workspace_uuid == workspace.uuid assert membership.role == 'developer' + assert membership.source == 'local' with pytest.raises(InvitationUsedError): await service.accept_invitation(created.token, account.uuid) diff --git a/tests/unit_tests/workspace/test_workspace_service.py b/tests/unit_tests/workspace/test_workspace_service.py index f24f2ca44..5d6633d32 100644 --- a/tests/unit_tests/workspace/test_workspace_service.py +++ b/tests/unit_tests/workspace/test_workspace_service.py @@ -153,6 +153,33 @@ async def test_initial_owner_cannot_be_claimed_by_another_account(workspace_test ).all() assert len(owners) == 1 assert owners[0].account_uuid == first_account_uuid + assert owners[0].source == 'local' + + +async def test_claim_initial_owner_reclassifies_existing_membership_as_local(workspace_test_context): + service, session_factory = workspace_test_context + + async with session_factory() as session: + async with session.begin(): + account_uuid = await _insert_account(session, 'reclaimed@example.com') + workspace = await service.ensure_singleton_workspace(session=session) + session.add( + WorkspaceMembership( + uuid='44444444-4444-4444-8444-444444444444', + workspace_uuid=workspace.uuid, + account_uuid=account_uuid, + role='viewer', + status='removed', + source='cloud_projection', + projection_revision=4, + ) + ) + + membership = await service.claim_initial_owner(account_uuid) + + assert membership.role == 'owner' + assert membership.status == 'active' + assert membership.source == 'local' async def test_execution_binding_returns_persisted_generation(workspace_test_context): From 7dc9dafb7cd20695210a533a0a12a788a2e07224 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 7 Aug 2026 11:26:42 +0800 Subject: [PATCH 2/7] fix(plugin): validate runtime timeout before startup (#2407) Co-authored-by: Chan --- src/langbot/pkg/plugin/connector.py | 3 +- .../unit_tests/plugin/test_connector_ping.py | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index cd07565aa..f2dbff418 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -841,6 +841,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): runtime_id=self._runtime_id, ) self.worker_policy = self._load_worker_policy() + plugin_config = self.ap.instance_config.data.get('plugin', {}) + connect_timeout_seconds = self._runtime_connect_timeout(plugin_config) async with self._lifecycle_lock: if self._closing: @@ -981,7 +983,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): task_coro = self.ctrl.run(new_connection_callback) self._transport_task = asyncio.create_task(task_coro) - connect_timeout_seconds = self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {})) try: await asyncio.wait_for(self._connected.wait(), timeout=connect_timeout_seconds) except asyncio.TimeoutError as exc: diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py index c33f4012a..3b3aee016 100644 --- a/tests/unit_tests/plugin/test_connector_ping.py +++ b/tests/unit_tests/plugin/test_connector_ping.py @@ -132,6 +132,49 @@ async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr( await connector.aclose() +@pytest.mark.asyncio +async def test_invalid_connect_timeout_is_rejected_before_transport_startup( + monkeypatch: pytest.MonkeyPatch, +): + connector = make_connector() + connector.ap.instance_config.data['plugin']['connect_timeout_seconds'] = 0 + stdio_controller = Mock() + websocket_controller = Mock() + create_task = Mock() + get_platform = Mock(return_value='linux') + use_websocket = Mock(return_value=False) + connector._start_runtime_subprocess = AsyncMock() + monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a') + monkeypatch.setattr(connector_module.asyncio, 'create_task', create_task) + monkeypatch.setattr(connector_module.platform, 'get_platform', get_platform) + monkeypatch.setattr( + connector_module.platform, + 'use_websocket_to_connect_plugin_runtime', + use_websocket, + ) + monkeypatch.setattr( + connector_module.stdio_client_controller, + 'StdioClientController', + stdio_controller, + ) + monkeypatch.setattr( + connector_module.ws_client_controller, + 'WebSocketClientController', + websocket_controller, + ) + + with pytest.raises(ValueError, match='plugin.connect_timeout_seconds'): + await connector.initialize() + + get_platform.assert_not_called() + use_websocket.assert_not_called() + stdio_controller.assert_not_called() + websocket_controller.assert_not_called() + connector._start_runtime_subprocess.assert_not_awaited() + create_task.assert_not_called() + assert connector._transport_task is None + + @pytest.mark.asyncio async def test_runtime_disconnect_notifies_once_and_clears_handler( monkeypatch: pytest.MonkeyPatch, From 78068db9c866b8a2d208d24c52602466e4e62073 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 7 Aug 2026 11:32:38 +0800 Subject: [PATCH 3/7] fix(cloud): preserve tenant scope for extension tasks (#2408) * fix(cloud): preserve tenant scope for extension tasks * ci: retrigger extension scope checks --------- Co-authored-by: Chan --- .../pkg/api/http/controller/groups/plugins.py | 13 +++++----- .../api/test_plugin_runtime_route_fence.py | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index e96f8d213..069fcb027 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -15,7 +15,6 @@ import posixpath import sqlalchemy from .....core import taskmgr -from .....core.task_boundary import run_in_workspace_uow from .....entity.persistence import plugin as persistence_plugin from ...authz import Permission from ...context import ExecutionContext, RequestContext @@ -311,11 +310,13 @@ class PluginsRouterGroup(group.RouterGroup): ): """Revalidate a captured task context immediately before Runtime I/O.""" - await run_in_workspace_uow( - self.ap, - execution_context.workspace_uuid, - lambda: self.ap.plugin_connector.require_workspace_context(execution_context), - ) + persistence_mgr = getattr(self.ap, 'persistence_mgr', None) + tenant_scope = getattr(persistence_mgr, 'tenant_scope', None) + if callable(tenant_scope): + async with tenant_scope(execution_context.workspace_uuid): + await self.ap.plugin_connector.require_workspace_context(execution_context) + return await operation() + await self.ap.plugin_connector.require_workspace_context(execution_context) return await operation() async def _require_authenticated_plugin_runtime_context( diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py index 3594a7374..509684fe8 100644 --- a/tests/unit_tests/api/test_plugin_runtime_route_fence.py +++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py @@ -124,24 +124,37 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r @pytest.mark.asyncio -async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls): +async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uow(plugin_router_cls): scopes = [] + active_scope = None + + transaction_active = False @asynccontextmanager - async def tenant_uow(workspace_uuid): + async def tenant_scope(workspace_uuid): + nonlocal active_scope scopes.append(workspace_uuid) - yield + active_scope = workspace_uuid + try: + yield + finally: + active_scope = None connector = SimpleNamespace( require_workspace_context=AsyncMock(side_effect=lambda context: context), ) - operation = AsyncMock(return_value='done') + + async def operation(): + assert active_scope == CONTEXT.workspace_uuid + assert transaction_active is False + return 'done' + router = object.__new__(plugin_router_cls) router.ap = SimpleNamespace( plugin_connector=connector, persistence_mgr=SimpleNamespace( mode=SimpleNamespace(value='cloud_runtime'), - tenant_uow=tenant_uow, + tenant_scope=tenant_scope, ), ) @@ -150,4 +163,3 @@ async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(p assert result == 'done' assert scopes == [CONTEXT.workspace_uuid] connector.require_workspace_context.assert_awaited_once_with(CONTEXT) - operation.assert_awaited_once() From 22c389edc16149828380c7153c0b492400f66a5f Mon Sep 17 00:00:00 2001 From: leonoxo Date: Sat, 8 Aug 2026 22:29:54 +0800 Subject: [PATCH 4/7] fix(pipeline): ground local-agent system prompt with current date (#2399) The local-agent runner's system prompt is a static string with no template-variable support, so the model had no anchor for "today" and resolved relative time references (e.g. "this quarter", "latest") against whichever period was best represented in training data instead of the real date, sometimes confidently answering with stale information for time-sensitive questions. PreProcessor now appends a short, deterministically-computed "Current date: ..." note to the system prompt on every request for local-agent pipelines, alongside guidance to verify time-sensitive facts with a search tool rather than answering from memory. The existing skill-awareness prompt injection is refactored to share the same append-to-system-prompt helper. --- src/langbot/pkg/pipeline/preproc/preproc.py | 62 +++++++---- tests/unit_tests/pipeline/test_preproc.py | 112 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 5b2201b2e..f2d9fe9a1 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -41,6 +41,29 @@ class PreProcessor(stage.PipelineStage): selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} return [tool for tool in tools if tool.name in selected_tool_names] + @staticmethod + def _append_to_system_prompt( + messages: list[provider_message.Message], + addition: str, + ) -> None: + """Append text to the first system message, creating one if none exists. + + Handles both plain-string and content-element (list) message bodies. + """ + if messages and messages[0].role == 'system': + head = messages[0] + if isinstance(head.content, str): + head.content = head.content + addition + elif isinstance(head.content, list): + for ce in head.content: + if getattr(ce, 'type', None) == 'text': + ce.text = (ce.text or '') + addition + break + else: + head.content.append(provider_message.ContentElement(type='text', text=addition)) + else: + messages.insert(0, provider_message.Message(role='system', content=addition.strip())) + async def process( self, query: pipeline_query.Query, @@ -275,6 +298,23 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt + # =========== Current date grounding for the local-agent runner =========== + # local-agent system prompts are static strings with no template-variable + # support, so without an explicit anchor the LLM resolves relative time + # references (e.g. "this quarter", "latest", "currently") against whichever + # period is best represented in its training data instead of the real date, + # and won't reliably know to double check time-sensitive facts with a tool. + if selected_runner == 'local-agent': + date_addition = ( + f'\n\nCurrent date: {datetime.datetime.now().strftime("%Y-%m-%d (%A)")}. ' + 'Resolve relative time references (e.g. "today", "this quarter", "latest", ' + '"currently") based on this date, not your training cutoff. For anything ' + 'time-sensitive that may have changed since training — stock prices, ' + 'financial results, news, current events, exchange rates, or similar — ' + 'verify with a search tool if one is available rather than answering from memory.' + ) + self._append_to_system_prompt(query.prompt.messages, date_addition) + # =========== Skill awareness for the local-agent runner =========== # The actual activation goes through the ``activate`` Tool Call so the # LLM doesn't see full SKILL.md instructions until it commits to a @@ -310,27 +350,7 @@ class PreProcessor(stage.PipelineStage): bound_skills=bound_skills, ) if skill_addition: - # Append to the first system message; create one if the - # prompt has none. Handles both plain-string and - # content-element (list) message bodies. - if query.prompt.messages and query.prompt.messages[0].role == 'system': - head = query.prompt.messages[0] - if isinstance(head.content, str): - head.content = head.content + skill_addition - elif isinstance(head.content, list): - appended = False - for ce in head.content: - if getattr(ce, 'type', None) == 'text': - ce.text = (ce.text or '') + skill_addition - appended = True - break - if not appended: - head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) - else: - query.prompt.messages.insert( - 0, - provider_message.Message(role='system', content=skill_addition.strip()), - ) + self._append_to_system_prompt(query.prompt.messages, skill_addition) self.ap.logger.debug( f'Skill index injected into system prompt: ' f'pipeline={query.pipeline_uuid} ' diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index c28858959..393418ee0 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -488,3 +488,115 @@ class TestPreProcessorToolSelection: result = await stage.process(query, 'PreProcessor') assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] + + +class TestPreProcessorDateGrounding: + """Tests for current-date injection into the local-agent system prompt.""" + + @pytest.mark.asyncio + async def test_local_agent_appends_date_to_existing_system_message(self): + """Date grounding text should be appended to an existing system prompt.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + from langbot_plugin.api.entities.builtin.provider import message as provider_message + + system_message = provider_message.Message(role='system', content='You are a helpful assistant.') + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[system_message], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + + result = await stage.process(query, 'PreProcessor') + + messages = result.new_query.prompt.messages + assert len(messages) == 1 + assert messages[0].role == 'system' + assert messages[0].content.startswith('You are a helpful assistant.') + assert 'Current date:' in messages[0].content + + @pytest.mark.asyncio + async def test_local_agent_creates_system_message_when_none_exists(self): + """A system message should be created when the prompt has none.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + + result = await stage.process(query, 'PreProcessor') + + messages = result.new_query.prompt.messages + assert len(messages) == 1 + assert messages[0].role == 'system' + assert 'Current date:' in messages[0].content + + @pytest.mark.asyncio + async def test_non_local_agent_runner_skips_date_injection(self): + """Runners other than local-agent should not get the date addition.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + query.pipeline_config = { + 'ai': { + 'runner': {'runner': 'dify-service-api'}, + 'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': 'default'}, + }, + 'output': {'misc': {'at-sender': False}}, + 'trigger': {'misc': {}}, + } + + result = await stage.process(query, 'PreProcessor') + + assert result.new_query.prompt.messages == [] From e37987215e8465818e373fa523075b5482b70a6e Mon Sep 17 00:00:00 2001 From: Dongchuan Fu <2213070223@qq.com> Date: Sun, 9 Aug 2026 17:38:01 +0800 Subject: [PATCH 5/7] feat(provider): add pipeline reasoning controls (#2373) * feat(provider): add pipeline reasoning controls * fix(provider): preserve local agent model compatibility * refactor(web): use shadcn reasoning slider * fix(runtime): stabilize reasoning chat delivery * fix(provider): route reasoning controls by model family * fix(provider): handle hosted Kimi reasoning protocols * fix(provider): map qwen reasoning levels to budgets * fix(provider): preserve think tags in streamed reasoning * fix(provider): preserve reasoning tool metadata * style(provider): satisfy ruff checks after merge * fix(persistence): preserve reasoning migration compatibility --- docs/review/model-reasoning-control-design.md | 476 ++++++++++ src/langbot/pkg/api/http/service/model.py | 87 +- src/langbot/pkg/entity/persistence/model.py | 6 + .../versions/0018_add_llm_reasoning_config.py | 57 ++ .../versions/0021_merge_reasoning_config.py | 21 + src/langbot/pkg/pipeline/controller.py | 2 +- .../pkg/platform/sources/websocket_adapter.py | 25 + src/langbot/pkg/provider/modelmgr/modelmgr.py | 6 +- .../pkg/provider/modelmgr/reasoning.py | 125 +++ .../pkg/provider/modelmgr/requester.py | 13 + .../modelmgr/requesters/litellmchat.py | 551 ++++++++++- .../pkg/provider/runners/localagent.py | 45 +- .../templates/metadata/pipeline/ai.yaml | 1 + .../persistence/test_migrations.py | 76 +- .../api/service/test_model_service.py | 130 ++- .../pipeline/test_controller_tenancy.py | 51 + .../test_websocket_session_isolation.py | 44 + tests/unit_tests/provider/test_litellmchat.py | 11 +- .../provider/test_localagent_sandbox_exec.py | 21 + .../unit_tests/provider/test_model_service.py | 137 ++- .../provider/test_reasoning_control.py | 872 ++++++++++++++++++ .../provider/test_requester_base.py | 1 + web/package.json | 1 + web/pnpm-lock.yaml | 207 +++++ .../dynamic-form/DynamicFormComponent.tsx | 13 + .../dynamic-form/DynamicFormItemComponent.tsx | 113 ++- .../dynamic-form/DynamicFormSaveValues.ts | 62 +- .../components/models-dialog/ModelsPanel.tsx | 33 +- .../components/AddModelPopover.tsx | 41 +- .../models-dialog/components/ModelItem.tsx | 63 +- .../models-dialog/components/ProviderCard.tsx | 68 +- .../home/components/models-dialog/types.ts | 10 + .../reasoning/ReasoningLevelPicker.tsx | 97 ++ web/src/app/infra/entities/api/index.ts | 24 + web/src/components/ui/slider.tsx | 36 + web/src/i18n/locales/en-US.ts | 13 + web/src/i18n/locales/ja-JP.ts | 13 + web/src/i18n/locales/zh-Hans.ts | 13 + .../unit/dynamic-form-save-values.test.mjs | 21 + 39 files changed, 3499 insertions(+), 87 deletions(-) create mode 100644 docs/review/model-reasoning-control-design.md create mode 100644 src/langbot/pkg/persistence/alembic/versions/0018_add_llm_reasoning_config.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0021_merge_reasoning_config.py create mode 100644 src/langbot/pkg/provider/modelmgr/reasoning.py create mode 100644 tests/unit_tests/provider/test_reasoning_control.py create mode 100644 web/src/app/home/components/reasoning/ReasoningLevelPicker.tsx create mode 100644 web/src/components/ui/slider.tsx diff --git a/docs/review/model-reasoning-control-design.md b/docs/review/model-reasoning-control-design.md new file mode 100644 index 000000000..95965ad2d --- /dev/null +++ b/docs/review/model-reasoning-control-design.md @@ -0,0 +1,476 @@ +# 模型思考控制设计方案 + +> 日期:2026-07-31 +> 状态:Phase 1 已审核并实现 +> 范围:LangBot 主仓库的模型配置、LiteLLM 请求层、Local Agent、Web 管理面板、监控与测试 + +## 1. 结论 + +建议为 LangBot 增加一套与厂商参数解耦的“思考策略”模型,并明确区分三个概念: + +1. **思考能力**:模型是否支持思考,以及支持开关、档位还是 token 预算。 +2. **思考策略**:一次请求选择厂商默认、关闭、开启或指定思考档位。 +3. **思考展示**:是否把模型返回的思考内容展示给最终用户。 + +现有 `remove-think` 只属于第 3 类。它会过滤输出,但不会阻止模型思考,也不会降低思考 token、费用或延迟。新能力不应复用或改写这个字段。 + +推荐实现原则: + +- 默认值为 `provider_default`,不向上游增加任何新参数,现有模型行为完全不变。 +- 用户显式选择的策略必须被准确执行;无法准确执行时返回明确错误,不静默降级。 +- LangBot 内部只保存统一策略,Provider 请求层负责翻译成各厂商参数。 +- `extra_args` 保留为高级逃生口,但不能成为主 UI 的思考配置方式。 +- 模型页只管理并展示能力;可写策略归属于 Local Agent 流水线,同一模型可在不同业务中使用不同思考量。 +- 原始 reasoning 数据与展示文本分开保存,保证多轮对话、工具调用和签名字段不丢失。 + +## 2. 调研结论 + +### 2.1 可验证资料 + +本次结论基于以下可验证来源: + +- OpenAI 官方 Reasoning Guide:`reasoning.effort` 的可选值由模型决定,可包括 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`;低档位偏向低延迟和低 token,高档位偏向质量。 + - https://developers.openai.com/api/docs/guides/reasoning#reasoning-effort +- LangBot 锁定的 LiteLLM `1.88.1` 实现。`uv.lock` 已锁定该版本,本地缓存中的适配代码可以确认 LangBot 实际依赖所支持的翻译行为。 +- LangBot 当前实现:模型级 `extra_args` 会在 `LiteLLMRequester._build_completion_args()` 中直接合并到 `acompletion()` 参数。 + +Anthropic、Google 和 LiteLLM 的官方文档域名在本次环境中被浏览器策略禁止访问,因此下表中这些厂商的结论以 LiteLLM `1.88.1` 实际适配代码为准。实施前应再用对应厂商官方文档做一次参数范围核验,尤其是模型代际和允许值。 + +### 2.2 厂商差异矩阵 + +| Provider / 生态 | 可控制能力 | LiteLLM 1.88.1 统一入口 | 关键限制 | 建议支持级别 | +| --- | --- | --- | --- | --- | +| OpenAI | 思考档位,部分模型支持 `none` | `reasoning_effort` | 每个模型支持的档位不同,不能把 `none` 当成通用能力 | 首批完整支持 | +| Anthropic | 旧模型使用 extended thinking + token budget;新模型可用 adaptive thinking + effort | `reasoning_effort` 或 `thinking` | `none` 表示不发送 thinking;新旧模型的映射不同 | 首批完整支持 | +| Gemini | 2.x 主要映射为 `thinkingBudget`;3.x 主要映射为 `thinkingLevel` | `reasoning_effort` 或 `thinking` | Gemini 3 的 `none` 可能只能降到最低档,不能保证真正关闭 | 首批支持,但严格限制关闭语义 | +| DeepSeek | 开启/关闭;当前适配不支持预算档位 | `thinking={type: enabled}`;非 `none` effort 会映射成开启 | 多轮思考模式要求回传 `reasoning_content` | 首批开关支持 | +| xAI | 思考档位 | `reasoning_effort` | 仅 reasoning-capable 模型接受 | 首批完整支持 | +| Ollama | `think` 布尔值;部分模型接受 low/medium/high | `reasoning_effort` | 非 gpt-oss 模型的档位可能退化为布尔开关 | 首批支持,按模型能力裁剪 UI | +| OpenRouter | 聚合多厂商的 reasoning 参数 | `reasoning_effort`、`thinking` | 实际能力由路由后的模型决定 | 首批支持,能力未知时要求测试 | +| Volcengine / Doubao | `thinking.type` 支持 enabled/disabled/auto | LiteLLM `volcengine` 适配器支持 `thinking` | LangBot 当前 manifest 使用 `openai`,不会进入该适配器 | 第二批,先修正路由并回归 | +| Bailian / Qwen | 厂商兼容接口有独立思考开关/预算 | LiteLLM `dashscope` 适配器目前未提供统一 reasoning 映射 | LangBot 当前 manifest 使用 `openai`,只能通过高级参数透传 | 第二批,实施前核对官方字段 | +| 其他 OpenAI-compatible 网关 | 取决于网关 | 尝试标准 `reasoning_effort` | 不能仅凭模型名推断完整能力 | 保守支持,默认不自动开启 | + +### 2.3 对 LangBot 的直接含义 + +不能把这个功能实现成单一 `enable_thinking: bool`,原因如下: + +- 有的模型只有开关,有的模型只有档位,有的模型允许精确 token 预算。 +- 有的模型本身始终推理,只能降低思考量,无法真正关闭。 +- 同一个通用档位在不同厂商会映射成不同的实际预算。 +- 聚合网关和自定义 OpenAI-compatible 服务无法可靠地通过模型名识别能力。 +- “不展示思考内容”不等于“关闭思考”。 + +## 3. 当前项目现状 + +### 3.1 已有能力 + +- `LLMModel.extra_args` 是 JSON 字段,Web 端已有通用高级参数编辑器。 +- `LiteLLMRequester` 会按“模型级 `extra_args`,再调用级 `extra_args`”的顺序合并参数。 +- LiteLLM 已统一处理多个 Provider 的 `reasoning_effort`、`thinking` 和返回的 `reasoning_content`。 +- `LocalAgentRunner` 的非流式、流式、工具调用和 fallback 路径都经过 `RuntimeProvider.invoke_llm*()`。 +- `remove-think` 已能控制 `` 或独立 reasoning 内容是否进入展示文本。 +- Gemini 工具调用所需的 `provider_specific_fields` / thought signature 已有保留逻辑和单元测试。 + +### 3.2 现有缺口 + +- 管理员只能手写 `extra_args`,没有统一语义、能力提示和校验。 +- `remove-think` 名称容易被误解为关闭模型思考。 +- 模型扫描只识别 `vision` 和 `func_call`,没有 reasoning 能力。 +- 当前返回处理会把 `reasoning_content` 拼进 `` 文本后删除原字段,可能损失多轮思考所需的结构化数据。 +- DeepSeek 思考模式需要在后续轮次回传 `reasoning_content`,当前链路不能保证完整保留。 +- Pipeline 只能选择模型,不能针对业务覆盖模型的思考策略。 +- 监控只记录总输入/输出 token,没有单独展示 reasoning token。 +- 部分 Provider manifest 仍声明为通用 `openai`,导致 LiteLLM 的厂商专用翻译器不会生效。 + +### 3.3 预计改动地图 + +| 层 | 主要文件 | 责任 | +| --- | --- | --- | +| 持久化 | `src/langbot/pkg/entity/persistence/model.py`、`src/langbot/pkg/persistence/alembic/versions/` | 新增 `reasoning_config` JSON 列和 Alembic 迁移 | +| 模型服务 | `src/langbot/pkg/api/http/service/model.py` | CRUD 校验、冲突检测、测试模型时使用统一策略 | +| HTTP 控制器 | `src/langbot/pkg/api/http/controller/groups/provider/models.py` | 继续复用现有模型路由,不新增平行 API | +| 模型管理 | `src/langbot/pkg/provider/modelmgr/modelmgr.py` | 临时模型、数据库模型与扫描结果加载新字段 | +| 请求抽象 | `src/langbot/pkg/provider/modelmgr/requester.py` | 定义能力查询和 reasoning 参数构建接口 | +| LiteLLM 适配 | `src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py` | 能力识别、策略翻译、参数合并、reasoning 返回保留 | +| Provider manifest | `src/langbot/pkg/provider/modelmgr/requesters/*.yaml` | 必要时修正 Provider 路由;相关变更放到独立阶段 | +| Agent 调用 | `src/langbot/pkg/provider/runners/localagent.py` | 所有非流式、流式、工具调用、fallback 路径传递统一策略 | +| Pipeline 元数据 | `src/langbot/templates/metadata/pipeline/ai.yaml` | 第二阶段加入 Pipeline 级覆盖 | +| 输出配置 | `src/langbot/templates/metadata/pipeline/output.yaml` | 保留键名,澄清 `remove-think` 只控制展示 | +| Web 类型/API | `web/src/app/infra/entities/api/index.ts`、`web/src/app/infra/http/BackendClient.ts` | 增加配置与能力响应类型 | +| 模型 UI | `web/src/app/home/components/models-dialog/` | 能力标记、策略控件、校验、模型测试 | +| i18n | `web/src/i18n/locales/` | 至少补齐英文、简体中文及项目已有覆盖语言 | +| 测试 | `tests/unit_tests/provider/`、`web/tests/` | 翻译、服务、流式 round-trip、前端状态测试 | + +Phase 1 不修改 `langbot-plugin-sdk` 的公共实体或运行时协议。现有 `provider_message.Message.provider_specific_fields` 已可承载 Provider 原始 reasoning 数据;只有后续要把 reasoning 升级为跨插件公开实体时,才需要跨仓库 SDK 变更。 + +## 4. 领域模型 + +### 4.1 统一策略 + +新增 `ReasoningConfig`,保存于 LLM 模型,Pipeline 可提供同结构覆盖。产品层只暴露一个离散档位: + +```json +{ + "level": "provider_default" +} +``` + +字段定义: + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `level` | `provider_default \| disabled \| enabled \| minimal \| low \| medium \| high \| xhigh \| max` | 同时表达开关和思考强度 | + +校验规则: + +- `provider_default`:不发送任何 reasoning 参数,保持厂商和模型默认行为。 +- `disabled`:明确关闭;仅当模型可真正关闭时允许保存/运行。 +- `enabled`:明确开启,但由 Provider 决定具体强度,适用于只有开关的模型。 +- `minimal` 到 `max`:明确开启,并指定强度;仅允许选择模型实际支持的档位。 +- 厂商的 `auto` 统一映射为 `provider_default`,不再增加一个重复状态。 +- 精确 token 预算不进入主数据结构。少数需要预算的场景继续通过高级参数配置,并由模型测试接口校验。 + +### 4.2 能力描述 + +沿用现有 `LLMModel.abilities`,新增 `reasoning` 能力标记。同时由后端在 API 返回中计算只读的 `reasoning_capabilities`: + +```json +{ + "supported": true, + "controls": ["toggle", "effort"], + "efforts": ["none", "low", "medium", "high"], + "can_disable": true, + "source": "litellm" +} +``` + +设计约束: + +- `abilities` 仍是用户可编辑的粗粒度能力,符合现有 `vision`、`func_call` 模式。 +- `reasoning_capabilities` 不持久化,优先从 LiteLLM 模型元数据计算,避免模型升级后数据库残留过期能力。 +- 无法识别的自定义模型返回 `supported: null`、`source: unknown`,不猜测。 +- 用户可手动添加 `reasoning` ability,但未知能力模型必须先通过“测试模型”验证显式策略。 +- UI 只展示后端声明可用的控件;未知模型保留 Provider Default 和高级参数入口。 + +### 4.3 持久化 + +在 `llm_models` 表新增 JSON 列: + +```text +reasoning_config JSON NOT NULL DEFAULT {"level":"provider_default"} +``` + +使用 Alembic 新迁移,不修改冻结的 legacy migration。 + +该列作为已实现版本的兼容字段保留;新的模型页不再提供写入口,Local Agent 请求以流水线中按模型 UUID 保存的策略为准。 + +不建议把内部策略塞进 `extra_args`,原因是当前 `extra_args` 会原样发送给 LiteLLM;使用保留键会让内部元数据泄漏到上游,并使高级参数与产品配置难以区分。 + +## 5. 配置优先级与请求流程 + +### 5.1 优先级 + +```text +Pipeline 当前候选模型策略 + ↓ 缺少配置时固定为 provider_default +Provider / 模型默认行为 +``` + +请求参数合并顺序: + +```text +基础参数 + -> 模型 extra_args + -> 调用级 extra_args + -> 统一 reasoning 策略翻译结果(最后应用) +``` + +统一策略最后应用,可以确保流水线行为不受模型页历史设置影响。为了避免用户困惑,保存和测试时要检测 `extra_args` 中的冲突字段;当 `level != provider_default` 时,发现以下字段应直接报错: + +- `reasoning_effort` +- `thinking` +- `reasoning` +- `extra_body` 内已知的 `thinking`、`enable_thinking`、`thinking_budget` 等字段 + +当 `level == provider_default` 时继续允许这些高级参数,保证旧配置兼容。 + +### 5.2 翻译层 + +在 `pkg/provider/modelmgr/` 内新增独立的 reasoning 规范化模块,职责是: + +1. 读取当前流水线候选模型的请求级策略。 +2. 查询 `ProviderAPIRequester.get_reasoning_capabilities(model)`。 +3. 严格校验策略是否可以准确执行。 +4. 生成 LiteLLM 参数,不直接发 HTTP。 +5. 返回可观测的“最终生效策略”供日志和测试使用。 + +建议接口: + +```python +class ProviderAPIRequester: + def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> ReasoningCapabilities: ... + + def build_reasoning_args( + self, + model: RuntimeLLMModel, + config: ReasoningConfig, + ) -> dict[str, Any]: ... +``` + +LiteLLMRequester 默认优先生成统一参数: + +- 强度档位:`reasoning_effort=` +- 仅开启:`thinking={"type":"enabled"}` 或 Provider 等价参数 +- 关闭:优先 `reasoning_effort="none"` +- 高级参数中的精确预算:`thinking={"type":"enabled","budget_tokens":N}` + +Provider 特例只放在 requester 翻译层,不进入 Pipeline 或平台适配器。 + +### 5.3 Provider 特例 + +- **Gemini 3**:如果 LiteLLM 能力表不能确认真正关闭,`disabled` 必须报“不支持关闭,可选择 Provider Default 或最低档”,不能把 `none` 静默映射成 low/minimal。 +- **DeepSeek**:所有非 `none` 档位最终都只是开启。能力 API 只返回 `toggle`,UI 不显示档位;多轮必须保存并回传 `reasoning_content`。 +- **Ollama**:仅对明确支持等级的模型展示 effort;其他模型只展示开关。 +- **OpenRouter**:以路由后的模型能力为准。模型未知时允许 Provider Default,显式策略必须通过测试接口。 +- **Volcengine**:使用 `thinking.type=enabled/disabled/auto`。应先让该 requester 进入 LiteLLM `volcengine` 适配器,或增加等价的明确翻译,不能依赖模型名。 +- **Bailian/Qwen**:作为第二批 Provider 专用翻译。实施前核对官方字段、模型范围、预算上下限和流式返回结构,不凭经验写接口。 + +## 6. 返回数据与思考展示 + +### 6.1 保留原始 reasoning + +当前 `LiteLLMRequester` 会读取 `reasoning_content`,将其拼接成 `` 文本,再删除原字段。建议改为: + +```text +上游 reasoning_content + ├─ 原样保存在 Message.provider_specific_fields.reasoning_content + └─ 根据 remove-think 决定是否渲染为 ... +``` + +流式路径需要在 accumulator 中分别累计 `content` 与 `reasoning_content`,最终消息必须携带结构化 reasoning。不能只依赖已经渲染的 `` 文本反向解析。 + +这样可以同时满足: + +- `remove-think=true` 时用户看不到思考内容,但多轮协议仍能回传必要数据。 +- `remove-think=false` 时保持当前用户体验。 +- DeepSeek 多轮 thinking 不丢上下文。 +- Gemini thought signature、Anthropic thinking block 等 Provider 字段可以继续按结构化方式 round-trip。 + +### 6.2 现有字段处理 + +保留数据库和 Pipeline 配置键 `remove-think`,避免破坏兼容。Web 文案改为更准确的: + +- 中文:`向用户展示思考过程` +- 英文:`Show reasoning process` + +UI 使用正向开关,保存时转换回 `remove-think = !showReasoning`。文案必须强调它只影响展示,不影响模型是否思考、token 或费用。 + +## 7. Web 管理面板 + +### 7.1 模型编辑 + +模型页只承担能力管理和只读展示: + +1. `Reasoning` ability 复选框与 Vision、Function Calling 并列,供无法自动识别的自定义模型手动声明能力。 +2. 模型卡片使用简短图标或 badge 标识 reasoning 能力。 +3. 模型页不提供可写思考挡位,避免模型默认值与流水线策略形成两个控制源。 + +### 7.2 Local Agent 流水线策略 + +在 Local Agent 的主模型和每一个 fallback 模型下分别显示紧凑离散滑杆: + +1. `Provider 默认` 始终为首个选项;选择它时不向上游增加任何思考参数。 +2. 完整档位顺序为:`Provider 默认 / 关闭 / 开启 / 最低 / 低 / 中 / 高 / 极高 / 最大`。 +3. 前端只渲染后端为该模型返回的可用档位;仅开关模型显示 `Provider 默认 / 关闭 / 开启`。 +4. 模型不能真正关闭时不提供 `关闭`;能力未知时只显示不可调的 `Provider 默认`。 +5. 主模型和 fallback 分别保存策略,切换候选模型时不会把一个模型的挡位错误应用到另一个模型。 +6. Dify、Coze、Langflow、n8n 等外部 Runner 不显示该控件,因为 LangBot 不直接发起其内部模型请求。 + +流水线配置保持旧格式兼容,并在模型选择对象中增加按 UUID 保存的映射: + +```json +{ + "model": { + "primary": "primary-model-uuid", + "fallbacks": ["fallback-model-uuid"], + "reasoning": { + "primary-model-uuid": "high" + } + } +} +``` + +`provider_default` 不写入映射;缺少 `reasoning` 的旧流水线天然等价于全部使用 Provider 默认。 + +滑杆交互要求:轨道使用现有主色和中性灰,不使用渐变;当前档位同时显示文字;支持键盘方向键和正确的 ARIA value text;窄屏下不溢出。 + +### 7.3 i18n + +新增文案至少覆盖 `en_US`、`zh_Hans`;`ja_JP` 在模型面板现有同类字段已覆盖时同步补齐。不要把厂商参数名直接作为用户文案。 + +## 8. API、MCP 与 Skill + +### 8.1 HTTP API + +模型 CRUD 增加: + +- 请求字段:`reasoning_config` +- 响应字段:`reasoning_config` +- 只读字段:`reasoning_capabilities` + +模型测试接口必须使用与真实请求完全相同的规范化和翻译逻辑,并在失败时返回可操作错误,例如: + +```text +Model gemini-3-... cannot disable reasoning. +Supported controls: effort=[low, medium, high]. +``` + +可选增加只读调试信息,仅在测试接口返回: + +```json +{ + "effective_reasoning": { + "level": "low", + "translated_keys": ["reasoning_effort"] + } +} +``` + +不得返回 API key、完整请求正文或原始思考内容。 + +### 8.2 MCP 与技能 + +当前 MCP 仅列出模型 Provider,没有完整模型 CRUD 工具。如果本次不新增 agent-accessible HTTP 操作,则无需强行新增 MCP 工具。 + +如果后续让 Agent 修改模型思考策略,则必须同一提交更新: + +- `src/langbot/pkg/api/mcp/server.py` +- 对应的 `skills/` 文档 +- 参数 schema 和安全说明 + +## 9. 监控与可观测性 + +控制思考量后,管理员需要判断质量、延迟和成本是否值得。建议第二阶段增加: + +- `reasoning_tokens`:从 `completion_tokens_details.reasoning_tokens` 或 Provider 等价字段提取。 +- `effective_reasoning_level`:记录规范化后的生效档位,不记录原始思考内容。 +- 模型监控页展示输入 token、可见输出 token、reasoning token、总延迟。 +- Provider 不返回细分 token 时显示未知,不推算。 + +安全要求:日志、监控、debug API 默认都不得记录 reasoning 原文。思考内容可能包含敏感信息或系统提示,不应因为新增配置而扩大持久化范围。 + +## 10. 兼容与迁移 + +### 10.1 数据迁移 + +- 所有现有 LLM 记录迁移为 `{"level":"provider_default"}`。 +- 不自动解析或迁移现有 `extra_args` 中的 reasoning 参数,避免误判嵌套结构和 Provider 语义。 +- UI 检测到旧 `extra_args` reasoning 字段时显示“由高级参数控制”,统一策略保持 Provider Default。 +- 用户主动改成统一策略时,要求先移除冲突高级参数。 + +### 10.2 运行时兼容 + +- `provider_default` 不产生任何新增请求参数。 +- 不改变现有 `remove-think` 的存储键和默认值。 +- 不改变已有 Provider 的 `litellm_provider`,除非该 Provider 在专项回归后单独切换。 +- `drop_params` 不能用于掩盖显式 reasoning 配置错误;显式策略被丢弃应视为失败。 +- 自托管和 toB 环境中的自定义兼容接口保持可用,未知能力不阻止 Provider Default 请求。 + +## 11. 实施拆分 + +### Phase 1:统一基础设施与主流 Provider + +- Alembic 增加 `llm_models.reasoning_config`。 +- Backend 模型实体、CRUD、测试接口支持统一配置。 +- LiteLLMRequester 增加能力查询、严格校验和参数翻译。 +- 支持 OpenAI、Anthropic、Gemini、DeepSeek、xAI、Ollama、OpenRouter 的已验证 LiteLLM 路径。 +- 修复结构化 reasoning 的非流式/流式保留。 +- 模型面板增加 reasoning ability 与只读能力标识。 +- Local Agent 主模型和每个 fallback 增加独立的请求级策略。 + +### Phase 2:国内 Provider + +- 专项核对并支持 Volcengine/Doubao、Bailian/Qwen。 +- 对相关 requester 的 `litellm_provider` 变更做独立回归,避免把 reasoning 功能和通用请求行为回归混在一起。 +- 补齐扫描结果中的 reasoning capability。 + +### Phase 3:监控与评估 + +- 持久化 reasoning token 和生效策略。 +- 监控页增加 reasoning 成本/延迟指标。 +- 建立不同 effort 的离线质量、首 token 延迟、总耗时和 token 对比基线。 + +## 12. 测试方案 + +### 12.1 单元测试 + +- `ReasoningConfig` 所有合法/非法组合。 +- `provider_default` 不产生任何新增参数。 +- 显式配置覆盖模型/调用 `extra_args` 的顺序。 +- reasoning 配置与高级参数冲突时拒绝。 +- OpenAI 档位原样映射。 +- Anthropic 档位映射,以及高级参数预算兼容。 +- Gemini 2 budget、Gemini 3 level,以及不支持真正关闭时拒绝。 +- DeepSeek 只显示/接受 toggle,非 `none` effort 不伪装成不同档位。 +- Ollama 布尔与分级模型差异。 +- Volcengine enabled/disabled/auto 翻译。 +- 未知 Provider 只允许 Provider Default,或在显式测试后使用标准参数。 +- 非流式 `reasoning_content` 保存到 `provider_specific_fields`。 +- 流式 reasoning 分片累计后仍能 round-trip。 +- Gemini thought signature 和工具调用现有测试不能回归。 + +### 12.2 服务与持久化测试 + +- 新建、读取、更新模型的 `reasoning_config`。 +- Alembic 从当前 head 升级后默认值正确。 +- 模型测试接口与真实 Local Agent 使用同一翻译函数。 +- 旧模型、旧 `extra_args` 和 `remove-think` 行为不变。 + +### 12.3 前端测试 + +- 能力不同的模型显示正确控件。 +- 离散滑杆只能停在后端返回的可用档位。 +- 当前档位文字、键盘操作和 ARIA value text 正确。 +- 仅开关模型、不可关闭模型、完整档位模型分别显示正确刻度。 +- fallback 能力不兼容时阻止保存并给出明确提示。 +- 中英文文案完整,移动端 Popover 不溢出。 + +### 12.4 Provider 冒烟测试 + +至少选取以下真实或可控 mock: + +- 一个支持 `none` 的 OpenAI reasoning 模型。 +- 一个不支持 `none` 的 reasoning 模型。 +- 一个 Anthropic adaptive thinking 模型。 +- 一个 Gemini 2.x 与一个 Gemini 3.x 模型。 +- 一个 DeepSeek hybrid thinking 模型,执行两轮含工具调用对话。 +- 一个 Ollama 本地 reasoning 模型。 +- 一个 OpenAI-compatible 自定义网关,验证 Provider Default 完全不变。 + +每个模型比较 Provider Default、最低档、中档、高档或关闭,记录成功率、首 token 延迟、总耗时、总 token 和 reasoning token(若可用)。 + +## 13. 风险与控制 + +| 风险 | 影响 | 控制措施 | +| --- | --- | --- | +| 将“最低思考”误当成“关闭” | 用户以为节省了成本,实际仍在推理 | `can_disable` 严格校验,不静默降级 | +| 模型能力表过期 | 新模型无法配置或旧模型报错 | 能力未知时保守;允许测试;升级 LiteLLM 时回归 | +| 高 effort 导致延迟/费用陡增 | 用户体验和预算风险 | 默认 Provider Default;UI 提示;后续监控 reasoning token | +| `extra_args` 与统一配置冲突 | 实际生效值不可预测 | 保存/测试时拒绝冲突;统一策略最后应用 | +| reasoning 原文进入日志 | 敏感信息泄露 | 不记录原文,只记录策略和 token | +| 多轮 reasoning 丢失 | 工具调用或后续轮次失败/降质 | 结构化保存并 round-trip;流式专项测试 | +| 修改 Provider 路由造成通用回归 | 非 reasoning 请求也受影响 | 国内 Provider 路由放第二阶段,独立提交和回归 | + +## 14. 需要审核确认的决策 + +1. **是否同意三层分离**:能力、策略、展示互不替代,保留 `remove-think` 仅控制展示。 +2. **是否同意严格语义**:显式关闭无法准确执行时直接报错,不自动降为最低思考。 +3. **是否同意请求级配置**:流水线按模型 UUID 保存挡位,不把产品配置塞进 `extra_args`。 +4. **是否同意 Runner 边界**:仅 Local Agent 展示控制项,外部 Runner 由其外部系统管理模型策略。 +5. **是否同意保守默认**:所有现有模型迁移为 Provider Default,不自动开启、关闭或迁移旧高级参数。 +6. **是否把结构化 reasoning 保留纳入第一阶段**:这是 DeepSeek 多轮和工具调用正确性的必要条件,建议必须纳入。 + +## 15. 推荐审核结果 + +建议按以上 6 项全部通过,并将 Phase 1 作为一个完整功能单元实施。不要只增加前端开关或只在 `extra_args` 中写 `reasoning_effort`;那样虽然改动小,但会继续混淆展示与推理、无法处理 Provider 差异,也无法保证多轮对话正确性。 diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 898a791e2..bf8c6b196 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -10,6 +10,7 @@ from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester +from ....provider.modelmgr import reasoning as model_reasoning from ....workspace.errors import WorkspaceNotFoundError from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders from .tenant import TenantContext, require_workspace_uuid, scope_statement @@ -55,6 +56,53 @@ def _redact_model_secrets(model_data: dict) -> dict: return redacted +def _normalize_llm_reasoning(model_data: dict) -> None: + model_data['reasoning_config'] = model_reasoning.validate_reasoning_config( + model_data.get('reasoning_config'), + model_data.get('abilities'), + model_data.get('extra_args'), + ) + + +def _validate_llm_reasoning_capability( + model_entity: persistence_model.LLMModel, + runtime_provider: model_requester.RuntimeProvider, +) -> None: + config = model_reasoning.normalize_reasoning_config(model_entity.reasoning_config) + if config['level'] == 'provider_default': + return + + runtime_model = model_requester.RuntimeLLMModel( + execution_context=runtime_provider.execution_context, + model_entity=model_entity, + provider=runtime_provider, + ) + capabilities = runtime_provider.requester.get_reasoning_capabilities(runtime_model) + model_reasoning.validate_reasoning_capabilities(config, capabilities, model_entity.name) + + +def _reasoning_capabilities(ap: app.Application, model: persistence_model.LLMModel) -> dict: + model_mgr = getattr(ap, 'model_mgr', None) + runtime_models = getattr(model_mgr, 'llm_model_dict', {}) if model_mgr is not None else {} + for runtime_model in runtime_models.values(): + if ( + runtime_model.model_entity.uuid == model.uuid + and runtime_model.model_entity.workspace_uuid == model.workspace_uuid + ): + return runtime_model.provider.requester.get_reasoning_capabilities(runtime_model) + return model_reasoning.default_reasoning_capabilities( + supported='reasoning' in (model.abilities or []), + source='manual' if 'reasoning' in (model.abilities or []) else 'unknown', + ) + + +def _serialize_llm_model(ap: app.Application, model: persistence_model.LLMModel) -> dict: + model_dict = ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) + model_dict['reasoning_config'] = model_reasoning.normalize_reasoning_config(model_dict.get('reasoning_config')) + model_dict['reasoning_capabilities'] = _reasoning_capabilities(ap, model) + return model_dict + + async def _validate_provider_supports( ap: app.Application, context: TenantContext, @@ -165,7 +213,7 @@ class LLMModelsService: models_list = [] for model in models: - model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) + model_dict = _serialize_llm_model(self.ap, model) provider = providers.get(model.provider_uuid) if provider: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) @@ -196,7 +244,7 @@ class LLMModelsService: ) ) models = result.all() - serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models] + serialized = [_serialize_llm_model(self.ap, model) for model in models] return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] async def create_llm_model( @@ -233,13 +281,17 @@ class LLMModelsService: await _require_workspace_provider(self.ap, context, model_data['provider_uuid']) await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid']) await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm') + _normalize_llm_reasoning(model_data) + + runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid']) + model_entity = persistence_model.LLMModel(**model_data) + _validate_llm_reasoning_capability(model_entity, runtime_provider) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) - runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid']) runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( context, - persistence_model.LLMModel(**model_data), + model_entity, runtime_provider, ) await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model) @@ -287,7 +339,7 @@ class LLMModelsService: if model is None: return None - model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) + model_dict = _serialize_llm_model(self.ap, model) # Get provider provider_result = await self.ap.persistence_mgr.execute_async( @@ -349,6 +401,18 @@ class LLMModelsService: await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid) await _validate_provider_supports(self.ap, context, provider_uuid, 'llm') + merged_model_data = { + key: value + for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items() + if key not in {'provider', 'created_at', 'updated_at', 'reasoning_capabilities'} + } + _normalize_llm_reasoning(merged_model_data) + model_data['reasoning_config'] = merged_model_data['reasoning_config'] + + runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid) + model_entity = persistence_model.LLMModel(**_runtime_model_data(model_uuid, merged_model_data)) + _validate_llm_reasoning_capability(model_entity, runtime_provider) + result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.update(persistence_model.LLMModel) @@ -362,19 +426,9 @@ class LLMModelsService: raise WorkspaceNotFoundError('Model not found') await self.ap.model_mgr.remove_llm_model(context, model_uuid) - runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid) runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( context, - persistence_model.LLMModel( - **_runtime_model_data( - model_uuid, - { - key: value - for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items() - if key not in {'provider', 'created_at', 'updated_at'} - }, - ) - ), + model_entity, runtime_provider, ) await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model) @@ -407,6 +461,7 @@ class LLMModelsService: raise WorkspaceNotFoundError('Model not found') runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid) else: + _normalize_llm_reasoning(model_data) runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data) extra_args = model_data.get('extra_args', {}) diff --git a/src/langbot/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py index c04a532b5..13cc178bb 100644 --- a/src/langbot/pkg/entity/persistence/model.py +++ b/src/langbot/pkg/entity/persistence/model.py @@ -48,6 +48,12 @@ class LLMModel(Base): provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) + reasoning_config = sqlalchemy.Column( + sqlalchemy.JSON, + nullable=False, + default=lambda: {'level': 'provider_default'}, + server_default=sqlalchemy.text('\'{"level":"provider_default"}\''), + ) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) diff --git a/src/langbot/pkg/persistence/alembic/versions/0018_add_llm_reasoning_config.py b/src/langbot/pkg/persistence/alembic/versions/0018_add_llm_reasoning_config.py new file mode 100644 index 000000000..dd2e5feef --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0018_add_llm_reasoning_config.py @@ -0,0 +1,57 @@ +"""add llm reasoning config + +Revision ID: 0018_llm_reasoning_config +Revises: 0017_oss_workspace_identity +Create Date: 2026-07-27 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0018_llm_reasoning_config' +down_revision = '0017_oss_workspace_identity' +branch_labels = None +depends_on = None + + +_LLM_MODELS = sa.table( + 'llm_models', + sa.column('reasoning_config', sa.JSON()), +) + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'reasoning_config' in columns: + return + + op.add_column( + 'llm_models', + sa.Column( + 'reasoning_config', + sa.JSON(), + nullable=True, + server_default=sa.text('\'{"level":"provider_default"}\''), + ), + ) + conn.execute(_LLM_MODELS.update().values(reasoning_config={'level': 'provider_default'})) + with op.batch_alter_table('llm_models') as batch_op: + batch_op.alter_column('reasoning_config', existing_type=sa.JSON(), nullable=False) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'reasoning_config' in columns: + with op.batch_alter_table('llm_models') as batch_op: + batch_op.drop_column('reasoning_config') diff --git a/src/langbot/pkg/persistence/alembic/versions/0021_merge_reasoning_config.py b/src/langbot/pkg/persistence/alembic/versions/0021_merge_reasoning_config.py new file mode 100644 index 000000000..2f0729017 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0021_merge_reasoning_config.py @@ -0,0 +1,21 @@ +"""merge reasoning config with the main migration branch + +Revision ID: 0021_merge_reasoning_config +Revises: 0020_membership_source, 0018_llm_reasoning_config +Create Date: 2026-08-09 +""" + +from __future__ import annotations + +revision = '0021_merge_reasoning_config' +down_revision = ('0020_membership_source', '0018_llm_reasoning_config') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py index 1502ae4de..9ee085120 100644 --- a/src/langbot/pkg/pipeline/controller.py +++ b/src/langbot/pkg/pipeline/controller.py @@ -132,7 +132,7 @@ class Controller: break - if not selected_query: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 + if not selected_query: # No query is runnable under the current session limits. await self.ap.query_pool.condition.wait() continue diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 0752225c6..bb43cb140 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -5,6 +5,7 @@ import contextvars import logging import time import typing +from dataclasses import dataclass from datetime import datetime import pydantic @@ -25,6 +26,15 @@ _current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.Context ) +@dataclass(frozen=True) +class WebSocketReplyContext: + """Trusted routing context retained when the originating socket reconnects.""" + + scope: WebSocketScope + pipeline_uuid: str + session_id: str | None + + class WebSocketMessage(pydantic.BaseModel): """WebSocket消息格式""" @@ -265,6 +275,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) embed_target = self._parse_embed_target(sender_id) if embed_target is not None: return embed_target + reply_context = getattr(message_source, '_websocket_reply_context', None) + if isinstance(reply_context, WebSocketReplyContext): + if reply_context.scope != self._scope(): + raise ValueError('WebSocket reply context does not match this adapter scope') + return reply_context.pipeline_uuid, reply_context.session_id raise ValueError('WebSocket reply target is not bound to this adapter scope') async def send_message( @@ -685,6 +700,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) # 异步触发事件处理 # Use owner_bot's listeners if available, otherwise fall back to proxy bot + object.__setattr__( + event, + '_websocket_reply_context', + WebSocketReplyContext( + scope=connection.scope, + pipeline_uuid=pipeline_uuid, + session_id=connection.session_id, + ), + ) + listeners = ( owner_bot.adapter.listeners if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners) diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index 9d5dc803f..16441cf18 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -649,6 +649,7 @@ class ModelManager: provider_uuid=runtime_provider.provider_entity.uuid, abilities=model_info.get('abilities', []), context_length=model_info.get('context_length'), + reasoning_config=model_info.get('reasoning_config', {'level': 'provider_default'}), extra_args=model_info.get('extra_args', {}), ) return self._build_llm_model(execution_context, model_entity, runtime_provider) @@ -717,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 new file mode 100644 index 000000000..de44af01f --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/reasoning.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import typing + + +ReasoningLevel = typing.Literal[ + 'provider_default', + 'disabled', + 'enabled', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] + +REASONING_LEVELS: tuple[str, ...] = ( + 'provider_default', + 'disabled', + 'enabled', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +) +DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'} + +_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]: + """Return the canonical model reasoning configuration.""" + if value is None: + return dict(DEFAULT_REASONING_CONFIG) + if not isinstance(value, dict): + raise ValueError('reasoning_config must be an object') + + unknown_fields = set(value) - {'level'} + if unknown_fields: + raise ValueError(f'Unsupported reasoning_config fields: {", ".join(sorted(unknown_fields))}') + + level = value.get('level', 'provider_default') + if level not in REASONING_LEVELS: + raise ValueError(f'Unsupported reasoning level: {level}') + return {'level': typing.cast(str, level)} + + +def validate_reasoning_config( + value: typing.Any, + abilities: typing.Iterable[str] | None, + extra_args: typing.Any, +) -> dict[str, str]: + """Validate a model-facing reasoning config and conflicting raw arguments.""" + config = normalize_reasoning_config(value) + if config['level'] == 'provider_default': + return config + + if 'reasoning' not in set(abilities or []): + raise ValueError('The reasoning ability must be enabled before selecting a reasoning level') + + conflicts = find_reasoning_arg_conflicts(extra_args) + if conflicts: + raise ValueError('reasoning_config conflicts with advanced parameters: ' + ', '.join(conflicts)) + return config + + +def find_reasoning_arg_conflicts(extra_args: typing.Any) -> list[str]: + if not isinstance(extra_args, dict): + return [] + + conflicts = [key for key in sorted(_CONFLICTING_TOP_LEVEL_ARGS) if key in extra_args] + extra_body = extra_args.get('extra_body') + if isinstance(extra_body, dict): + conflicts.extend(f'extra_body.{key}' for key in sorted(_CONFLICTING_EXTRA_BODY_ARGS) if key in extra_body) + return conflicts + + +def validate_reasoning_capabilities( + config: typing.Any, + capabilities: typing.Mapping[str, typing.Any], + model_name: str, +) -> None: + """Ensure an explicit reasoning level can be honored by the requester.""" + level = normalize_reasoning_config(config)['level'] + if level == 'provider_default': + return + + available_levels = capabilities.get('levels') + if not isinstance(available_levels, list): + 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}' + ) + + +def default_reasoning_capabilities( + supported: bool = False, + source: str = 'unknown', +) -> dict[str, typing.Any]: + return { + 'supported': supported, + 'levels': ['provider_default'], + 'source': source, + } diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py index 0c8232dd5..e6808c36e 100644 --- a/src/langbot/pkg/provider/modelmgr/requester.py +++ b/src/langbot/pkg/provider/modelmgr/requester.py @@ -10,6 +10,7 @@ from ...entity.persistence import model as persistence_model from ...workspace.errors import WorkspaceInvariantError import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from . import token +from . import reasoning import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -377,11 +378,15 @@ class RuntimeLLMModel: provider: RuntimeProvider """提供商实例""" + reasoning_config_override: dict[str, str] | None + """Request-scoped reasoning policy supplied by the active pipeline.""" + def __init__( self, execution_context: ExecutionContext, model_entity: persistence_model.LLMModel, provider: RuntimeProvider, + reasoning_config_override: dict[str, str] | None = None, ): _ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model') if model_entity.workspace_uuid != execution_context.workspace_uuid: @@ -391,6 +396,7 @@ class RuntimeLLMModel: self.execution_context = execution_context self.model_entity = model_entity self.provider = provider + self.reasoning_config_override = reasoning_config_override class RuntimeEmbeddingModel: @@ -482,6 +488,13 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta): """ raise NotImplementedError('This provider does not support model scanning') + def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> dict[str, typing.Any]: + """Return normalized reasoning controls supported by a model.""" + return reasoning.default_reasoning_capabilities( + supported='reasoning' in (model.model_entity.abilities or []), + source='manual' if 'reasoning' in (model.model_entity.abilities or []) else 'unknown', + ) + @abc.abstractmethod async def invoke_llm( self, diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index f4daaf1df..d23a4fc51 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -7,7 +7,7 @@ import typing import litellm from litellm import acompletion, aembedding, arerank -from .. import errors, requester +from .. import errors, reasoning, requester from ....utils import httpclient import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -164,6 +164,39 @@ class LiteLLMRequester(requester.ProviderAPIRequester): _EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding') _RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank') + _QWEN_DEDICATED_THINKING_MODELS = frozenset( + { + 'qwen3.7-max-preview', + 'qwen3.7-max-2026-05-17', + } + ) + _QWEN_REASONING_BUDGETS = { + 'low': 1024, + 'medium': 4096, + 'high': 8192, + } + _INFERRED_EFFORT_PROVIDERS = frozenset( + { + 'anthropic', + 'gemini', + 'groq', + 'mistral', + 'openai', + 'openrouter', + 'together_ai', + 'xai', + } + ) + _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': '', @@ -172,6 +205,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 'drop_params': False, 'num_retries': 0, 'api_version': '', + 'requester_name': '', } async def initialize(self): @@ -201,7 +235,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester): return False provider = self._get_custom_llm_provider() - candidates: list[tuple[str, str | None]] = [(model_name, provider)] + candidates: list[tuple[str, str | None]] = [ + (candidate, None) for candidate in self._metadata_model_candidates(model_name) + ] + candidates.append((model_name, provider)) litellm_model_name = self._build_litellm_model_name(model_name) if litellm_model_name != model_name: candidates.append((litellm_model_name, None)) @@ -268,6 +305,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester): deduped_candidates.append(candidate) return deduped_candidates + @staticmethod + def _metadata_model_candidates(model_name: str) -> list[str]: + """Return known equivalent model IDs used only for LiteLLM metadata lookup.""" + normalized_model_name = (model_name or '').lower() + if normalized_model_name.startswith('mimo-v2.5'): + return [f'openrouter/xiaomi/{normalized_model_name}'] + return [] + def _known_context_length_fallback(self, model_name: str) -> int | None: normalized_model_name = (model_name or '').lower() if normalized_model_name.startswith('deepseek-v4-'): @@ -287,7 +332,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if not callable(helper): return self._known_context_length_fallback(model_name) - candidates = [model_name] + candidates = self._metadata_model_candidates(model_name) + candidates.append(model_name) litellm_model_name = self._build_litellm_model_name(model_name) if litellm_model_name != model_name: candidates.append(litellm_model_name) @@ -314,6 +360,297 @@ class LiteLLMRequester(requester.ProviderAPIRequester): def _supports_vision(self, model_name: str) -> bool: return self._safe_litellm_bool_helper('supports_vision', model_name) + def _supports_reasoning(self, model_name: str) -> bool: + return self._safe_litellm_bool_helper('supports_reasoning', model_name) + + 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 '' + + # Bailian's compatible endpoint also hosts Kimi models. Keep those + # models on Kimi's ``thinking`` protocol instead of Qwen's + # ``enable_thinking`` protocol. + if requester_name == 'bailian-chat-completions': + inferred_family = self._infer_reasoning_family_from_model_name(model_name) + if inferred_family == 'kimi': + return inferred_family + + 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 + + @staticmethod + def _is_anthropic_adaptive_model(model_name: str) -> bool: + basename = model_name.lower().rsplit('/', 1)[-1] + if 'mythos-preview' in basename: + return True + + 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 in LiteLLMRequester._QWEN_DEDICATED_THINKING_MODELS + or normalized_name.startswith('qwq') + 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] + + 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): + 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-')): + 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) + if not callable(helper): + return {} + + candidates = [ + *self._metadata_model_candidates(model_name), + model_name, + self._build_litellm_model_name(model_name), + ] + for candidate in candidates: + try: + info = helper(candidate) + except Exception: + continue + if isinstance(info, dict): + return info + model_dump = getattr(info, 'model_dump', None) + if callable(model_dump): + try: + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + except Exception: + continue + return {} + + def get_reasoning_capabilities(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]: + model_name = model.model_entity.name + abilities = model.model_entity.abilities or [] + detected = self._supports_reasoning(model_name) + declared = 'reasoning' in abilities + 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() + 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 family in self._INFERRED_EFFORT_PROVIDERS: + levels = ['provider_default', 'low', 'medium', 'high'] + else: + levels = ['provider_default'] + + 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) + 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 + + family = self._reasoning_family(model.model_entity.name, model) + if level == 'disabled': + if family in {'deepseek', 'kimi', 'mimo', 'doubao'}: + return {'extra_body': {'thinking': {'type': 'disabled'}}} + 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 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 == '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': { + '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): @@ -344,6 +681,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): ) if supports_provider_reported_vision or self._supports_vision(model_id): abilities.append('vision') + supports_provider_reported_reasoning = bool( + model_payload and model_payload.get('supports_reasoning') is True + ) + 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 context_length = self._context_length_from_scan_payload(model_payload) @@ -354,13 +698,51 @@ 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) + + # ``content`` is also used for the user-facing rendering. + # Do not replay that rendered wrapper alongside the + # structured provider reasoning on the next request. + if reasoning_content or thinking_blocks: + content = msg_dict.get('content') + if isinstance(content, str): + msg_dict['content'] = self._strip_think(content) + + 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: @@ -421,6 +803,52 @@ class LiteLLMRequester(requester.ProviderAPIRequester): return content or '' + @staticmethod + def _thinking_blocks_text(thinking_blocks: typing.Any) -> str: + if not isinstance(thinking_blocks, list): + return '' + parts = [] + for block in thinking_blocks: + if isinstance(block, dict): + text = block.get('thinking') + else: + text = getattr(block, 'thinking', None) + if isinstance(text, str) and text: + parts.append(text) + return ''.join(parts) + + @classmethod + def _merge_thinking_blocks( + cls, + current: list[dict[str, typing.Any]], + incoming: typing.Any, + ) -> list[dict[str, typing.Any]]: + """Merge Anthropic thinking block fragments emitted by a stream.""" + if not isinstance(incoming, list): + return current + merged = [dict(block) for block in current] + for raw_block in incoming: + block = cls._as_dict(raw_block) + if not block: + continue + block_type = block.get('type') + if block_type == 'redacted_thinking': + merged.append(block) + continue + + text = block.get('thinking') if isinstance(block.get('thinking'), str) else '' + signature = block.get('signature') + if merged and merged[-1].get('type') == 'thinking' and not merged[-1].get('signature'): + merged[-1]['thinking'] = f'{merged[-1].get("thinking", "")}{text}' + if signature: + merged[-1]['signature'] = signature + elif merged and signature and merged[-1].get('signature') == signature: + if text and text != merged[-1].get('thinking', ''): + merged[-1]['thinking'] = f'{merged[-1].get("thinking", "")}{text}' + else: + merged.append(block) + return merged + @staticmethod def _normalize_usage(usage: typing.Any) -> dict: """Normalize a LiteLLM/OpenAI usage object into a plain token dict. @@ -651,7 +1079,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() @@ -670,6 +1104,29 @@ class LiteLLMRequester(requester.ProviderAPIRequester): args.update(model.model_entity.extra_args) args.update(extra_args) + reasoning_args = self._build_reasoning_args(model) + if reasoning_args: + conflicts = reasoning.find_reasoning_arg_conflicts(model.model_entity.extra_args) + conflicts.extend(reasoning.find_reasoning_arg_conflicts(extra_args)) + if conflicts: + raise errors.RequesterError( + 'reasoning_config conflicts with advanced parameters: ' + ', '.join(dict.fromkeys(conflicts)) + ) + reasoning_extra_body = reasoning_args.get('extra_body') + if isinstance(reasoning_extra_body, dict): + existing_extra_body = args.get('extra_body') or {} + if not isinstance(existing_extra_body, dict): + raise errors.RequesterError('extra_body must be an object') + args.update({key: value for key, value in reasoning_args.items() if key != 'extra_body'}) + args['extra_body'] = {**existing_extra_body, **reasoning_extra_body} + else: + args.update(reasoning_args) + 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') + args['allowed_openai_params'] = list(dict.fromkeys([*allowed_openai_params, 'reasoning_effort'])) + if funcs: tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs) if tools: @@ -699,10 +1156,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester): content = message_data.get('content', '') reasoning_content = message_data.get('reasoning_content', None) - message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think) + thinking_blocks = message_data.get('thinking_blocks') + if reasoning_content or thinking_blocks: + provider_fields = dict(message_data.get('provider_specific_fields') or {}) + if reasoning_content: + provider_fields['reasoning_content'] = reasoning_content + if thinking_blocks: + provider_fields['thinking_blocks'] = thinking_blocks + message_data['provider_specific_fields'] = provider_fields + display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None + message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think) if 'reasoning_content' in message_data: del message_data['reasoning_content'] + if 'thinking_blocks' in message_data: + del message_data['thinking_blocks'] message = provider_message.Message(**message_data) usage_info = self._extract_usage(response) @@ -728,6 +1196,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): role = 'assistant' tool_call_state: dict[int, dict[str, typing.Any]] = {} think_state = _ThinkStripState() if remove_think else None + reasoning_started = False + reasoning_closed = False + thinking_blocks_state: list[dict[str, typing.Any]] = [] try: response = await acompletion(**args) @@ -758,28 +1229,63 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if 'role' in delta and delta['role']: role = delta['role'] - delta_content = delta.get('content', '') - reasoning_content = delta.get('reasoning_content', '') + delta_content = delta.get('content') or '' + reasoning_content = delta.get('reasoning_content') or '' + provider_fields = dict(delta.get('provider_specific_fields') or {}) + raw_thinking_blocks = delta.get('thinking_blocks') + if raw_thinking_blocks: + thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks) + provider_fields['thinking_blocks'] = thinking_blocks_state + thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks) + display_reasoning_content = reasoning_content or thinking_blocks_text # Handle reasoning_content based on remove_think flag if reasoning_content: + provider_fields['reasoning_content'] = reasoning_content if remove_think: - # Skip reasoning content when remove_think is True - chunk_idx += 1 - continue + delta_content = delta_content or None else: - # Use reasoning_content as the displayed content - delta_content = reasoning_content + # Stream explicit markers so downstream adapters and + # the debug page see the same format as non-streaming + # responses. + if not reasoning_started: + delta_content = '\n' + reasoning_started = True + else: + delta_content = '' + delta_content += display_reasoning_content + if delta.get('content'): + delta_content += f'\n\n{delta.get("content")}' + reasoning_closed = True + + elif display_reasoning_content: + if remove_think: + delta_content = delta_content or None + else: + if not reasoning_started: + delta_content = '\n' + reasoning_started = True + else: + delta_content = '' + delta_content += display_reasoning_content + if delta.get('content'): + delta_content += f'\n\n{delta.get("content")}' + reasoning_closed = True + + elif delta_content and not remove_think and reasoning_started and not reasoning_closed: + delta_content = f'\n\n{delta_content}' + reasoning_closed = True + + if finish_reason and not remove_think and reasoning_started and not reasoning_closed: + delta_content = f'{delta_content}\n\n' + reasoning_closed = True if think_state is not None and delta_content: delta_content = think_state.feed(delta_content) - if not delta_content: - chunk_idx += 1 - continue tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state) - if chunk_idx == 0 and not delta_content and not tool_calls: + if not delta_content and not tool_calls and not provider_fields and not finish_reason: chunk_idx += 1 continue @@ -791,13 +1297,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester): } # Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures) - if delta.get('provider_specific_fields'): - chunk_data['provider_specific_fields'] = delta['provider_specific_fields'] + if provider_fields: + chunk_data['provider_specific_fields'] = provider_fields chunk_data = {k: v for k, v in chunk_data.items() if v is not None} yield provider_message.MessageChunk(**chunk_data) chunk_idx += 1 + if reasoning_started and not reasoning_closed: + yield provider_message.MessageChunk( + role=role, + content='\n\n', + is_final=True, + ) + if think_state is not None: pending_content = think_state.flush() if pending_content: diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index dc8f82dcb..2bb6f9a6f 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -6,6 +6,7 @@ import typing from .. import runner from ...telemetry import features as telemetry_features from ..modelmgr import requester as modelmgr_requester +from ..modelmgr import reasoning as modelmgr_reasoning from ..tools.loaders.native import EXEC_TOOL_NAME import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -60,6 +61,7 @@ class _StreamAccumulator: self.msg_idx = 0 self.accumulated_content = initial_content or '' self.last_role = 'assistant' + self.provider_specific_fields: dict[str, typing.Any] = {} self.msg_sequence = msg_sequence self.remove_think = remove_think self._think_state = None @@ -90,10 +92,27 @@ class _StreamAccumulator: name=tool_call.function.name if tool_call.function else '', arguments='', ), + provider_specific_fields=( + dict(tool_call.provider_specific_fields) if tool_call.provider_specific_fields else None + ), ) + elif tool_call.provider_specific_fields: + existing_fields = self.tool_calls_map[tool_call.id].provider_specific_fields or {} + self.tool_calls_map[tool_call.id].provider_specific_fields = { + **existing_fields, + **tool_call.provider_specific_fields, + } if tool_call.function and tool_call.function.arguments: self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments + if msg.provider_specific_fields: + for key, value in msg.provider_specific_fields.items(): + if key == 'reasoning_content' and isinstance(value, str): + previous = self.provider_specific_fields.get(key, '') + self.provider_specific_fields[key] = f'{previous}{value}' + else: + self.provider_specific_fields[key] = value + if msg.is_final: self._flush_think_state() @@ -103,6 +122,7 @@ class _StreamAccumulator: role=self.last_role, content=self._maybe_strip_think(self.accumulated_content), tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None, + provider_specific_fields=(self.provider_specific_fields or None) if msg.is_final else None, is_final=msg.is_final, msg_sequence=self.msg_sequence, ) @@ -115,6 +135,7 @@ class _StreamAccumulator: role=self.last_role, content=self._maybe_strip_think(self.accumulated_content), tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None, + provider_specific_fields=self.provider_specific_fields or None, msg_sequence=self.msg_sequence, ) @@ -233,9 +254,10 @@ class LocalAgentRunner(runner.RequestRunner): execution_context, query.use_llm_model_uuid, ) - candidates.append(primary) except ValueError: self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found') + else: + candidates.append(LocalAgentRunner._apply_pipeline_reasoning_config(query, primary)) # Fallback models fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', []) @@ -245,12 +267,31 @@ class LocalAgentRunner(runner.RequestRunner): execution_context, fb_uuid, ) - candidates.append(fb_model) except ValueError: self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') + else: + candidates.append(LocalAgentRunner._apply_pipeline_reasoning_config(query, fb_model)) return candidates + @staticmethod + def _apply_pipeline_reasoning_config( + query: pipeline_query.Query, + model: modelmgr_requester.RuntimeLLMModel, + ) -> modelmgr_requester.RuntimeLLMModel: + local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) + model_config = local_agent_config.get('model', {}) + reasoning_by_model = model_config.get('reasoning', {}) if isinstance(model_config, dict) else {} + level = ( + reasoning_by_model.get(model.model_entity.uuid, 'provider_default') + if isinstance(reasoning_by_model, dict) + else 'provider_default' + ) + reasoning_config = modelmgr_reasoning.normalize_reasoning_config({'level': level}) + configured_model = copy.copy(model) + configured_model.reasoning_config_override = reasoning_config + return configured_model + async def _invoke_with_fallback( self, query: pipeline_query.Query, diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index b5c5eb79f..063f91d73 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -92,6 +92,7 @@ stages: default: primary: '' fallbacks: [] + reasoning: {} - name: max-round label: en_US: Max Round diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index e7106f939..729683f9f 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -9,8 +9,11 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q from __future__ import annotations +import json + import pytest import sqlalchemy +from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.entity.persistence.base import Base @@ -105,7 +108,18 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0020_membership_source' + assert _get_script_head() == '0021_merge_reasoning_config' + + @pytest.mark.asyncio + async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): + """A database that already ran the feature migration must remain upgradable.""" + async with sqlite_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') + await run_alembic_upgrade(sqlite_engine, 'head') + + assert await get_alembic_current(sqlite_engine) == '0021_merge_reasoning_config' @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): @@ -202,6 +216,66 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() + @pytest.mark.asyncio + async def test_reasoning_config_migrates_existing_models(self, sqlite_engine): + """Upgrade from 0017 backfills reasoning config and keeps a database default.""" + async with sqlite_engine.begin() as conn: + await conn.execute( + text( + """ + CREATE TABLE llm_models ( + uuid VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + provider_uuid VARCHAR(255) NOT NULL, + abilities JSON NOT NULL, + context_length INTEGER, + extra_args JSON NOT NULL, + prefered_ranking INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + ) + await conn.execute( + text( + """ + INSERT INTO llm_models ( + uuid, name, provider_uuid, abilities, extra_args, prefered_ranking + ) VALUES ( + 'existing-model', 'Existing Model', 'provider', '[]', '{}', 0 + ) + """ + ) + ) + + await run_alembic_stamp(sqlite_engine, '0017_oss_workspace_identity') + await run_alembic_upgrade(sqlite_engine, 'head') + + async with sqlite_engine.begin() as conn: + columns = await conn.run_sync(lambda sync_conn: sqlalchemy.inspect(sync_conn).get_columns('llm_models')) + reasoning_column = next(column for column in columns if column['name'] == 'reasoning_config') + assert reasoning_column['nullable'] is False + + existing_value = ( + await conn.execute(text("SELECT reasoning_config FROM llm_models WHERE uuid = 'existing-model'")) + ).scalar_one() + assert json.loads(existing_value) == {'level': 'provider_default'} + + await conn.execute( + text( + """ + INSERT INTO llm_models ( + uuid, name, provider_uuid, abilities, extra_args, prefered_ranking + ) VALUES ( + 'new-model', 'New Model', 'provider', '[]', '{}', 0 + ) + """ + ) + ) + new_value = ( + await conn.execute(text("SELECT reasoning_config FROM llm_models WHERE uuid = 'new-model'")) + ).scalar_one() + assert json.loads(new_value) == {'level': 'provider_default'} + class TestSQLiteMigrationFreshDatabase: """Tests for fresh database workflow.""" diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 7e6af718a..91afa0916 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -17,12 +17,14 @@ import pytest from unittest.mock import AsyncMock, Mock from types import SimpleNamespace +from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.api.http.service.model import ( LLMModelsService, EmbeddingModelsService, RerankModelsService, _parse_provider_api_keys, _runtime_model_data, + _serialize_llm_model, _validate_provider_supports, ) from langbot.pkg.api.http.service import model as model_service_module @@ -64,15 +66,19 @@ def _create_mock_llm_model( abilities: list = None, context_length: int | None = None, extra_args: dict = None, + reasoning_config: dict = None, ) -> Mock: """Helper to create mock LLMModel entity.""" model = Mock(spec=LLMModel) + model.workspace_uuid = WORKSPACE_UUID model.uuid = model_uuid model.name = name model.provider_uuid = provider_uuid model.abilities = abilities or [] model.context_length = context_length model.extra_args = extra_args or {} + model.reasoning_config = reasoning_config or {'level': 'provider_default'} + model.prefered_ranking = 0 return model @@ -156,6 +162,26 @@ def _create_runtime_model_mgr() -> SimpleNamespace: return manager +def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace: + execution_context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid=WORKSPACE_UUID, + placement_generation=1, + ) + return SimpleNamespace( + execution_context=execution_context, + provider_entity=ModelProvider( + workspace_uuid=WORKSPACE_UUID, + uuid='provider-uuid', + name='Reasoning Provider', + requester='openai', + base_url='https://api.openai.com', + api_keys=[], + ), + requester=SimpleNamespace(get_reasoning_capabilities=Mock(return_value=capabilities)), + ) + + class TestParseProviderApiKeys: """Tests for _parse_provider_api_keys helper function.""" @@ -209,6 +235,42 @@ class TestRuntimeModelData: assert result['extra_args'] == {'temp': 0.7} +class TestSerializeLLMModel: + def test_includes_runtime_reasoning_capabilities(self): + model = _create_mock_llm_model( + abilities=['reasoning'], + reasoning_config={'level': 'high'}, + ) + capabilities = { + 'supported': True, + 'levels': ['provider_default', 'low', 'high'], + 'source': 'litellm', + } + runtime_model = SimpleNamespace( + model_entity=model, + provider=SimpleNamespace( + requester=SimpleNamespace(get_reasoning_capabilities=Mock(return_value=capabilities)) + ), + ) + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace( + serialize_model=Mock( + return_value={ + 'uuid': model.uuid, + 'name': model.name, + 'reasoning_config': {'level': 'high'}, + } + ) + ), + model_mgr=SimpleNamespace(llm_model_dict={('workspace', model.uuid): runtime_model}), + ) + + serialized = _serialize_llm_model(ap, model) + + assert serialized['reasoning_config'] == {'level': 'high'} + assert serialized['reasoning_capabilities'] == capabilities + + class TestLLMModelsServiceGetLLMModels: """Tests for LLMModelsService.get_llm_models method.""" @@ -580,6 +642,66 @@ class TestLLMModelsServiceCreateLLMModel: ap.provider_service.find_or_create_provider.assert_called_once() assert result_uuid is not None + async def test_create_llm_model_validates_explicit_reasoning_level(self): + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=_create_mock_result([]))) + runtime_provider = _create_reasoning_runtime_provider( + { + 'supported': True, + 'levels': ['provider_default', 'low', 'high'], + 'source': 'litellm', + } + ) + ap.model_mgr = _create_runtime_model_mgr() + ap.model_mgr.provider_dict = {'provider-uuid': runtime_provider} + + service = LLMModelsService(ap) + await service.create_llm_model( + WORKSPACE_UUID, + { + 'uuid': 'reasoning-model', + 'name': 'Reasoning Model', + 'provider_uuid': 'provider-uuid', + 'abilities': ['reasoning'], + 'reasoning_config': {'level': 'high'}, + 'extra_args': {}, + }, + preserve_uuid=True, + auto_set_to_default_pipeline=False, + ) + + runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[1] + assert runtime_entity.reasoning_config == {'level': 'high'} + + async def test_create_llm_model_rejects_unsupported_reasoning_before_insert(self): + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) + runtime_provider = _create_reasoning_runtime_provider( + { + 'supported': True, + 'levels': ['provider_default'], + 'source': 'manual', + } + ) + ap.model_mgr = _create_runtime_model_mgr() + ap.model_mgr.provider_dict = {'provider-uuid': runtime_provider} + + service = LLMModelsService(ap) + with pytest.raises(ValueError, match='Available levels: provider_default'): + await service.create_llm_model( + WORKSPACE_UUID, + { + 'name': 'Unknown Reasoning Model', + 'provider_uuid': 'provider-uuid', + 'abilities': ['reasoning'], + 'reasoning_config': {'level': 'high'}, + 'extra_args': {}, + }, + auto_set_to_default_pipeline=False, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + class TestLLMModelsServiceUpdateLLMModel: """Tests for LLMModelsService.update_llm_model method.""" @@ -595,7 +717,10 @@ class TestLLMModelsServiceUpdateLLMModel: ap.model_mgr.remove_llm_model = AsyncMock() ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock()) - ap.persistence_mgr.execute_async = AsyncMock() + existing_model = _create_mock_llm_model() + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[_create_mock_result(first_item=existing_model), _create_mock_result()] + ) service = LLMModelsService(ap) service.get_llm_model = AsyncMock(return_value=_existing_llm_data()) @@ -623,7 +748,8 @@ class TestLLMModelsServiceUpdateLLMModel: ap.model_mgr.provider_dict = {} # Empty ap.model_mgr.remove_llm_model = AsyncMock() - ap.persistence_mgr.execute_async = AsyncMock() + existing_model = _create_mock_llm_model() + ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_model)) service = LLMModelsService(ap) service.get_llm_model = AsyncMock(return_value=_existing_llm_data('nonexistent-provider')) diff --git a/tests/unit_tests/pipeline/test_controller_tenancy.py b/tests/unit_tests/pipeline/test_controller_tenancy.py index ac999e56b..d3617179b 100644 --- a/tests/unit_tests/pipeline/test_controller_tenancy.py +++ b/tests/unit_tests/pipeline/test_controller_tenancy.py @@ -29,6 +29,57 @@ def _prepare_scheduler(mock_app): return query_pool, session +@pytest.mark.asyncio +async def test_consumer_schedules_query_after_running_transition( + mock_app, + sample_query, +): + query_pool = MagicMock() + query_pool.queries = [sample_query] + query_pool.__aenter__ = AsyncMock(return_value=query_pool) + query_pool.__aexit__ = AsyncMock(return_value=None) + query_pool.remove_query = AsyncMock(return_value=True) + wait_for_query = asyncio.Event() + query_pool.condition = SimpleNamespace( + wait=AsyncMock(side_effect=wait_for_query.wait), + notify_all=Mock(), + ) + query_pool.mark_query_running_locked = Mock(side_effect=query_pool.queries.remove) + mock_app.query_pool = query_pool + + session = SimpleNamespace(_semaphore=asyncio.Semaphore(1)) + mock_app.sess_mgr.get_session = AsyncMock(return_value=session) + runtime_pipeline = SimpleNamespace(run=AsyncMock()) + mock_app.pipeline_mgr = SimpleNamespace(get_pipeline_by_uuid=AsyncMock(return_value=runtime_pipeline)) + + task_created = asyncio.Event() + process_tasks = [] + + def create_process_task(coro, **_kwargs): + process_tasks.append(asyncio.create_task(coro)) + task_created.set() + + mock_app.task_mgr.create_task = Mock(side_effect=create_process_task) + controller = Controller(mock_app) + initial_slots = controller.semaphore._value + consumer_task = asyncio.create_task(controller.consumer()) + + try: + await asyncio.wait_for(task_created.wait(), timeout=2) + finally: + consumer_task.cancel() + with pytest.raises(asyncio.CancelledError): + await consumer_task + await asyncio.gather(*process_tasks) + + query_pool.mark_query_running_locked.assert_called_once_with(sample_query) + runtime_pipeline.run.assert_awaited_once_with(sample_query) + query_pool.remove_query.assert_awaited_once_with(sample_query) + assert query_pool.queries == [] + assert session._semaphore._value == 1 + assert controller.semaphore._value == initial_slots + + @pytest.mark.asyncio async def test_controller_drops_stale_query_before_pipeline_lookup( mock_app, diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py index 958ba5888..37c352d17 100644 --- a/tests/unit_tests/platform/test_websocket_session_isolation.py +++ b/tests/unit_tests/platform/test_websocket_session_isolation.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock import pytest import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession from langbot.pkg.platform.sources.websocket_manager import ( @@ -343,6 +344,49 @@ async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch ) +@pytest.mark.asyncio +async def test_dashboard_reply_survives_connection_replacement(monkeypatch): + manager = WebSocketConnectionManager() + original = await manager.add_connection( + websocket=Mock(), + scope=SCOPE_A, + pipeline_uuid='pipeline-1', + session_type='person', + ) + monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager) + + adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger()) + adapter.websocket_person_session = WebSocketSession(id='person') + adapter.websocket_group_session = WebSocketSession(id='group') + received = [] + + async def listener(event, _callback_adapter): + received.append(event) + + adapter.listeners = {platform_events.FriendMessage: listener} + await adapter.handle_websocket_message( + original, + {'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False}, + ) + await asyncio.sleep(0) + await manager.remove_connection(original.connection_id) + replacement = await manager.add_connection( + websocket=Mock(), + scope=SCOPE_A, + pipeline_uuid='pipeline-1', + session_type='person', + ) + + await adapter.reply_message( + received[0], + platform_message.MessageChain([platform_message.Plain(text='done')]), + ) + + response = await replacement.send_queue.get() + assert response['type'] == 'response' + assert response['data']['content'] == 'done' + + def test_session_ids_must_be_canonical_random_uuids(): assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1') assert not is_valid_session_id('session-a') diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index d0332f726..b666c3339 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -1304,6 +1304,7 @@ class TestScanModels: ) requester._supports_function_calling = Mock(side_effect=lambda model_id: model_id == 'gpt-4o') requester._supports_vision = Mock(side_effect=lambda model_id: model_id == 'gpt-4o') + requester._supports_reasoning = Mock(side_effect=lambda model_id: model_id == 'o3') requester._safe_context_length = Mock(side_effect=lambda model_id: 128000 if model_id == 'gpt-4o' else None) mock_response = Mock() @@ -1311,6 +1312,7 @@ class TestScanModels: return_value={ 'data': [ {'id': 'gpt-4o'}, + {'id': 'o3'}, {'id': 'text-embedding-3-small'}, {'id': 'bge-reranker-v2'}, ] @@ -1327,6 +1329,7 @@ class TestScanModels: by_id = {model['id']: model for model in result['models']} assert by_id['gpt-4o']['abilities'] == ['func_call', 'vision'] assert by_id['gpt-4o']['context_length'] == 128000 + assert by_id['o3']['abilities'] == ['reasoning'] assert by_id['text-embedding-3-small']['type'] == 'embedding' assert by_id['bge-reranker-v2']['type'] == 'rerank' @@ -1374,8 +1377,8 @@ class TestScanModels: ) with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info: - mock_get_model_info.side_effect = ( - lambda model: {'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {} + mock_get_model_info.side_effect = lambda model: ( + {'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {} ) assert requester._safe_context_length('moonshot-v1-128k') == 131072 @@ -1404,8 +1407,8 @@ class TestScanModels: ) with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling: - mock_supports_function_calling.side_effect = ( - lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None + mock_supports_function_calling.side_effect = lambda model, custom_llm_provider=None: ( + model == 'moonshot/kimi-k2.6' and custom_llm_provider is None ) assert requester._supports_function_calling('kimi-k2.6') is True diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py index e912d4e27..e9426f6b1 100644 --- a/tests/unit_tests/provider/test_localagent_sandbox_exec.py +++ b/tests/unit_tests/provider/test_localagent_sandbox_exec.py @@ -178,6 +178,27 @@ def test_stream_accumulator_merges_fragmented_tool_call_arguments(): assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}' +def test_stream_accumulator_preserves_tool_call_provider_specific_fields(): + accumulator = _StreamAccumulator() + emitted = accumulator.add( + provider_message.MessageChunk( + role='assistant', + tool_calls=[ + provider_message.ToolCall( + id='call-gemini', + type='function', + function=provider_message.FunctionCall(name='lookup', arguments='{}'), + provider_specific_fields={'thought_signature': 'sig'}, + ) + ], + is_final=True, + ) + ) + + assert emitted is not None + assert emitted.tool_calls[0].provider_specific_fields == {'thought_signature': 'sig'} + + def test_stream_accumulator_strips_leading_think_from_tool_round_content(): accumulator = _StreamAccumulator( msg_sequence=3, diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py index ba184de2b..bd11ccac1 100644 --- a/tests/unit_tests/provider/test_model_service.py +++ b/tests/unit_tests/provider/test_model_service.py @@ -249,7 +249,11 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() 'ai': { 'runner': {'runner': 'local-agent'}, 'local-agent': { - 'model': {'primary': model_uuid, 'fallbacks': []}, + 'model': { + 'primary': model_uuid, + 'fallbacks': [], + 'reasoning': {model_uuid: 'high'}, + }, 'prompt': [], 'knowledge-bases': [], }, @@ -293,3 +297,134 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query) assert [model.model_entity.uuid for model in candidates] == [model_uuid] + assert candidates[0].reasoning_config_override == {'level': 'high'} + + +@pytest.mark.asyncio +async def test_local_agent_applies_reasoning_per_fallback_model(): + execution_context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid='workspace-test', + placement_generation=1, + ) + provider = Mock( + execution_context=execution_context, + provider_entity=persistence_model.ModelProvider( + workspace_uuid='workspace-test', + uuid='provider', + name='provider', + requester='openai', + base_url='https://example.com', + api_keys=[], + ), + ) + primary = requester.RuntimeLLMModel( + execution_context, + persistence_model.LLMModel( + workspace_uuid='workspace-test', + uuid='primary-model', + name='primary', + provider_uuid='provider', + abilities=['reasoning'], + extra_args={}, + ), + provider, + ) + fallback = requester.RuntimeLLMModel( + execution_context, + persistence_model.LLMModel( + workspace_uuid='workspace-test', + uuid='fallback-model', + name='fallback', + provider_uuid='provider', + abilities=['reasoning'], + extra_args={}, + ), + provider, + ) + models = {'primary-model': primary, 'fallback-model': fallback} + runner = SimpleNamespace( + ap=SimpleNamespace( + model_mgr=SimpleNamespace( + get_model_by_uuid=AsyncMock(side_effect=lambda _context, model_uuid: models[model_uuid]), + ), + logger=Mock(), + ) + ) + query = SimpleNamespace( + use_llm_model_uuid='primary-model', + variables={'_fallback_model_uuids': ['fallback-model']}, + pipeline_config={ + 'ai': { + 'local-agent': { + 'model': { + 'primary': 'primary-model', + 'fallbacks': ['fallback-model'], + 'reasoning': { + 'primary-model': 'low', + 'fallback-model': 'high', + }, + } + } + } + }, + _execution_context=execution_context, + ) + + candidates = await LocalAgentRunner._get_model_candidates(runner, query) + + assert [candidate.reasoning_config_override for candidate in candidates] == [ + {'level': 'low'}, + {'level': 'high'}, + ] + assert candidates[0] is not primary + assert candidates[1] is not fallback + assert primary.reasoning_config_override is None + assert fallback.reasoning_config_override is None + + +def test_local_agent_rejects_invalid_pipeline_reasoning_level(): + execution_context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid='workspace-test', + placement_generation=1, + ) + provider = Mock( + execution_context=execution_context, + provider_entity=persistence_model.ModelProvider( + workspace_uuid='workspace-test', + uuid='provider', + name='provider', + requester='openai', + base_url='https://example.com', + api_keys=[], + ), + ) + model = requester.RuntimeLLMModel( + execution_context, + persistence_model.LLMModel( + workspace_uuid='workspace-test', + uuid='primary-model', + name='primary', + provider_uuid='provider', + abilities=['reasoning'], + extra_args={}, + ), + provider, + ) + query = SimpleNamespace( + pipeline_config={ + 'ai': { + 'local-agent': { + 'model': { + 'primary': 'primary-model', + 'fallbacks': [], + 'reasoning': {'primary-model': 'turbo'}, + } + } + } + } + ) + + with pytest.raises(ValueError, match='Unsupported reasoning level'): + LocalAgentRunner._apply_pipeline_reasoning_config(query, model) diff --git a/tests/unit_tests/provider/test_reasoning_control.py b/tests/unit_tests/provider/test_reasoning_control.py new file mode 100644 index 000000000..1affff784 --- /dev/null +++ b/tests/unit_tests/provider/test_reasoning_control.py @@ -0,0 +1,872 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import langbot_plugin.api.entities.builtin.provider.message as provider_message +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.entity.persistence import model as persistence_model +from langbot.pkg.provider.modelmgr import errors, reasoning, requester +from langbot.pkg.provider.modelmgr.requesters import litellmchat +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester +from langbot.pkg.provider.runners.localagent import _StreamAccumulator + + +def _runtime_model( + request: LiteLLMRequester, + 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', + workspace_uuid='workspace-test', + placement_generation=1, + ) + entity = persistence_model.LLMModel( + workspace_uuid='workspace-test', + uuid='reasoning-model', + name=name, + provider_uuid='provider-test', + abilities=abilities if abilities is not None else ['reasoning'], + reasoning_config={'level': level}, + extra_args={}, + ) + provider = SimpleNamespace( + execution_context=execution_context, + provider_entity=persistence_model.ModelProvider( + workspace_uuid='workspace-test', + uuid='provider-test', + name='provider', + requester=requester_name or request.requester_cfg.get('requester_name') or 'custom-requester', + base_url='https://example.com', + api_keys=[], + ), + requester=request, + token_mgr=SimpleNamespace(), + ) + return requester.RuntimeLLMModel(execution_context, entity, 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(): + assert reasoning.normalize_reasoning_config(None) == {'level': 'provider_default'} + assert reasoning.normalize_reasoning_config({}) == {'level': 'provider_default'} + assert reasoning.validate_reasoning_config( + {'level': 'high'}, + ['reasoning'], + {}, + ) == {'level': 'high'} + + with pytest.raises(ValueError, match='Unsupported reasoning level'): + reasoning.normalize_reasoning_config({'level': 'turbo'}) + with pytest.raises(ValueError, match='reasoning ability'): + reasoning.validate_reasoning_config({'level': 'low'}, [], {}) + with pytest.raises(ValueError, match='extra_body.thinking_budget'): + reasoning.validate_reasoning_config( + {'level': 'low'}, + ['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_without_known_protocol_stays_conservative(monkeypatch): + request = _requester() + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + capabilities = request.get_reasoning_capabilities(_runtime_model(request)) + + assert capabilities == { + 'supported': True, + 'levels': ['provider_default'], + 'source': 'manual', + } + + +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) + + capabilities = request.get_reasoning_capabilities( + _runtime_model(request, name='future-reasoning-model', abilities=[]) + ) + + assert capabilities == { + 'supported': False, + 'levels': ['provider_default'], + 'source': 'unknown', + } + + +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'], + 'source': 'unknown', + } + + +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', 'disabled', 'enabled'], + 'source': 'provider', + } + + +def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch): + request = _requester('openai', 'openai-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr( + request, + '_safe_model_info', + lambda _: { + 'supports_none_reasoning_effort': True, + 'supports_minimal_reasoning_effort': False, + 'supports_low_reasoning_effort': True, + 'supports_xhigh_reasoning_effort': True, + }, + ) + + capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='gpt-5')) + + assert capabilities['source'] == 'litellm' + assert capabilities['levels'] == [ + 'provider_default', + 'disabled', + 'low', + 'medium', + 'high', + 'xhigh', + ] + + +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 mixed['levels'] == ['provider_default', 'disabled', 'enabled'] + 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']) +def test_qwen_dedicated_thinking_release_models_are_not_toggleable(model_name, monkeypatch): + request = _requester('openai', 'bailian-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[])) + + assert capabilities['levels'] == ['provider_default', 'low', 'medium', 'high'] + + +@pytest.mark.parametrize( + ('model_name', 'expected_levels'), + [ + ('kimi-k2.6', ['provider_default', 'disabled', 'enabled']), + ('kimi-k2.5', ['provider_default', 'disabled', 'enabled']), + ('kimi-k2.7-code', ['provider_default']), + ('kimi-k2-thinking', ['provider_default']), + ], +) +def test_bailian_kimi_profiles_use_kimi_model_rules(model_name, expected_levels, monkeypatch): + request = _requester('openai', 'bailian-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_bailian_kimi_uses_thinking_protocol_instead_of_qwen_protocol(monkeypatch): + request = _requester('openai', 'bailian-chat-completions') + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + + assert request._build_reasoning_args(_runtime_model(request, 'disabled', name='kimi-k2.6')) == { + 'extra_body': {'thinking': {'type': 'disabled'}} + } + + +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' + } + + 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, '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', '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') + + model.reasoning_config_override = {'level': 'provider_default'} + assert request._build_reasoning_args(model) == {} + + model.reasoning_config_override = {'level': 'low'} + assert request._build_reasoning_args(model) == {'reasoning_effort': 'low'} + + +def test_always_on_reasoning_models_do_not_offer_disabled(monkeypatch): + 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'] + + gemini_request = _requester('gemini') + monkeypatch.setattr(gemini_request, '_supports_reasoning', lambda _: True) + monkeypatch.setattr( + gemini_request, + '_safe_model_info', + lambda _: {'supports_none_reasoning_effort': True}, + ) + gemini_capabilities = gemini_request.get_reasoning_capabilities(_runtime_model(gemini_request, name='gemini-3-pro')) + assert 'disabled' not in gemini_capabilities['levels'] + with pytest.raises(errors.RequesterError, match='not supported'): + gemini_request._build_reasoning_args(_runtime_model(gemini_request, 'disabled', name='gemini-3-pro')) + + +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 _: {}) + + toggle_capabilities = ollama_request.get_reasoning_capabilities(_runtime_model(ollama_request, name='qwen3')) + assert toggle_capabilities['levels'] == [ + 'provider_default', + 'disabled', + 'enabled', + ] + assert ollama_request._build_reasoning_args(_runtime_model(ollama_request, 'enabled', name='qwen3')) == { + 'reasoning_effort': 'low' + } + + effort_capabilities = ollama_request.get_reasoning_capabilities(_runtime_model(ollama_request, name='gpt-oss:20b')) + assert effort_capabilities['levels'] == [ + 'provider_default', + 'disabled', + 'low', + 'medium', + 'high', + ] + assert ollama_request._build_reasoning_args(_runtime_model(ollama_request, 'high', name='gpt-oss:20b')) == { + 'reasoning_effort': 'high' + } + + 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') + ) == {'extra_body': {'thinking': {'type': 'disabled'}}} + + +def test_explicit_unsupported_level_raises(monkeypatch): + request = _requester() + monkeypatch.setattr(request, '_supports_reasoning', lambda _: False) + monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) + + with pytest.raises(errors.RequesterError, match='Available levels: provider_default'): + request._build_reasoning_args(_runtime_model(request, 'high', abilities=[])) + + +def test_provider_inference_rejects_levels_outside_conservative_profile(monkeypatch): + 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', name='gpt-5', abilities=[])) + + +@pytest.mark.asyncio +async def test_completion_args_reject_reasoning_extra_arg_conflicts(monkeypatch): + 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') + model.model_entity.extra_args = {'reasoning_effort': 'low'} + model.provider.token_mgr.get_token = lambda: 'test-token' + + with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'): + await request._build_completion_args(model, []) + + +@pytest.mark.asyncio +async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypatch): + 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' + + args = await request._build_completion_args(model, []) + + assert args['reasoning_effort'] == 'high' + assert args['allowed_openai_params'] == ['custom_extension', 'reasoning_effort'] + + +@pytest.mark.asyncio +async def test_provider_default_does_not_allow_or_send_reasoning_effort(): + 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' + + args = await request._build_completion_args(model, []) + + assert 'reasoning_effort' not in args + assert 'allowed_openai_params' not in args + + +@pytest.mark.asyncio +async def test_deepseek_disabled_thinking_is_merged_into_extra_body(monkeypatch): + 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}} + model.provider.token_mgr.get_token = lambda: 'test-token' + + args = await request._build_completion_args(model, []) + + assert args['extra_body'] == { + 'custom_extension': True, + 'thinking': {'type': 'disabled'}, + } + + +@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='\nprior reasoning\n\nanswer', + provider_specific_fields={'reasoning_content': 'prior reasoning'}, + ) + ] + + args = await request._build_completion_args(model, history) + + assert args['messages'][0]['reasoning_content'] == 'prior reasoning' + assert args['messages'][0]['content'] == 'answer' + 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] + + +@pytest.mark.asyncio +async def test_non_stream_anthropic_thinking_blocks_are_preserved(monkeypatch): + request = _requester('anthropic', 'anthropic-messages') + request._build_completion_args = AsyncMock(return_value={}) + thinking_blocks = [{'type': 'thinking', 'thinking': 'private reasoning', 'signature': 'sig'}] + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=_Dumpable( + { + 'role': 'assistant', + 'content': 'answer', + 'thinking_blocks': thinking_blocks, + } + ) + ) + ], + usage=None, + ) + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=response)) + + message, _ = await request.invoke_llm(None, _runtime_model(request, 'high', name='claude-sonnet-4-6'), []) + + assert message.content == '\nprivate reasoning\n\nanswer' + assert message.provider_specific_fields == {'thinking_blocks': thinking_blocks} + + +class _Dumpable: + def __init__(self, data: dict): + self.data = data + + def model_dump(self) -> dict: + return dict(self.data) + + +@pytest.mark.asyncio +async def test_non_stream_reasoning_content_is_preserved(monkeypatch): + request = _requester('deepseek') + request._build_completion_args = AsyncMock(return_value={}) + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=_Dumpable( + { + 'role': 'assistant', + 'content': 'answer', + 'reasoning_content': 'private reasoning', + } + ) + ) + ], + usage=None, + ) + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=response)) + + message, _ = await request.invoke_llm(None, _runtime_model(request), [], remove_think=True) + + assert message.content == 'answer' + assert message.provider_specific_fields == {'reasoning_content': 'private reasoning'} + + +@pytest.mark.asyncio +async def test_stream_reasoning_round_trip_with_hidden_display(monkeypatch): + request = _requester('deepseek') + request._build_completion_args = AsyncMock(return_value={}) + + async def chunks(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'role': 'assistant', 'reasoning_content': 'private '}), + finish_reason=None, + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'content': 'answer'}), + finish_reason='stop', + ) + ], + usage=None, + ) + + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks())) + accumulator = _StreamAccumulator(remove_think=True) + emitted: provider_message.MessageChunk | None = None + + async for chunk in request.invoke_llm_stream( + None, + _runtime_model(request), + [], + remove_think=True, + ): + emitted = accumulator.add(chunk) or emitted + + assert emitted is not None + assert emitted.content == 'answer' + assert emitted.provider_specific_fields == {'reasoning_content': 'private '} + + +@pytest.mark.asyncio +async def test_stream_reasoning_content_is_wrapped_for_display(monkeypatch): + request = _requester('deepseek') + request._build_completion_args = AsyncMock(return_value={}) + + async def chunks(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'role': 'assistant', 'reasoning_content': 'private '}), + finish_reason=None, + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'content': 'answer'}), + finish_reason='stop', + ) + ], + usage=None, + ) + + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks())) + accumulator = _StreamAccumulator(remove_think=False) + emitted: provider_message.MessageChunk | None = None + + async for chunk in request.invoke_llm_stream( + None, + _runtime_model(request), + [], + remove_think=False, + ): + emitted = accumulator.add(chunk) or emitted + + assert emitted is not None + assert emitted.content == '\nprivate \n\nanswer' + assert emitted.provider_specific_fields == {'reasoning_content': 'private '} + + +@pytest.mark.asyncio +async def test_stream_anthropic_thinking_blocks_are_preserved(monkeypatch): + request = _requester('anthropic', 'anthropic-messages') + request._build_completion_args = AsyncMock(return_value={}) + thinking_blocks = [{'type': 'thinking', 'thinking': 'private ', 'signature': 'sig'}] + + async def chunks(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'role': 'assistant', 'thinking_blocks': thinking_blocks}), + finish_reason=None, + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable({'content': 'answer'}), + finish_reason='stop', + ) + ], + usage=None, + ) + + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks())) + accumulator = _StreamAccumulator(remove_think=False) + emitted: provider_message.MessageChunk | None = None + + async for chunk in request.invoke_llm_stream( + None, + _runtime_model(request, 'high', name='claude-sonnet-4-6'), + [], + remove_think=False, + ): + emitted = accumulator.add(chunk) or emitted + + assert emitted is not None + assert emitted.content == '\nprivate \n\nanswer' + assert emitted.provider_specific_fields == {'thinking_blocks': thinking_blocks} + + +@pytest.mark.asyncio +async def test_hidden_thinking_does_not_drop_same_delta_tool_call(monkeypatch): + request = _requester('openai', 'openai-chat-completions') + request._build_completion_args = AsyncMock(return_value={}) + + async def chunks(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=_Dumpable( + { + 'content': 'hidden', + 'tool_calls': [ + { + 'index': 0, + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'lookup', 'arguments': '{}'}, + } + ], + } + ), + finish_reason='tool_calls', + ) + ], + usage=None, + ) + + monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks())) + collected = [ + chunk + async for chunk in request.invoke_llm_stream( + None, + _runtime_model(request, 'provider_default'), + [], + remove_think=True, + ) + ] + + assert len(collected) == 1 + assert collected[0].tool_calls[0].id == 'call_1' diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 672c930be..756ba42d4 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -400,6 +400,7 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da assert model.model_entity.abilities == model_entity.abilities assert model.model_entity.extra_args == model_entity.extra_args assert model.provider is not None + assert model.reasoning_config_override is None def test_runtime_llm_model_provider_ref(runtime_llm_model): diff --git a/web/package.json b/web/package.json index e896b975e..715bc546d 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.11", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 10521306b..ce058c568 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -72,6 +72,9 @@ dependencies: '@radix-ui/react-separator': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slider': + specifier: ^1.4.7 + version: 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.4(@types/react@19.2.10)(react@19.2.1) @@ -555,10 +558,18 @@ packages: resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} dev: false + /@radix-ui/number@1.1.3: + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + dev: false + /@radix-ui/primitive@1.1.3: resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} dev: false + /@radix-ui/primitive@1.1.7: + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + dev: false + /@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} peerDependencies: @@ -682,6 +693,29 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -718,6 +752,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} peerDependencies: @@ -769,6 +816,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-context@1.2.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -815,6 +875,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-direction@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -1104,6 +1177,26 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -1281,6 +1374,36 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1309,6 +1432,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-slot@1.3.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -1469,6 +1606,22 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -1483,6 +1636,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -1524,6 +1691,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -1537,6 +1717,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-previous@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -1565,6 +1758,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-size@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index b233c25f0..6126a21e2 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -144,6 +144,7 @@ function getValueSchema(spec: DynamicFormValueSpec) { return z.object({ primary: z.string(), fallbacks: z.array(z.string()), + reasoning: z.record(z.string()), }); case DynamicFormItemType.PROMPT_EDITOR: return z.array( @@ -488,12 +489,24 @@ export default function DynamicFormComponent({ (v): v is string => typeof v === 'string', ) : [], + reasoning: + obj.reasoning != null && + typeof obj.reasoning === 'object' && + !Array.isArray(obj.reasoning) + ? Object.fromEntries( + Object.entries(obj.reasoning).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string', + ), + ) + : {}, }; } // Legacy string format or any other unexpected type return { primary: typeof value === 'string' ? value : '', fallbacks: [], + reasoning: {}, }; } if (item.type === 'prompt-editor') { diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 299aac655..861090ec4 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -25,6 +25,7 @@ import { EmbeddingModel, RerankModel, PluginTool, + ReasoningLevel, } from '@/app/infra/entities/api'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -66,6 +67,9 @@ import SettingsDialog, { } from '@/app/home/components/settings-dialog/SettingsDialog'; import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors'; import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types'; +import ReasoningLevelPicker, { + REASONING_LEVELS, +} from '@/app/home/components/reasoning/ReasoningLevelPicker'; function hasUsableUuid( item: T, @@ -874,7 +878,11 @@ export default function DynamicFormItemComponent({ ]; const rawModelValue = field.value; - const modelValue: { primary: string; fallbacks: string[] } = + const modelValue: { + primary: string; + fallbacks: string[]; + reasoning: Record; + } = rawModelValue != null && typeof rawModelValue === 'object' && !Array.isArray(rawModelValue) @@ -893,10 +901,29 @@ export default function DynamicFormItemComponent({ .fallbacks as unknown[] ).filter((v): v is string => typeof v === 'string') : [], + reasoning: + (rawModelValue as Record).reasoning != null && + typeof (rawModelValue as Record).reasoning === + 'object' && + !Array.isArray( + (rawModelValue as Record).reasoning, + ) + ? (Object.fromEntries( + Object.entries( + (rawModelValue as Record) + .reasoning as Record, + ).filter( + (entry): entry is [string, ReasoningLevel] => + typeof entry[1] === 'string' && + REASONING_LEVELS.includes(entry[1] as ReasoningLevel), + ), + ) as Record) + : {}, } : { primary: typeof rawModelValue === 'string' ? rawModelValue : '', fallbacks: [], + reasoning: {}, }; const renderModelSelect = ( @@ -1043,20 +1070,79 @@ export default function DynamicFormItemComponent({ field.onChange({ ...modelValue, ...patch }); }; + const updateModelReasoning = ( + modelUuid: string, + level: ReasoningLevel, + ) => { + if (!modelUuid) return; + const updated = { ...modelValue.reasoning }; + if (level === 'provider_default') { + delete updated[modelUuid]; + } else { + updated[modelUuid] = level; + } + updateValue({ reasoning: updated }); + }; + + const replaceModel = ( + currentUuid: string, + nextUuid: string, + patch: Partial, + ) => { + const nextValue = { ...modelValue, ...patch }; + const updatedReasoning = { ...modelValue.reasoning }; + const currentModelStillSelected = + nextValue.primary === currentUuid || + nextValue.fallbacks.includes(currentUuid); + if ( + currentUuid && + currentUuid !== nextUuid && + !currentModelStillSelected + ) { + delete updatedReasoning[currentUuid]; + } + updateValue({ ...nextValue, reasoning: updatedReasoning }); + }; + + const renderReasoningPicker = (modelUuid: string) => { + if (!modelUuid) return null; + const model = llmModels.find( + (candidate) => candidate.uuid === modelUuid, + ); + const currentLevel = + modelValue.reasoning[modelUuid] || 'provider_default'; + const availableLevels = model?.reasoning_capabilities?.levels || [ + 'provider_default', + ]; + const levels = REASONING_LEVELS.filter( + (level) => availableLevels.includes(level) || level === currentLevel, + ); + + return ( + updateModelReasoning(modelUuid, level)} + /> + ); + }; + const addFallbackModel = () => { updateValue({ fallbacks: [...modelValue.fallbacks, ''] }); }; const updateFallbackModel = (index: number, value: string) => { const updated = [...modelValue.fallbacks]; + const currentUuid = updated[index]; updated[index] = value; - updateValue({ fallbacks: updated }); + replaceModel(currentUuid, value, { fallbacks: updated }); }; const removeFallbackModel = (index: number) => { const updated = [...modelValue.fallbacks]; + const removedUuid = updated[index]; updated.splice(index, 1); - updateValue({ fallbacks: updated }); + replaceModel(removedUuid, '', { fallbacks: updated }); }; const moveFallbackModel = (index: number, direction: 'up' | 'down') => { @@ -1081,10 +1167,12 @@ export default function DynamicFormItemComponent({
{renderModelSelect( modelValue.primary, - (val) => updateValue({ primary: val }), + (val) => + replaceModel(modelValue.primary, val, { primary: val }), t('models.selectModel'), )}
+ {renderReasoningPicker(modelValue.primary)} + + +
+ {currentLabel} + +
+ onChange(safeLevels[index])} + /> +
+ + ); +} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index d5639f608..0eef6543c 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -99,9 +99,33 @@ export interface LLMModel { provider?: ModelProvider; abilities?: string[]; context_length?: number | null; + reasoning_config?: ReasoningConfig; + reasoning_capabilities?: ReasoningCapabilities; extra_args?: object; } +export type ReasoningLevel = + | 'provider_default' + | 'disabled' + | 'enabled' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; + +export interface ReasoningConfig { + level: ReasoningLevel; +} + +export interface ReasoningCapabilities { + supported: boolean; + levels: ReasoningLevel[]; + legacy_levels?: ReasoningLevel[]; + source: 'litellm' | 'provider' | 'manual' | 'unknown'; +} + export interface ApiRespProviderEmbeddingModels { models: EmbeddingModel[]; } diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx new file mode 100644 index 000000000..4e3e69e1a --- /dev/null +++ b/web/src/components/ui/slider.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; + +import { cn } from '@/lib/utils'; + +const Slider = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ef60408f6..6603cf560 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -214,6 +214,19 @@ const enUS = { selectModelAbilities: 'Select model abilities', visionAbility: 'Vision Ability', functionCallAbility: 'Function Call', + reasoningAbility: 'Reasoning', + reasoningLevel: 'Reasoning level', + reasoningLevels: { + providerDefault: 'Provider default', + disabled: 'Off', + enabled: 'On', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Maximum', + }, contextLength: 'Context Window', contextLengthPlaceholder: 'Unknown', contextLengthInvalid: 'Context window must be a positive integer', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f206c2cd5..7285086c4 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -217,6 +217,19 @@ const jaJP = { selectModelAbilities: 'モデル機能を選択', visionAbility: '視覚機能', functionCallAbility: '関数呼び出し', + reasoningAbility: '推論', + reasoningLevel: '推論レベル', + reasoningLevels: { + providerDefault: 'Provider デフォルト', + disabled: 'オフ', + enabled: 'オン', + minimal: '最小', + low: '低', + medium: '中', + high: '高', + xhigh: '最高', + max: '最大', + }, contextLength: 'コンテキストウィンドウ', contextLengthPlaceholder: '不明', contextLengthInvalid: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 0e3bb5ae5..bbece7f35 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -204,6 +204,19 @@ const zhHans = { selectModelAbilities: '选择模型能力', visionAbility: '视觉能力', functionCallAbility: '函数调用', + reasoningAbility: '思考能力', + reasoningLevel: '思考档位', + reasoningLevels: { + providerDefault: 'Provider 默认', + disabled: '关闭', + enabled: '开启', + minimal: '最低', + low: '低', + medium: '中', + high: '高', + xhigh: '极高', + max: '最大', + }, contextLength: '上下文窗口', contextLengthPlaceholder: '未知', contextLengthInvalid: '上下文窗口必须是正整数', diff --git a/web/tests/unit/dynamic-form-save-values.test.mjs b/web/tests/unit/dynamic-form-save-values.test.mjs index bc314c9fa..8d9e2121c 100644 --- a/web/tests/unit/dynamic-form-save-values.test.mjs +++ b/web/tests/unit/dynamic-form-save-values.test.mjs @@ -34,12 +34,26 @@ test('normalizes only single-line text fields in a dynamic form save snapshot', { name: 'multiline', type: 'text', default: '' }, { name: 'string-list', type: 'array[string]', default: [] }, { name: 'count', type: 'integer', default: 0 }, + { + name: 'model', + type: 'model-fallback-selector', + default: { primary: '', fallbacks: [], reasoning: {} }, + }, ]; const values = { 'single-line': '\t hello world \n', multiline: ' keep multiline whitespace \n', 'string-list': [' first ', ' second '], count: 3, + model: { + primary: 'primary-model', + fallbacks: ['fallback-model'], + reasoning: { + 'primary-model': 'high', + 'fallback-model': 'provider_default', + 'removed-model': 'medium', + }, + }, }; assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), { @@ -47,5 +61,12 @@ test('normalizes only single-line text fields in a dynamic form save snapshot', multiline: ' keep multiline whitespace \n', 'string-list': [' first ', ' second '], count: 3, + model: { + primary: 'primary-model', + fallbacks: ['fallback-model'], + reasoning: { + 'primary-model': 'high', + }, + }, }); }); From 90f3d880e504d638f8cde637138c544e1a481948 Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 11 Aug 2026 13:23:03 +0800 Subject: [PATCH 6/7] fix(deps): constrain mcp to v1 (#2413) Co-authored-by: Chan --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6144f3cb..125f4c58b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "pynacl>=1.5.0", # Required for Discord voice support "gewechat-client>=0.1.5", "lark-oapi>=1.5.5", - "mcp>=1.25.0", + "mcp>=1.25.0,<2.0.0", "nakuru-project-idk>=0.0.2.1", "ollama>=0.4.8", "openai>1.0.0", diff --git a/uv.lock b/uv.lock index 14eb1251a..7866686ed 100644 --- a/uv.lock +++ b/uv.lock @@ -2136,7 +2136,7 @@ requires-dist = [ { name = "mako", specifier = ">=1.3.12" }, { name = "markdown", specifier = ">=3.6" }, { name = "matrix-nio", specifier = ">=0.25.2" }, - { name = "mcp", specifier = ">=1.25.0" }, + { name = "mcp", specifier = ">=1.25.0,<2.0.0" }, { name = "mypy", specifier = ">=1.16.0" }, { name = "nakuru-project-idk", specifier = ">=0.0.2.1" }, { name = "ollama", specifier = ">=0.4.8" }, From 20710df9cb96b8c447d862d2177ba395db1dff9b Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 11 Aug 2026 16:57:16 +0800 Subject: [PATCH 7/7] fix(cloud): scope QR login requests to workspace (#2414) Co-authored-by: Chan --- .../qrcode-login/QrCodeLoginDialog.tsx | 47 +++++++++--- 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 + .../unit/qr-code-login-workspace.test.mjs | 73 +++++++++++++++++++ 10 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 web/tests/unit/qr-code-login-workspace.test.mjs diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 5865dbb4a..439b99f9d 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -15,6 +15,7 @@ import { XCircle, } from 'lucide-react'; import QRCode from 'qrcode'; +import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; export type QrLoginPlatform = | 'feishu' @@ -55,12 +56,12 @@ const PLATFORM_CONFIGS: Record = { }, weixin: { titleKey: 'weixin.scanLogin', - connectingKey: 'feishu.connecting', + connectingKey: 'weixin.connecting', scanQRCodeKey: 'weixin.scanQRCode', - waitingKey: 'feishu.waitingForScan', + waitingKey: 'weixin.waitingForScan', successKey: 'weixin.loginSuccess', failedKey: 'weixin.loginFailed', - retryKey: 'feishu.retry', + retryKey: 'weixin.retry', apiBase: '/api/v1/platform/adapters/weixin/login', extractSuccess: (data) => ({ token: data.token, @@ -146,6 +147,8 @@ export default function QrCodeLoginDialog({ const checkExpiredRef = useRef | null>(null); const abortRef = useRef(null); const sessionIdRef = useRef(null); + const sessionWorkspaceUuidRef = useRef(null); + const sessionApiBaseRef = useRef(''); const baseUrlRef = useRef(''); const cleanedRef = useRef(false); @@ -180,18 +183,23 @@ export default function QrCodeLoginDialog({ } if (sessionIdRef.current) { const token = localStorage.getItem('token'); - const baseUrl = - import.meta.env.VITE_API_BASE_URL || window.location.origin; + const workspaceUuid = sessionWorkspaceUuidRef.current; fetch( - `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, + `${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; } + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; }, []); const startLogin = useCallback(async () => { @@ -204,6 +212,7 @@ export default function QrCodeLoginDialog({ setSuccessMeta(''); const token = localStorage.getItem('token'); + const workspaceUuid = getActiveWorkspaceUuid(); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; baseUrlRef.current = baseUrl; const cfg = platformConfigRef.current; @@ -214,7 +223,10 @@ export default function QrCodeLoginDialog({ const res = await fetch(`${baseUrl}${cfg.apiBase}`, { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, signal: controller.signal, }); @@ -225,6 +237,8 @@ export default function QrCodeLoginDialog({ const { session_id, qr_data_url, qr_url, expire_at } = json.data; sessionIdRef.current = session_id; + sessionWorkspaceUuidRef.current = workspaceUuid; + sessionApiBaseRef.current = cfg.apiBase; if (qr_data_url) { setQrDataUrl(qr_data_url); @@ -270,11 +284,19 @@ export default function QrCodeLoginDialog({ `${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid + ? { 'X-Workspace-Id': workspaceUuid } + : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; } setState('expired'); } @@ -286,7 +308,12 @@ export default function QrCodeLoginDialog({ try { const pollRes = await fetch( `${baseUrl}${cfg.apiBase}/status/${session_id}`, - { headers: { Authorization: `Bearer ${token}` } }, + { + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, + }, ); if (!pollRes.ok) return; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6603cf560..c60ad295f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1921,6 +1921,9 @@ const enUS = { 'Scan the QR code below with WeChat to authorize and automatically fill in the token', loginSuccess: 'Login successful! Token has been filled in', loginFailed: 'Login failed', + connecting: 'Connecting to WeChat service...', + waitingForScan: 'Waiting for scan', + retry: 'Retry', }, dingtalk: { createApp: 'One-Click Create DingTalk App', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1efa34173..43fc33a9b 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1747,6 +1747,9 @@ const esES = { loginSuccess: '¡Inicio de sesión correcto! El token se ha rellenado automáticamente', loginFailed: 'Error al iniciar sesión', + connecting: 'Conectando con el servicio de WeChat...', + waitingForScan: 'Esperando escaneo', + retry: 'Reintentar', }, dingtalk: { createApp: 'Crear aplicación de DingTalk con un clic', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7285086c4..026ddd3c4 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1837,6 +1837,9 @@ const jaJP = { scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力', loginSuccess: 'ログイン成功!トークンが自動入力されました', loginFailed: 'ログイン失敗', + connecting: 'WeChatサービスに接続中...', + waitingForScan: 'スキャン待ち', + retry: '再試行', }, dingtalk: { createApp: 'ワンクリックでDingTalkアプリ作成', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index b167c81a1..975360fb8 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1717,6 +1717,9 @@ const ruRU = { 'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен', loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически', loginFailed: 'Не удалось выполнить вход', + connecting: 'Подключение к сервису WeChat...', + waitingForScan: 'Ожидание сканирования', + retry: 'Повторить', }, dingtalk: { createApp: 'Создать приложение DingTalk в один клик', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c1afcb48a..d19e2fef1 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1680,6 +1680,9 @@ const thTH = { 'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ', loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว', loginFailed: 'เข้าสู่ระบบไม่สำเร็จ', + connecting: 'กำลังเชื่อมต่อบริการ WeChat...', + waitingForScan: 'กำลังรอการสแกน', + retry: 'ลองอีกครั้ง', }, dingtalk: { createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 1a4af1319..02bab4c7a 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1708,6 +1708,9 @@ const viVN = { 'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token', loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động', loginFailed: 'Đăng nhập thất bại', + connecting: 'Đang kết nối tới dịch vụ WeChat...', + waitingForScan: 'Đang chờ quét mã', + retry: 'Thử lại', }, dingtalk: { createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bbece7f35..3aba0fcfa 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1834,6 +1834,9 @@ const zhHans = { scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌', loginSuccess: '登录成功!令牌已自动填入', loginFailed: '登录失败', + connecting: '正在连接微信服务...', + waitingForScan: '等待扫码中', + retry: '重试', }, dingtalk: { createApp: '一键创建钉钉应用', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 707907a32..91ecdde61 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1657,6 +1657,9 @@ const zhHant = { scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌', loginSuccess: '登入成功!令牌已自動填入', loginFailed: '登入失敗', + connecting: '正在連接微信服務...', + waitingForScan: '等待掃碼中', + retry: '重試', }, dingtalk: { createApp: '一鍵建立釘釘應用', diff --git a/web/tests/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs new file mode 100644 index 000000000..5e7819373 --- /dev/null +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const dialogPath = path.join( + root, + 'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx', +); +const localeDir = path.join(root, 'src/i18n/locales'); + +const dialogSource = fs.readFileSync(dialogPath, 'utf8'); + +test('QR credential exchanges preserve the active Workspace scope', () => { + assert.match(dialogSource, /getActiveWorkspaceUuid/); + assert.match( + dialogSource, + /sessionWorkspaceUuidRef\.current = workspaceUuid/, + ); + assert.match( + dialogSource, + /const workspaceUuid = sessionWorkspaceUuidRef\.current/, + ); + assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/); + assert.match( + dialogSource, + /`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/, + ); + assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/); + + const workspaceHeaderUses = dialogSource.match( + /'X-Workspace-Id': workspaceUuid/g, + ); + assert.equal( + workspaceHeaderUses?.length, + 4, + 'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope', + ); +}); + +test('WeChat QR login never reuses Feishu progress copy', () => { + const weixinConfig = dialogSource.match( + /weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/, + )?.[0]; + assert.ok(weixinConfig, 'WeChat platform config is missing'); + assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/); + assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/); + assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/); + assert.doesNotMatch(weixinConfig, /feishu\./); + + for (const locale of [ + 'en-US.ts', + 'es-ES.ts', + 'ja-JP.ts', + 'ru-RU.ts', + 'th-TH.ts', + 'vi-VN.ts', + 'zh-Hans.ts', + 'zh-Hant.ts', + ]) { + const source = fs.readFileSync(path.join(localeDir, locale), 'utf8'); + const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0]; + assert.ok(block, `${locale} is missing the WeChat locale block`); + for (const key of ['connecting', 'waitingForScan', 'retry']) { + assert.match( + block, + new RegExp(`\\b${key}:`), + `${locale} is missing weixin.${key}`, + ); + } + } +});