diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md index 241cc1238..81c5f5a7d 100644 --- a/skills/skills/langbot-space-ops/SKILL.md +++ b/skills/skills/langbot-space-ops/SKILL.md @@ -57,6 +57,10 @@ Requests without a valid PAT get `401 Unauthorized`. `/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`. All tools are read-only. +`select_models.category` accepts `chat`, `embedding`, `rerank`, `tts`, or `asr`. +TTS entries include `model.default_voice`; pass it as `extra_args.voice` when +creating a TTS model. Some providers require a model-qualified voice name. + ## How to use 1. Create a PAT in your Space account settings. diff --git a/src/langbot/pkg/cloud/model_catalog.py b/src/langbot/pkg/cloud/model_catalog.py index 1d7808202..2f4075478 100644 --- a/src/langbot/pkg/cloud/model_catalog.py +++ b/src/langbot/pkg/cloud/model_catalog.py @@ -14,11 +14,14 @@ from ..entity.persistence import model as persistence_model LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions' LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models' _MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921') -_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'} +SUPPORTED_MODEL_CATEGORIES = ('chat', 'embedding', 'rerank', 'tts', 'asr') +_SUPPORTED_CATEGORIES = set(SUPPORTED_MODEL_CATEGORIES) _MODEL_TABLES = ( persistence_model.LLMModel, persistence_model.EmbeddingModel, persistence_model.RerankModel, + persistence_model.TTSModel, + persistence_model.ASRModel, ) @@ -27,7 +30,8 @@ class CloudModelCatalogItem(BaseModel): uuid: str = Field(min_length=1, max_length=255) model_id: str = Field(min_length=1, max_length=255) - category: Literal['chat', 'embedding', 'rerank'] + category: Literal['chat', 'embedding', 'rerank', 'tts', 'asr'] + default_voice: str | None = None llm_abilities: tuple[str, ...] = () is_featured: bool = False featured_order: int = 0 @@ -334,4 +338,10 @@ class CloudModelCatalogSyncService: return persistence_model.EmbeddingModel, common if item.category == 'rerank': return persistence_model.RerankModel, common + if item.category == 'tts': + if item.default_voice: + common['extra_args'] = {'voice': item.default_voice} + return persistence_model.TTSModel, common + if item.category == 'asr': + return persistence_model.ASRModel, common raise ValueError(f'Unsupported model category: {item.category}') diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py index 845a55256..73b16262f 100644 --- a/src/langbot/pkg/entity/dto/space_model.py +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -40,7 +40,8 @@ class SpaceModel(pydantic.BaseModel): uuid: str model_id: str provider: str - category: str # chat / embedding + category: str # chat / embedding / rerank / tts / asr + default_voice: str | None = None llm_abilities: list[str] | None = None is_featured: bool = False featured_order: int = 0 diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index e0713f732..f375b9e8c 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -578,6 +578,16 @@ class ModelManager: for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True) } existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)} + audio_services = { + 'tts': self.ap.tts_models_service, + 'asr': self.ap.asr_models_service, + } + existing_audio_models = {} + for category, service in audio_services.items(): + if any(model.category == category for model in space_models): + existing_audio_models[category] = { + model['uuid']: model for model in await getattr(service, f'get_{category}_models')(context) + } created = 0 updated = 0 @@ -676,6 +686,31 @@ class ModelManager: ) updated += 1 + elif space_model.category in audio_services: + category = space_model.category + service = audio_services[category] + existing = existing_audio_models[category].get(space_model.uuid) + desired = { + 'name': space_model.model_id, + 'provider_uuid': space_model_provider.uuid, + 'prefered_ranking': space_model.featured_order, + } + if existing is None: + extra_args = {} + if category == 'tts' and space_model.default_voice: + extra_args['voice'] = space_model.default_voice + await getattr(service, f'create_{category}_model')( + context, + {'uuid': space_model.uuid, **desired, 'extra_args': extra_args}, + preserve_uuid=True, + ) + created += 1 + elif existing.get('provider_uuid') == space_model_provider.uuid and any( + existing.get(key) != value for key, value in desired.items() + ): + await getattr(service, f'update_{category}_model')(context, space_model.uuid, dict(desired)) + updated += 1 + if created or updated: self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.') diff --git a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml index 4bfdb98a6..1b0980844 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/spacechatcmpl.yaml @@ -23,11 +23,13 @@ spec: type: integer required: true default: 120 - alias: "space LangBot Space langbot-space 官方 official 自有 内置 rerank embedding" + alias: "space LangBot Space langbot-space 官方 official 自有 内置 rerank embedding tts asr" support_type: - llm - text-embedding - rerank + - tts + - asr provider_category: maas execution: python: diff --git a/tests/unit_tests/cloud/test_model_catalog.py b/tests/unit_tests/cloud/test_model_catalog.py index d8d9e625d..62e9e6bfe 100644 --- a/tests/unit_tests/cloud/test_model_catalog.py +++ b/tests/unit_tests/cloud/test_model_catalog.py @@ -10,13 +10,15 @@ import sqlalchemy from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.cloud.model_catalog import ( + SUPPORTED_MODEL_CATEGORIES, + CloudModelCatalogItem, CloudModelCatalogSnapshot, CloudModelCatalogSyncService, system_model_uuid, system_provider_uuid, ) from langbot.pkg.entity.persistence.base import Base -from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider +from langbot.pkg.entity.persistence.model import ASRModel, TTSModel, EmbeddingModel, LLMModel, ModelProvider from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode @@ -29,6 +31,72 @@ OWNER_A = '10000000-0000-4000-8000-000000000001' OWNER_B = '10000000-0000-4000-8000-000000000002' +@pytest.mark.parametrize('category,table', [('tts', TTSModel), ('asr', ASRModel)]) +async def test_audio_catalog_create_update_delete_and_voice(tmp_path, category, table): + assert SUPPORTED_MODEL_CATEGORIES == ('chat', 'embedding', 'rerank', 'tts', 'asr') + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "audio.db"}') + manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) + manager.db = SimpleNamespace(get_engine=lambda: engine) + app = SimpleNamespace(persistence_mgr=manager) + snapshot = _snapshot() + item = CloudModelCatalogItem(uuid='audio', model_id='audio-test', category=category, default_voice='cosy:alex') + snapshot = snapshot.model_copy(update={'models': (item,)}) + service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID) + billing = snapshot.workspaces[0] + model_uuid = system_model_uuid(WORKSPACE_A, category, 'audio') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute( + sqlalchemy.insert(Workspace).values( + uuid=WORKSPACE_A, + instance_uuid=INSTANCE_UUID, + name='A', + slug='a', + source='cloud_projection', + ) + ) + assert await service._sync_workspace(WORKSPACE_A, snapshot, billing) == { + 'created': 2, + 'updated': 0, + 'deleted': 0, + } + assert await service._sync_workspace(WORKSPACE_A, snapshot, billing) == { + 'created': 0, + 'updated': 0, + 'deleted': 0, + } + async with manager.tenant_uow(WORKSPACE_A) as uow: + model = await uow.session.get(table, model_uuid) + assert model.extra_args == ({'voice': 'cosy:alex'} if category == 'tts' else {}) + item = item.model_copy(update={'model_id': 'renamed', 'default_voice': 'cosy:bella', 'is_featured': True}) + snapshot = snapshot.model_copy(update={'models': (item,)}) + assert await service._sync_workspace(WORKSPACE_A, snapshot, billing) == { + 'created': 0, + 'updated': 1, + 'deleted': 0, + } + async with manager.tenant_uow(WORKSPACE_A) as uow: + model = await uow.session.get(table, model_uuid) + assert model.name == 'renamed' + assert model.extra_args == ({'voice': 'cosy:bella'} if category == 'tts' else {}) + item = item.model_copy(update={'default_voice': None}) + snapshot = snapshot.model_copy(update={'models': (item,)}) + await service._sync_workspace(WORKSPACE_A, snapshot, billing) + async with manager.tenant_uow(WORKSPACE_A) as uow: + assert (await uow.session.get(table, model_uuid)).extra_args == {} + snapshot = snapshot.model_copy(update={'models': ()}) + assert await service._sync_workspace(WORKSPACE_A, snapshot, billing) == { + 'created': 0, + 'updated': 0, + 'deleted': 1, + } + async with manager.tenant_uow(WORKSPACE_A) as uow: + assert await uow.session.get(table, model_uuid) is None + finally: + await engine.dispose() + + class _CatalogProvider: def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None: self.snapshot = snapshot diff --git a/tests/unit_tests/provider/test_model_manager.py b/tests/unit_tests/provider/test_model_manager.py index 1185e55d5..cd9ef4651 100644 --- a/tests/unit_tests/provider/test_model_manager.py +++ b/tests/unit_tests/provider/test_model_manager.py @@ -33,6 +33,61 @@ from tests.unit_tests.provider.conftest import ( # ============================================================================ +@pytest.mark.asyncio +@pytest.mark.parametrize('category,voice', [('tts', 'cosy:alex'), ('tts', None), ('asr', None)]) +async def test_space_audio_sync_creates_updates_and_preserves_custom_models(mock_app_for_modelmgr, category, voice): + app = mock_app_for_modelmgr + provider = persistence_model.ModelProvider(uuid='space-provider', requester='space-chat-completions') + app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider)) + app.space_service.get_models = AsyncMock( + return_value=[ + SimpleNamespace( + uuid='audio-model', model_id='audio-test', category=category, default_voice=voice, featured_order=10 + ) + ] + ) + app.llm_model_service.get_llm_models = AsyncMock(return_value=[]) + app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[]) + app.rerank_models_service = AsyncMock() + app.rerank_models_service.get_rerank_models.return_value = [] + app.tts_models_service = AsyncMock() + app.asr_models_service = AsyncMock() + service = getattr(app, f'{category}_models_service') + list_models = getattr(service, f'get_{category}_models') + create_model = getattr(service, f'create_{category}_model') + update_model = getattr(service, f'update_{category}_model') + list_models.return_value = [] + manager = ModelManager(app) + await manager.sync_new_models_from_space(TEST_EXECUTION_CONTEXT) + values = { + 'uuid': 'audio-model', + 'name': 'audio-test', + 'provider_uuid': 'space-provider', + 'extra_args': {'voice': voice} if voice else {}, + 'prefered_ranking': 10, + } + create_model.assert_awaited_once_with(TEST_EXECUTION_CONTEXT, values, preserve_uuid=True) + + list_models.return_value = [{**values, 'name': 'old-name', 'extra_args': {'voice': 'user-choice'}}] + await manager.sync_new_models_from_space(TEST_EXECUTION_CONTEXT) + update_model.assert_awaited_once_with( + TEST_EXECUTION_CONTEXT, + 'audio-model', + { + 'name': 'audio-test', + 'provider_uuid': 'space-provider', + 'prefered_ranking': 10, + }, + ) + update_model.reset_mock() + list_models.return_value = [values] + await manager.sync_new_models_from_space(TEST_EXECUTION_CONTEXT) + update_model.assert_not_awaited() + list_models.return_value = [{**values, 'name': 'custom', 'provider_uuid': 'custom-provider'}] + await manager.sync_new_models_from_space(TEST_EXECUTION_CONTEXT) + update_model.assert_not_awaited() + + @pytest.mark.asyncio async def test_model_manager_initialize_with_fake_requesters(fake_requester_registry): """Test ModelManager initializes with fake requester registry.""" @@ -112,6 +167,8 @@ async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_mod app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[]) app.rerank_models_service = AsyncMock() app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[]) + app.tts_models_service = AsyncMock() + app.asr_models_service = AsyncMock() model_mgr = ModelManager(app) await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)