mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-05 07:12:02 +08:00
feat(models): synchronize Space TTS and ASR models (#2857)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
532b8ce204
commit
63cb2bc460
@@ -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.
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user