mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-11 21:30:59 +00:00
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
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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='<think>\nprior reasoning\n</think>\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 == '<think>\nprivate reasoning\n</think>\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 == '<think>\nprivate \n</think>\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 == '<think>\nprivate \n</think>\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': '<think>hidden</think>',
|
||||
'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'
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user