feat(models): synchronize Space TTS and ASR models (#2857)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-11-04 01:06:34 +08:00
committed by GitHub
co-authored by multica-agent
parent 532b8ce204
commit 63cb2bc460
7 changed files with 182 additions and 5 deletions
+4
View File
@@ -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.
+12 -2
View File
@@ -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}')
+2 -1
View File
@@ -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
@@ -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.')
@@ -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:
+69 -1
View File
@@ -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
@@ -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)