mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-18 16:30:58 +00:00
chore: merge master into dev/4.11.x
This commit is contained in:
@@ -106,7 +106,12 @@ async def plugin_security_api(plugin_module):
|
||||
application.plugin_connector.require_workspace_context = AsyncMock()
|
||||
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
||||
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
||||
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
||||
application.plugin_connector.get_debug_info = AsyncMock(
|
||||
return_value={
|
||||
'plugin_debug_key': 'runtime-debug-secret',
|
||||
'expires_at': '2026-08-04T12:00:00Z',
|
||||
}
|
||||
)
|
||||
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
||||
application.plugin_connector.set_plugin_config = AsyncMock()
|
||||
|
||||
@@ -230,10 +235,22 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
||||
assert operator_denied.status_code == 403
|
||||
assert allowed.status_code == 200
|
||||
assert (await allowed.get_json())['data'] == {
|
||||
'debug_url': 'http://localhost:5401',
|
||||
'debug_url': 'ws://localhost:5401/plugin/debug/ws',
|
||||
'plugin_debug_key': 'runtime-debug-secret',
|
||||
'expires_at': '2026-08-04T12:00:00Z',
|
||||
}
|
||||
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api):
|
||||
application, client, _ = plugin_security_api
|
||||
application.instance_config.data['plugin'].pop('display_plugin_debug_url')
|
||||
|
||||
response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
@@ -14,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -58,6 +60,12 @@ async def space_oauth_api():
|
||||
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
||||
)
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||
application.workspace_service.get_execution_binding = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace_uuid=WORKSPACE_UUID,
|
||||
workspace_created_at=WORKSPACE_CREATED_AT,
|
||||
)
|
||||
)
|
||||
application.space_service.get_oauth_authorize_url = Mock(
|
||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||
)
|
||||
@@ -157,34 +165,51 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
|
||||
async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
|
||||
_, client = space_oauth_api
|
||||
|
||||
wrong_origin = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
wrong_path = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'http://localhost/arbitrary'},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
forged_origin = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
|
||||
headers={'Origin': 'https://evil.example'},
|
||||
)
|
||||
forged_host = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
|
||||
headers={'Host': 'evil.example'},
|
||||
)
|
||||
responses = [
|
||||
await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': redirect_uri},
|
||||
headers={'Origin': 'https://irrelevant.example'},
|
||||
)
|
||||
for redirect_uri in (
|
||||
'https://langbot.example/auth/space/callback',
|
||||
'https://gateway.example:8443/auth/space/callback',
|
||||
'https://192.0.2.10/auth/space/callback',
|
||||
'http://localhost:5300/auth/space/callback',
|
||||
'http://127.0.0.1:5300/auth/space/callback',
|
||||
'http://[::1]:5300/auth/space/callback',
|
||||
'http://langbot.example/auth/space/callback',
|
||||
'http://192.0.2.10:5300/auth/space/callback',
|
||||
)
|
||||
]
|
||||
|
||||
assert (await wrong_origin.get_json())['code'] == 1
|
||||
assert (await wrong_path.get_json())['code'] == 1
|
||||
assert (await forged_origin.get_json())['code'] == 1
|
||||
assert (await forged_host.get_json())['code'] == 1
|
||||
assert all(response.status_code == 200 for response in responses)
|
||||
payloads = [await response.get_json() for response in responses]
|
||||
assert all(payload['code'] == 0 for payload in payloads)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
|
||||
_, client = space_oauth_api
|
||||
|
||||
responses = [
|
||||
await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': redirect_uri},
|
||||
)
|
||||
for redirect_uri in (
|
||||
'https://langbot.example/arbitrary',
|
||||
'https://langbot.example/auth/space/callback?next=https://evil.example',
|
||||
'https://user@langbot.example/auth/space/callback',
|
||||
'https://langbot.example/auth/space/callback#fragment',
|
||||
)
|
||||
]
|
||||
|
||||
payloads = [await response.get_json() for response in responses]
|
||||
assert all(payload['code'] == 1 for payload in payloads)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -234,7 +259,11 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
|
||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||
'oauth-code',
|
||||
[WORKSPACE_UUID],
|
||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -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()
|
||||
@@ -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,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
assert _get_script_head() == '0020_merge_agent_cloud_heads'
|
||||
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
||||
@@ -117,7 +120,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_merge_agent_cloud_heads'
|
||||
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
|
||||
|
||||
@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) == '0022_merge_agent_reasoning_heads'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||
@@ -214,6 +228,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."""
|
||||
|
||||
@@ -85,6 +85,32 @@ async def clean_database(postgres_engine: AsyncEngine):
|
||||
await clean()
|
||||
|
||||
|
||||
async def test_upgrade_adds_3072_dimension_index_and_constraint(
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
) -> None:
|
||||
async with postgres_engine.begin() as conn:
|
||||
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
|
||||
await run_alembic_upgrade(postgres_engine, 'head')
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
constraint = await conn.scalar(
|
||||
text(
|
||||
'SELECT pg_get_constraintdef(oid) FROM pg_constraint '
|
||||
"WHERE conrelid = 'langbot_vectors'::regclass "
|
||||
"AND conname = 'ck_langbot_vectors_embedding_dimension_enabled'"
|
||||
)
|
||||
)
|
||||
assert '3072' in constraint
|
||||
index_definition = await conn.scalar(
|
||||
text("SELECT indexdef FROM pg_indexes WHERE indexname = 'ix_langbot_vectors_hnsw_cosine_3072'")
|
||||
)
|
||||
assert 'halfvec(3072)' in index_definition
|
||||
assert 'halfvec_cosine_ops' in index_definition
|
||||
|
||||
|
||||
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
|
||||
@@ -92,7 +92,7 @@ def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 512, 768, 1024, 1536],
|
||||
'allowed_dimensions': [384, 512, 768, 1024, 1536, 3072],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -25,6 +25,7 @@ import time
|
||||
|
||||
from langbot.pkg.api.http.service.space import SpaceService
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -573,10 +574,20 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
# Execute
|
||||
result = await service.exchange_oauth_code('auth_code')
|
||||
result = await service.exchange_oauth_code(
|
||||
'auth_code',
|
||||
['workspace-1'],
|
||||
{'workspace-1': 1_700_000_000},
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result['access_token'] == 'new_access_token'
|
||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||
'code': 'auth_code',
|
||||
'instance_id': constants.instance_id,
|
||||
'workspace_uuids': ['workspace-1'],
|
||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||
}
|
||||
|
||||
async def test_exchange_oauth_code_api_error(self):
|
||||
"""Raises ValueError on API error."""
|
||||
|
||||
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
|
||||
service = UserService(ap)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='请使用 Space 账户登录'):
|
||||
with pytest.raises(ValueError, match='请使用 LangBot 账号登录'):
|
||||
await service.authenticate('space@example.com', 'password')
|
||||
|
||||
|
||||
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
)
|
||||
service = UserService(ap)
|
||||
|
||||
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
|
||||
with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
|
||||
await service.register_invited_account('invite-token', 'member@example.com', 'password')
|
||||
|
||||
async def test_create_or_update_new_space_user_first_init(self):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector
|
||||
_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
|
||||
|
||||
|
||||
def make_app(logger: Mock, runtime_endpoint: str = ''):
|
||||
def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
|
||||
return SimpleNamespace(
|
||||
logger=logger,
|
||||
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
|
||||
@@ -42,6 +42,7 @@ def make_app(logger: Mock, runtime_endpoint: str = ''):
|
||||
}
|
||||
}
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
|
||||
)
|
||||
|
||||
|
||||
@@ -306,10 +307,27 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance(
|
||||
)
|
||||
|
||||
|
||||
def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
|
||||
|
||||
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
|
||||
|
||||
|
||||
def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True))
|
||||
|
||||
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
|
||||
connector.get_control_headers()
|
||||
|
||||
|
||||
def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
|
||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
|
||||
|
||||
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
|
||||
connector.get_control_headers()
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ def _cloud_config() -> dict:
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 768, 1536],
|
||||
'allowed_dimensions': [384, 768, 1536, 3072],
|
||||
},
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': False}},
|
||||
@@ -216,7 +216,6 @@ async def test_cloud_directory_capacity_contract_is_fail_closed(directory_config
|
||||
[
|
||||
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
||||
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
||||
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
|
||||
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -181,6 +181,39 @@ def _delta(
|
||||
)
|
||||
|
||||
|
||||
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
|
||||
application, _session_factory = projection_context
|
||||
request_sync = Mock()
|
||||
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
|
||||
event = DirectoryEvent(
|
||||
cursor=2,
|
||||
uuid='20000000-0000-4000-8000-000000000002',
|
||||
aggregate_uuid=WORKSPACE_UUID,
|
||||
event_type='directory.changed',
|
||||
revision=2,
|
||||
payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
|
||||
created_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
|
||||
)
|
||||
batch = DirectoryEventBatch(
|
||||
instance_uuid=INSTANCE_UUID,
|
||||
after_cursor=1,
|
||||
cursor=2,
|
||||
high_water_cursor=2,
|
||||
events=[event],
|
||||
)
|
||||
service = DirectoryProjectionService(
|
||||
application,
|
||||
_Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
|
||||
INSTANCE_UUID,
|
||||
)
|
||||
await service.initialize()
|
||||
request_sync.reset_mock()
|
||||
|
||||
await service.sync_once()
|
||||
|
||||
request_sync.assert_called_once_with()
|
||||
|
||||
|
||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||
application, session_factory = projection_context
|
||||
reconcile_execution_projection = Mock()
|
||||
@@ -1023,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()
|
||||
@@ -1034,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
|
||||
|
||||
@@ -273,6 +273,94 @@ async def test_snapshot_must_cover_every_active_workspace() -> None:
|
||||
await service.sync_once()
|
||||
|
||||
|
||||
async def test_periodic_sync_discovers_workspace_created_after_startup_cache_release(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog-new-workspace.db"}')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
startup_bindings = [
|
||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1)
|
||||
]
|
||||
live_bindings = [
|
||||
*startup_bindings,
|
||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
||||
]
|
||||
|
||||
class _WorkspaceService:
|
||||
startup_released = False
|
||||
|
||||
async def list_active_execution_bindings(self):
|
||||
return list(live_bindings if self.startup_released else startup_bindings)
|
||||
|
||||
def release_startup_execution_bindings(self):
|
||||
self.startup_released = True
|
||||
|
||||
workspace_service = _WorkspaceService()
|
||||
app = SimpleNamespace(
|
||||
persistence_mgr=manager,
|
||||
workspace_service=workspace_service,
|
||||
model_mgr=SimpleNamespace(load_models_from_db=_AsyncCounter()),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
||||
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace),
|
||||
[
|
||||
{
|
||||
'uuid': WORKSPACE_A,
|
||||
'instance_uuid': INSTANCE_UUID,
|
||||
'name': 'A',
|
||||
'slug': 'a',
|
||||
'source': 'cloud_projection',
|
||||
},
|
||||
{
|
||||
'uuid': WORKSPACE_B,
|
||||
'instance_uuid': INSTANCE_UUID,
|
||||
'name': 'B',
|
||||
'slug': 'b',
|
||||
'source': 'cloud_projection',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
await service.initialize()
|
||||
workspace_service.release_startup_execution_bindings()
|
||||
await service.sync_once()
|
||||
|
||||
async with engine.connect() as connection:
|
||||
provider_b = await connection.scalar(
|
||||
sqlalchemy.select(ModelProvider).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_B))
|
||||
)
|
||||
assert provider_b is not None
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_catalog_run_wakes_immediately_when_directory_changes() -> None:
|
||||
sync_started = asyncio.Event()
|
||||
|
||||
class _WakeService(CloudModelCatalogSyncService):
|
||||
async def sync_once(self, *, reload_runtime: bool = True):
|
||||
del reload_runtime
|
||||
sync_started.set()
|
||||
return {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||
|
||||
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
||||
service = _WakeService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID, sync_interval_seconds=3600)
|
||||
task = asyncio.create_task(service.run())
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
service.request_sync()
|
||||
await asyncio.wait_for(sync_started.wait(), timeout=0.2)
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def _async_value(value):
|
||||
return value
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
||||
app.platform_mgr = SimpleNamespace(_bots_by_key={})
|
||||
app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
|
||||
app.rag_mgr = SimpleNamespace(knowledge_bases={})
|
||||
app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()})
|
||||
app.plugin_connector = SimpleNamespace(
|
||||
_known_desired_states={'installation': object()},
|
||||
_runtime_available=lambda: True,
|
||||
)
|
||||
app.persistence_mgr = SimpleNamespace(
|
||||
get_resource_stats=lambda: {
|
||||
'configured_capacity': 20,
|
||||
@@ -140,3 +143,4 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
||||
}
|
||||
assert stats['models']['providers'] == 1
|
||||
assert stats['runtimes']['plugin_installations'] == 1
|
||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||
|
||||
@@ -319,6 +319,7 @@ class TestApplyEnvOverridesToConfig:
|
||||
load_config = get_load_config_module()
|
||||
cfg = {
|
||||
'plugin': {
|
||||
'connect_timeout_seconds': 30.0,
|
||||
'worker': {
|
||||
'max_cpus': 1.0,
|
||||
'max_memory_mb': 512,
|
||||
@@ -329,11 +330,12 @@ class TestApplyEnvOverridesToConfig:
|
||||
'restart_failure_threshold': 8,
|
||||
'restart_failure_window_seconds': 30.0,
|
||||
'restart_circuit_open_seconds': 60.0,
|
||||
}
|
||||
},
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
}
|
||||
env = {
|
||||
'PLUGIN__CONNECT_TIMEOUT_SECONDS': '180',
|
||||
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
||||
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
||||
'PLUGIN__WORKER__MAX_PIDS': '64',
|
||||
@@ -349,6 +351,7 @@ class TestApplyEnvOverridesToConfig:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['plugin']['connect_timeout_seconds'] == 180.0
|
||||
assert result['plugin']['worker'] == {
|
||||
'max_cpus': 2.5,
|
||||
'max_memory_mb': 1024,
|
||||
@@ -393,6 +396,14 @@ class TestApplyEnvOverridesToConfig:
|
||||
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
||||
assert result['mcp']['stdio']['enabled'] is False
|
||||
|
||||
def test_runtime_policy_defaults_add_typed_plugin_connect_timeout(self):
|
||||
load_config = get_load_config_module()
|
||||
|
||||
completed = load_config._complete_runtime_policy_defaults({'plugin': {'enable': True}})
|
||||
|
||||
assert completed['plugin']['connect_timeout_seconds'] == 180.0
|
||||
assert isinstance(completed['plugin']['connect_timeout_seconds'], float)
|
||||
|
||||
def test_webhook_prefix_override(self):
|
||||
"""Test overriding webhook_prefix via environment variable."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -967,6 +967,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
||||
),
|
||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||
sa.select(sa.cast(sa.column('embedding'), HALFVEC(3072))),
|
||||
sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
|
||||
_multi_value_statement(value=1),
|
||||
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
The web debug client uploads Image / Voice / File components carrying a storage
|
||||
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
|
||||
LLM input and the Box sandbox inbox have usable bytes) while retaining the key
|
||||
for browser history. Covers mimetype selection per type and fail-closed error
|
||||
LLM input and the Box sandbox inbox have usable bytes), then deletes the
|
||||
consumed upload. Covers mimetype selection per type and fail-closed error
|
||||
handling.
|
||||
"""
|
||||
|
||||
@@ -52,7 +52,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_jpeg_mimetype_and_retained_storage_key():
|
||||
async def test_image_jpeg_mimetype_and_consumed_storage_key():
|
||||
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
|
||||
path = f'{_UPLOAD_PREFIX}photo.jpg'
|
||||
chain = [{'type': 'Image', 'path': path}]
|
||||
@@ -61,8 +61,12 @@ async def test_image_jpeg_mimetype_and_retained_storage_key():
|
||||
|
||||
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
|
||||
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
||||
assert chain[0]['path'] == path
|
||||
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||
assert chain[0]['path'] == ''
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
||||
_CONTEXT,
|
||||
path,
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
|
||||
|
||||
def test_history_retains_storage_key_without_large_base64_payload():
|
||||
|
||||
@@ -7,8 +7,8 @@ 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.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
|
||||
@@ -347,9 +347,9 @@ async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_reply_uses_event_pipeline_after_connection_closes(monkeypatch):
|
||||
async def test_dashboard_reply_survives_connection_replacement(monkeypatch):
|
||||
manager = WebSocketConnectionManager()
|
||||
connection = await manager.add_connection(
|
||||
original = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
scope=SCOPE_A,
|
||||
pipeline_uuid='pipeline-1',
|
||||
@@ -357,22 +357,36 @@ async def test_dashboard_reply_uses_event_pipeline_after_connection_closes(monke
|
||||
)
|
||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||
|
||||
app = Mock()
|
||||
app.platform_mgr.websocket_proxy_bot.bot_entity = Mock(spec=[])
|
||||
adapter = WebSocketAdapter.model_construct(ap=app, logger=AsyncMock())
|
||||
message_source = platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=f'websocket_{connection.connection_id}',
|
||||
nickname='User',
|
||||
remark='User',
|
||||
),
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
time=1,
|
||||
)
|
||||
object.__setattr__(message_source, '_langbot_pipeline_uuid', 'pipeline-1')
|
||||
await manager.remove_connection(connection.connection_id)
|
||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
|
||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||
received = []
|
||||
|
||||
assert await adapter._get_message_context(message_source) == ('pipeline-1', None)
|
||||
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'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -468,7 +482,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
||||
await adapter._process_image_components(connection, message_chain)
|
||||
|
||||
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
|
||||
assert message_chain[0]['path'] == 'v1/current/upload_image/key.png'
|
||||
assert message_chain[0]['path'] == ''
|
||||
storage_mgr.scoped_prefix.assert_called_once_with(
|
||||
connection.execution_context,
|
||||
owner_type='upload_image',
|
||||
@@ -482,7 +496,11 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
||||
'v1/current/upload_image/key.png',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
||||
connection.execution_context,
|
||||
'v1/current/upload_image/key.png',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='does not belong'):
|
||||
await adapter._process_image_components(
|
||||
|
||||
@@ -93,14 +93,10 @@ class TestRunAgent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_revalidates_trusted_execution_context(self):
|
||||
connector = create_mock_connector()
|
||||
connector._current_execution_context = AsyncMock(
|
||||
return_value=TEST_EXECUTION_CONTEXT
|
||||
)
|
||||
connector._current_execution_context = AsyncMock(return_value=TEST_EXECUTION_CONTEXT)
|
||||
|
||||
class RuntimeHandler:
|
||||
installation_scope = Mock(
|
||||
side_effect=lambda _binding: nullcontext()
|
||||
)
|
||||
installation_scope = Mock(side_effect=lambda _binding: nullcontext())
|
||||
|
||||
async def run_agent(self, *_args):
|
||||
yield {'type': 'run.completed'}
|
||||
@@ -113,16 +109,12 @@ class TestRunAgent:
|
||||
)
|
||||
|
||||
assert results == [{'type': 'run.completed'}]
|
||||
connector.require_workspace_context.assert_awaited_once_with(
|
||||
TEST_EXECUTION_CONTEXT
|
||||
)
|
||||
connector.require_workspace_context.assert_awaited_once_with(TEST_EXECUTION_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_payload_workspace_mismatch(self):
|
||||
connector = create_mock_connector()
|
||||
connector._current_execution_context = AsyncMock(
|
||||
return_value=TEST_EXECUTION_CONTEXT
|
||||
)
|
||||
connector._current_execution_context = AsyncMock(return_value=TEST_EXECUTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
|
||||
with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
|
||||
@@ -670,8 +662,13 @@ class TestDisabledPluginEarlyReturns:
|
||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||
|
||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||
execution_context = connector_module.ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
result = await connector.get_debug_info()
|
||||
result = await connector.get_debug_info(execution_context)
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
|
||||
)
|
||||
|
||||
|
||||
def make_connector() -> PluginRuntimeConnector:
|
||||
def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(
|
||||
@@ -34,6 +34,7 @@ def make_connector() -> PluginRuntimeConnector:
|
||||
'space': {'url': ''},
|
||||
}
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
|
||||
)
|
||||
return PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
@@ -142,6 +143,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,
|
||||
@@ -292,10 +336,17 @@ def test_closed_deployment_selects_instance_scoped_shared_profile():
|
||||
assert connector.runtime_profile == 'shared'
|
||||
|
||||
|
||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||
def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeypatch):
|
||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = make_connector()
|
||||
|
||||
assert connector._control_headers(allow_generate=False) == {}
|
||||
|
||||
|
||||
def test_cloud_runtime_rejects_missing_control_secret(monkeypatch):
|
||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = make_connector(cloud=True)
|
||||
|
||||
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
||||
connector._control_headers(allow_generate=False)
|
||||
|
||||
|
||||
@@ -153,6 +153,31 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_logs_workspace_installation_counts_and_elapsed_time():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
binding_b = execution_binding('workspace-b')
|
||||
setting_a = plugin_setting('01', 'a' * 64)
|
||||
setting_b = plugin_setting('02', 'b' * 64)
|
||||
connector = shared_connector(
|
||||
[[binding_a, binding_b]],
|
||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
||||
)
|
||||
connector.handler = runtime_handler()
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
matching_calls = [
|
||||
call
|
||||
for call in connector.ap.logger.info.call_args_list
|
||||
if call.args
|
||||
and call.args[0]
|
||||
== 'Shared plugin runtime reconcile completed: workspaces=%d desired_installations=%d elapsed_seconds=%.3f'
|
||||
]
|
||||
assert len(matching_calls) == 1
|
||||
assert matching_calls[0].args[1:3] == (2, 2)
|
||||
assert matching_calls[0].args[3] >= 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
|
||||
package = b'local-lbpkg-bytes'
|
||||
|
||||
@@ -6,9 +6,10 @@ Tests cover:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from importlib import import_module
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def get_connector_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
@@ -60,3 +61,28 @@ def test_runtime_id_is_stable_across_core_restarts(monkeypatch):
|
||||
monkeypatch.setattr(connector.constants, 'instance_id', 'instance-a')
|
||||
|
||||
assert connector.PluginRuntimeConnector._build_runtime_id() == 'instance-a:plugin-runtime'
|
||||
|
||||
|
||||
def test_runtime_connect_timeout_defaults_to_three_minutes():
|
||||
connector = get_connector_module()
|
||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout({}) == 180.0
|
||||
|
||||
|
||||
def test_runtime_connect_timeout_reads_typed_plugin_config():
|
||||
connector = get_connector_module()
|
||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout({'connect_timeout_seconds': 45.5}) == 45.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [True, False, None, 0, -1, float('nan'), float('inf'), '180', object()])
|
||||
def test_runtime_connect_timeout_rejects_invalid_values(value):
|
||||
connector = get_connector_module()
|
||||
with pytest.raises(ValueError, match='plugin.connect_timeout_seconds'):
|
||||
connector.PluginRuntimeConnector._runtime_connect_timeout({'connect_timeout_seconds': value})
|
||||
|
||||
|
||||
def test_runtime_connect_timeout_error_displays_actual_seconds():
|
||||
connector = get_connector_module()
|
||||
|
||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout_error(45.5) == (
|
||||
'Plugin runtime did not become ready within 45.5 seconds'
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
@@ -67,6 +67,20 @@ def make_handler(app):
|
||||
return runtime_handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish():
|
||||
app = SimpleNamespace()
|
||||
runtime_handler = make_handler(app)
|
||||
runtime_handler.call_action = AsyncMock(return_value={})
|
||||
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||
|
||||
await runtime_handler.reconcile_plugin_installations((desired,))
|
||||
|
||||
assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS
|
||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
|
||||
|
||||
@@ -396,9 +396,7 @@ async def test_legacy_oss_knowledge_file_reply_uses_complete_installation_bindin
|
||||
get_file_stream=AsyncMock(return_value=b'knowledge-file'),
|
||||
)
|
||||
runtime_handler.send_file = AsyncMock(return_value='knowledge-file-key')
|
||||
legacy_context = workspace_context().for_installation(
|
||||
installation_context.installation_uuid
|
||||
)
|
||||
legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
@@ -505,3 +503,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
'runtime_id': 'runtime-a',
|
||||
}
|
||||
assert request.get('context') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_debug_info_converts_execution_context_to_sdk_action_context():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'})
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
result = await runtime_handler.get_debug_info(execution_context)
|
||||
|
||||
assert result == {'plugin_debug_key': 'debug-key'}
|
||||
assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
@@ -1305,6 +1305,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()
|
||||
@@ -1312,6 +1313,7 @@ class TestScanModels:
|
||||
return_value={
|
||||
'data': [
|
||||
{'id': 'gpt-4o'},
|
||||
{'id': 'o3'},
|
||||
{'id': 'text-embedding-3-small'},
|
||||
{'id': 'bge-reranker-v2'},
|
||||
]
|
||||
@@ -1328,6 +1330,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'
|
||||
|
||||
@@ -1375,8 +1378,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
|
||||
@@ -1405,8 +1408,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
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
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
|
||||
|
||||
|
||||
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()))
|
||||
emitted = [
|
||||
chunk
|
||||
async for chunk in request.invoke_llm_stream(
|
||||
None,
|
||||
_runtime_model(request),
|
||||
[],
|
||||
remove_think=True,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in emitted) == 'answer'
|
||||
assert (
|
||||
''.join(
|
||||
chunk.provider_specific_fields.get('reasoning_content', '')
|
||||
for chunk in emitted
|
||||
if chunk.provider_specific_fields
|
||||
)
|
||||
== '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()))
|
||||
emitted = [
|
||||
chunk
|
||||
async for chunk in request.invoke_llm_stream(
|
||||
None,
|
||||
_runtime_model(request),
|
||||
[],
|
||||
remove_think=False,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in emitted) == '<think>\nprivate \n</think>\nanswer'
|
||||
assert (
|
||||
''.join(
|
||||
chunk.provider_specific_fields.get('reasoning_content', '')
|
||||
for chunk in emitted
|
||||
if chunk.provider_specific_fields
|
||||
)
|
||||
== '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()))
|
||||
emitted = [
|
||||
chunk
|
||||
async for chunk in request.invoke_llm_stream(
|
||||
None,
|
||||
_runtime_model(request, 'high', name='claude-sonnet-4-6'),
|
||||
[],
|
||||
remove_think=False,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in emitted) == '<think>\nprivate \n</think>\nanswer'
|
||||
assert (
|
||||
next(
|
||||
chunk.provider_specific_fields['thinking_blocks']
|
||||
for chunk in emitted
|
||||
if chunk.provider_specific_fields and 'thinking_blocks' in chunk.provider_specific_fields
|
||||
)
|
||||
== 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'
|
||||
@@ -510,6 +510,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):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +15,12 @@ def get_heartbeat_module():
|
||||
return import_module('langbot.pkg.telemetry.heartbeat')
|
||||
|
||||
|
||||
def test_workspace_created_timestamp_treats_naive_database_values_as_utc():
|
||||
heartbeat = get_heartbeat_module()
|
||||
created_at = datetime(2026, 8, 4, 0, 0, 0)
|
||||
assert heartbeat._workspace_created_timestamp(created_at) == 1785801600
|
||||
|
||||
|
||||
def make_app():
|
||||
ap = Mock()
|
||||
ap.instance_config = Mock()
|
||||
@@ -57,15 +64,17 @@ def make_app():
|
||||
|
||||
class TestBuildHeartbeatPayload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_shape(self):
|
||||
async def test_payload_shape(self, monkeypatch):
|
||||
heartbeat = get_heartbeat_module()
|
||||
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
||||
ap = make_app()
|
||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||
|
||||
assert payload['event_type'] == 'instance_heartbeat'
|
||||
assert payload['query_id'] == ''
|
||||
assert payload['workspace_uuid'] == 'workspace-a'
|
||||
assert 'instance_id' not in payload
|
||||
assert payload['instance_id']
|
||||
assert payload['workspace_create_ts'] == 0
|
||||
assert 'instance_create_ts' in payload
|
||||
assert 'timestamp' in payload
|
||||
f = payload['features']
|
||||
@@ -100,8 +109,9 @@ class TestBuildHeartbeatPayload:
|
||||
assert payload['features']['pipeline_count'] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
|
||||
async def test_cloud_counts_loaded_registries_without_tenant_sql(self, monkeypatch):
|
||||
heartbeat = get_heartbeat_module()
|
||||
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
||||
ap = make_app()
|
||||
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||
ap.persistence_mgr.execute_async = AsyncMock(
|
||||
@@ -140,7 +150,11 @@ class TestBuildHeartbeatPayload:
|
||||
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
|
||||
SimpleNamespace(workspace_uuid='workspace-b', placement_generation=9),
|
||||
SimpleNamespace(
|
||||
workspace_uuid='workspace-b',
|
||||
placement_generation=9,
|
||||
workspace_created_at=datetime(2026, 8, 4, tzinfo=timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
||||
@@ -150,7 +164,9 @@ class TestBuildHeartbeatPayload:
|
||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
||||
|
||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
||||
assert all('instance_id' not in payload for payload in payloads)
|
||||
assert all(payload['instance_id'] for payload in payloads)
|
||||
assert payloads[0]['workspace_create_ts'] == 0
|
||||
assert payloads[1]['workspace_create_ts'] == 1785801600
|
||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
||||
|
||||
@@ -596,6 +596,36 @@ class TestTelemetryManagedRuntimeAuthentication:
|
||||
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
||||
|
||||
|
||||
class TestAuthenticatedWorkspaceReporter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_owner_access_token_is_sent_as_bearer(self):
|
||||
telemetry = get_telemetry_module()
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
mock_app.user_service = Mock()
|
||||
mock_app.user_service.get_workspace_owner = AsyncMock(
|
||||
return_value=Mock(user='owner@example.com', space_access_token='expired-token')
|
||||
)
|
||||
mock_app.space_service = Mock()
|
||||
mock_app.space_service.get_valid_access_token = AsyncMock(return_value='refreshed-workspace-owner-token')
|
||||
manager = telemetry.TelemetryManager(mock_app)
|
||||
manager.telemetry_config = {'url': 'https://example.com'}
|
||||
|
||||
response = Mock(status_code=200, text='')
|
||||
response.json = Mock(return_value={'code': 0})
|
||||
mock_client = Mock()
|
||||
mock_client.post = Mock(return_value=response)
|
||||
|
||||
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
|
||||
await manager.send({'query_id': 'q-1', 'workspace_uuid': 'workspace-1'})
|
||||
|
||||
mock_app.user_service.get_workspace_owner.assert_awaited_once_with('workspace-1')
|
||||
mock_app.space_service.get_valid_access_token.assert_awaited_once_with('owner@example.com')
|
||||
assert mock_client.post.call_args.kwargs['headers'] == {
|
||||
'Authorization': 'Bearer refreshed-workspace-owner-token'
|
||||
}
|
||||
|
||||
|
||||
class TestStartSendTask:
|
||||
"""Tests for start_send_task() method."""
|
||||
|
||||
|
||||
@@ -7,25 +7,28 @@ from types import SimpleNamespace
|
||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
|
||||
instance_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
||||
|
||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
||||
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
|
||||
assert workspace_uuid_from_instance_id(f'instance_{instance_uuid}') == instance_uuid
|
||||
|
||||
|
||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
first = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
second = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
first = workspace_uuid_from_instance_id('instance_migration_test')
|
||||
second = workspace_uuid_from_instance_id('instance_migration_test')
|
||||
|
||||
assert first == second
|
||||
assert str(uuid.UUID(first)) == first
|
||||
|
||||
|
||||
def test_query_telemetry_identity_uses_execution_workspace_only():
|
||||
def test_query_telemetry_identity_reports_instance_and_workspace():
|
||||
from langbot.pkg.telemetry.identity import workspace_identity
|
||||
|
||||
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
|
||||
identity = workspace_identity(SimpleNamespace(workspace_uuid='workspace-a', instance_uuid='instance-a'))
|
||||
|
||||
assert identity == {"workspace_uuid": "workspace-a"}
|
||||
assert identity == {
|
||||
'instance_id': 'instance-a',
|
||||
'workspace_uuid': 'workspace-a',
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestVectorDBManagerInitialization:
|
||||
mock_app,
|
||||
connection_string='postgresql://user:pass@host:5432/langbot',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -241,7 +241,7 @@ class TestVectorDBManagerInitialization:
|
||||
user='admin',
|
||||
password='secret',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -269,7 +269,7 @@ class TestVectorDBManagerInitialization:
|
||||
user='postgres',
|
||||
password='postgres',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user