Files
autoclip/backend/tests/test_ai_model_settings.py
T
周小舟andCursor a19cac4392 feat: 重排 AI 设置、首次引导弹窗、示例项目与 Studio 编辑器重做
设置
- AI 模型页拆为 AI 服务 / 字幕转写 / 封面 / 高级四节,逻辑抽到 modelSettingsLogic + useModelSettings,
  ProviderFields / ModelPicker 独立组件;首次配置默认开启画面识别与 AI 封面(参考视频画面)
- 供应商分组「模型聚合站」改为「推荐」,保留赞助说明
- 首页首次进入弹出「连接 AI 服务」对话框(FirstRunSetup),未连接时导入被拦下并引导
- 修复对话框内下拉层级、Esc 误关闭

示例项目
- 内置 Sam Altman 访谈三段拼接原片 + 字幕 + 封面(backend/assets/example),
  一键创建已完成项目,携带来源链接与元数据;卡片 / 详情页标出示例与来源

Studio / 发布
- 编辑器右侧面板按 DESIGN.md 重做(DraftSettingsPanel):字幕样式改为全片四种带预览的样式,
  片头文字降为可选并用视觉缩略图选择;左侧播放器吸顶随滚动可见
- 竖屏裁切增加说话人跟随自动取景(YuNet 人脸 + 口部运动,按需安装 OpenCV 运行时),
  渲染支持逐段 crop 轨迹
- 导入确认页去掉重复的分析方式提问,控件统一 Row/Segmented;发布页文案去术语化,
  封面入口补齐并默认自动生成

其他
- 后端 ai-model-settings 文档模型、云端转写、模型目录等配套服务与测试
- 8 种语言文案同步

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 10:26:39 +08:00

349 lines
17 KiB
Python

"""Connection isolation, migration, atomic persistence and dynamic capability data."""
import asyncio
import json
import os
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from backend.services import ai_model_settings as ai
from backend.core import model_registry as registry
legacy_migrate = ai.migrate_legacy
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
monkeypatch.setenv('AUTOCLIP_DATA_DIR', str(tmp_path))
monkeypatch.setenv('AUTOCLIP_APP_DIR', str(tmp_path))
monkeypatch.setattr(ai, 'migrate_legacy', lambda: ai.ModelSettings())
def example():
return ai.ModelSettings(
connections=[
ai.Connection(id='one', name='Analysis account', provider='openai', base_url='https://one.example/v1', api_key='sk-first-secret'),
ai.Connection(id='two', name='Cover account', provider='openai', base_url='https://two.example/v1', api_key='sk-second-secret'),
],
analysis=ai.Assignment(connection_id='one', model='custom-vision', capability='multimodal'),
cover=ai.Assignment(connection_id='two', model='my-image'), cover_enabled=True,
)
def test_independent_connections_and_key_references_survive_analysis_change():
from backend.services import cover
from backend.services.studio import vision_settings
ai.save(example())
assert vision_settings.effective()['api_key'] == 'sk-first-secret'
assert cover.load_config().api_key == 'sk-second-secret'
config = ai.load()
config.analysis.model = 'another-model'
config.connections[0].api_key = 'rotated-key'
ai.save(config)
assert vision_settings.effective()['model'] == 'another-model'
assert vision_settings.effective()['api_key'] == 'rotated-key'
assert cover.load_config().model == 'my-image'
assert cover.load_config().api_key == 'sk-second-secret'
def test_same_provider_multiple_accounts_and_separate_vision():
config = example()
config.vision = ai.Assignment(connection_id='two', model='vision-b', capability='multimodal')
config.analysis.capability = 'text'
ai.save(config)
assert ai.vision_endpoint(ai.load()) == {'base_url': 'https://two.example/v1', 'api_key': 'sk-second-secret', 'model': 'vision-b'}
def test_secrets_are_masked_preserved_and_can_be_cleared():
response = ai.save(example())
assert 'sk-first-secret' not in json.dumps(response)
assert all('api_key' not in c for c in response['connections'])
ai.save(ai.ModelSettings.model_validate(response))
assert ai.load().connections[0].api_key == 'sk-first-secret'
response['connections'][0]['api_key'] = ''
ai.save(ai.ModelSettings.model_validate(response))
assert ai.load().connections[0].api_key == ''
assert os.stat(ai.path()).st_mode & 0o777 == 0o600
def test_changed_endpoint_cannot_reuse_hidden_key():
response = ai.save(example())
response['connections'][0]['base_url'] = 'https://new.example/v1'
before = ai.path().read_bytes()
with pytest.raises(ValueError, match='重新填写'):
ai.save(ai.ModelSettings.model_validate(response))
assert ai.path().read_bytes() == before
def test_invalid_binding_and_visual_choice_do_not_partially_save():
ai.save(example())
before = ai.path().read_bytes()
config = example()
config.analysis.capability = 'text'
config.analysis_mode = 'visual'
with pytest.raises(ValueError, match='多模态'):
ai.save(config)
assert ai.path().read_bytes() == before
raw = example().model_dump()
raw['connections'].pop()
with pytest.raises(ValidationError, match='引用的服务'):
ai.ModelSettings.model_validate(raw)
def test_atomic_write_failure_leaves_old_config(monkeypatch):
ai.save(example())
before = ai.path().read_bytes()
def fail(*args):
raise OSError('disk full')
monkeypatch.setattr(ai.os, 'replace', fail)
with pytest.raises(OSError):
ai.save(example())
assert ai.path().read_bytes() == before
assert not list(ai.path().parent.glob('*.tmp'))
def test_local_analysis_does_not_disable_cloud_cover():
from backend.services import cover
config = example()
config.connections[0] = ai.Connection(id='one', name='Local', provider='ollama', api_key='')
ai.save(config)
assert ai.vision_endpoint(ai.load())['base_url'] == 'http://localhost:11434/v1'
assert cover.load_config().enabled and cover.load_config().api_key == 'sk-second-secret'
def test_runtime_manager_reloads_connection_document(tmp_path, monkeypatch):
from backend.core.llm_manager import LLMManager
monkeypatch.setattr(LLMManager, '_sync_config_if_needed', lambda self: None)
monkeypatch.setattr(LLMManager, '_initialize_provider', lambda self: None)
ai.save(example())
manager = LLMManager(settings_file=tmp_path / 'settings.json')
assert manager.settings['openai_api_key'] == 'sk-first-secret'
config = example()
config.analysis = ai.Assignment(connection_id='two', model='new-analysis')
ai.save(config)
manager._reload_if_settings_changed()
assert manager.settings['openai_api_key'] == 'sk-second-secret'
assert manager.settings['model_name'] == 'new-analysis'
def test_qwen_verified_and_unknown_is_not_text_only():
c = ai.Connection(id='q', name='Qwen', provider='dashscope')
assert registry.lookup_capability(c, 'qwen3.8-max') == 'multimodal'
assert registry.lookup_capability(c, 'qwen3.8-flash') == 'multimodal'
assert registry.lookup_capability(c, 'qwen-future-unknown') is None
unknown = registry._records(c, [{'id': 'future-model'}], {})[0]
assert unknown['capability'] is None and unknown['analysis']
def test_http_config_and_discovery_use_saved_secrets_without_returning_them(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.api.v1.settings import router
app = FastAPI()
app.include_router(router)
async def discover(connection, refresh):
assert connection.api_key == 'sk-first-secret'
return {'models': [{'id': 'new-model', 'capability': 'multimodal', 'analysis': True, 'image': False}], 'source': 'live'}
monkeypatch.setattr(registry, 'discover', discover)
with TestClient(app) as client:
response = client.put('/settings/ai-models', json=example().model_dump())
assert response.status_code == 200
assert 'sk-first-secret' not in response.text
connection = response.json()['connections'][0]
listing = client.post('/settings/ai-models/discover', json={'connection': connection})
assert listing.status_code == 200 and listing.json()['models'][0]['id'] == 'new-model'
assert client.get('/settings/ai-models').json()['cover']['connection_id'] == 'two'
def test_no_unselected_ocr_model_is_called():
from backend.services import cover
config = example()
config.analysis.capability = 'text'
ai.save(config)
with pytest.raises(cover.ImageError, match='跳过校对'):
cover.verify_endpoint(cover.load_config())
def test_live_metadata_overrides_catalog_and_keeps_new_models(monkeypatch):
c = example().connections[0]
async def metadata():
return None
async def fetch(*args, **kwargs):
return {'data': [{'id': 'brand-new', 'architecture': {'input_modalities': ['text', 'image'], 'output_modalities': ['text']}},
{'id': 'image-new', 'architecture': {'input_modalities': ['text'], 'output_modalities': ['image']}}]}
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fetch)
result = asyncio.run(registry.discover(c))
assert [m['id'] for m in result['models']] == ['brand-new', 'image-new']
assert registry.lookup_capability(c, 'brand-new') == 'multimodal'
assert result['models'][1]['image'] and not result['models'][1]['analysis']
assert 'sk-first-secret' not in registry._path().read_text()
def test_failed_refresh_preserves_last_live_list(monkeypatch):
c = example().connections[0]
registry._update(registry._scope(c), {'models': [{'id': 'saved-model'}], 'updated_at': 1, 'source': 'live'})
async def metadata():
return None
async def fail(*args, **kwargs):
raise RuntimeError('network unavailable')
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fail)
result = asyncio.run(registry.discover(c, refresh=True))
assert result['models'] == [{'id': 'saved-model'}]
assert result['source'] == 'cache' and result['warning']
def test_migration_keeps_legacy_endpoints_without_writing(monkeypatch):
# Retrieve the real function, replaced in the fixture only to isolate saves.
from backend.services import cover
from backend.services.studio import vision_settings, analysis_preferences
from backend.core import llm_manager
manager = SimpleNamespace(settings={'llm_provider': 'openai', 'model_name': 'main-model'},
_reload_if_settings_changed=lambda: None,
openai_compatible_endpoint=lambda: {'base_url': 'https://main.example/v1', 'api_key': 'main-key'})
monkeypatch.setattr(llm_manager, 'get_llm_manager', lambda: manager)
monkeypatch.setattr(vision_settings, 'effective', lambda: {'mode': 'custom', 'base_url': 'https://vision.example/v1', 'api_key': 'vision-key', 'model': 'vision-model'})
monkeypatch.setattr(cover, 'load_config', lambda: cover.CoverConfig(enabled=True, model='image-model', api_key='image-key', base_url='https://image.example/v1'))
monkeypatch.setattr(analysis_preferences, 'load', lambda: analysis_preferences.AnalysisPreferences(analysis_mode='subtitle'))
config = legacy_migrate()
assert len(config.connections) == 3
assert ai.connection_for(config, config.vision).api_key == 'vision-key'
assert ai.connection_for(config, config.cover).api_key == 'image-key'
assert config.analysis_mode == 'subtitle'
assert not ai.path().exists()
def test_infistar_public_preview_and_exact_account_intersection(monkeypatch):
calls = []
async def fetch(url, **kwargs):
calls.append((url, kwargs))
if url.endswith('/api/pricing'):
assert not kwargs.get('headers')
return {'success': True, 'data': [
{'model_name': 'new-image', 'supported_endpoint_types': ['image-generation']},
{'model_name': 'edit-only', 'supported_endpoint_types': ['image-edit']},
{'model_name': 'new-chat', 'supported_endpoint_types': ['openai'], 'tags': '图像理解,工具调用'},
{'model_name': 'public-only', 'supported_endpoint_types': ['image-generation']},
]}
return {'data': [{'id': 'new-image'}, {'id': 'new-chat'}, {'id': 'edit-only'}]}
async def metadata():
return None
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fetch)
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
connection = ai.Connection(id='preview', name='Infistar', provider='infistar')
preview = asyncio.run(registry.discover(connection))
assert preview['preview'] is True
assert len(calls) == 1 and calls[0][0].endswith('/api/pricing')
assert [m['id'] for m in preview['models'] if m['image']] == ['new-image', 'public-only']
connection.api_key = 'test-secret'
account = asyncio.run(registry.discover(connection))
assert not account.get('preview')
assert [m['id'] for m in account['models'] if m['image']] == ['new-image']
assert [m['id'] for m in account['models'] if m['analysis']] == ['new-chat']
assert registry.lookup_capability(connection, 'new-chat') == 'multimodal'
assert 'test-secret' not in registry._path().read_text()
def test_public_catalog_outage_preserves_preview(monkeypatch):
async def fail(*args, **kwargs):
raise RuntimeError('offline')
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fail)
connection = ai.Connection(id='preview', name='Infistar', provider='infistar')
result = asyncio.run(registry.discover(connection))
assert result['preview']
assert len([m for m in result['models'] if m['image']]) > 1
assert '缓存' in result['warning']
def test_official_preview_does_not_call_authenticated_models_endpoint(monkeypatch):
async def metadata():
return None
async def unexpected(*args, **kwargs):
raise AssertionError('preview must not call authenticated endpoint')
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', unexpected)
result = asyncio.run(registry.discover(ai.Connection(id='preview', name='OpenAI', provider='openai')))
assert result['preview'] and result['models']
@pytest.mark.parametrize('provider,expected', [
('seed', {'doubao-seedream-5-0-flash-260915', 'doubao-seedream-5-0-pro-260628'}),
('dashscope', {'qwen-image-3.0', 'wan2.7-image', 'z-image-turbo'}),
('gemini', {'gemini-3.1-flash-image', 'gemini-3-pro-image'}),
('openai', {'gpt-image-2.5-flare', 'gpt-image-2'}),
('glm', {'glm-image', 'cogview-4'}),
('grok', {'grok-imagine-image-2.0', 'grok-imagine-image'}),
])
def test_official_image_previews_include_verified_models(monkeypatch, provider, expected):
async def metadata():
return None
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
result = asyncio.run(registry.discover(ai.Connection(id='preview', name=provider, provider=provider)))
assert expected <= {m['id'] for m in result['models'] if m['image']}
assert result['preview']
if provider == 'seed':
assert 'doubao-seedream-5-0-260128' not in {m['id'] for m in result['models']}
def test_official_image_and_gateway_protocols_stay_independent():
for provider in ['gemini', 'grok', 'glm']:
endpoint = ai.image_endpoint(ai.Connection(id='one', name='test', provider=provider))
assert endpoint['provider'] == provider
assert not endpoint['base_url'].endswith('/openai')
assert ai.image_endpoint(ai.Connection(id='one', name='test', provider='infistar'))['provider'] == 'openai'
def test_research_agent_image_output_is_not_a_native_cover_model():
connection = ai.Connection(id='preview', name='Gemini', provider='gemini')
data = {'metadata': {'providers': {'google': {'deep-research-preview-04-2026': {'image_output': True}}}}}
records = registry._records(connection, ['deep-research-preview-04-2026'], data)
assert not records[0]['image'] and not records[0]['analysis']
def test_transcription_model_is_saved_and_used_without_overriding_explicit_call(monkeypatch, tmp_path):
from backend.utils import speech_recognizer as speech
config = example()
config.transcription = ai.Transcription(model='large-v3')
saved = ai.save(config)
assert saved['transcription'] == {'provider': 'whisper_local', 'model': 'large-v3', 'connection_id': None}
calls = []
class FakeRecognizer:
def __init__(self, config=None):
self.config = config
def generate_subtitle(self, video, output, config):
calls.append(config.model)
assert config.enable_fallback is False
return output
monkeypatch.setattr(speech, 'SpeechRecognizer', FakeRecognizer)
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt', method='whisper_local')
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt', method='whisper_local', model='tiny')
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt')
assert calls == ['large-v3', 'tiny', 'large-v3']
def test_cloud_transcription_binding_is_validated():
from pydantic import ValidationError
from backend.services.ai_model_settings import ModelSettings, Connection, Transcription
connection = Connection(id='asr', name='ASR', provider='openai')
value = ModelSettings(connections=[connection], transcription=Transcription(provider='cloud', model='whisper-1', connection_id='asr'))
assert value.transcription.connection_id == 'asr'
with pytest.raises(ValidationError):
ModelSettings(transcription=Transcription(provider='cloud', model='whisper-1', connection_id='gone'))
with pytest.raises(ValidationError):
ModelSettings(connections=[connection], transcription=Transcription(provider='cloud', model='gpt-4o-transcribe', connection_id='asr'))
def test_cloud_selection_routes_auto_without_loading_whisper(monkeypatch, tmp_path):
from backend.services import ai_model_settings as settings, cloud_transcription
from backend.utils.speech_recognizer import generate_subtitle_for_video, configured_whisper_model
value = settings.ModelSettings(connections=[settings.Connection(id='asr', name='ASR', provider='openai')],
transcription=settings.Transcription(provider='cloud', connection_id='asr', model='whisper-1'))
monkeypatch.setattr(settings, 'load', lambda: value)
calls = []
monkeypatch.setattr(cloud_transcription, 'transcribe', lambda *args: calls.append(args) or tmp_path / 'result.srt')
assert generate_subtitle_for_video(tmp_path / 'video.mp4') == tmp_path / 'result.srt'
assert calls[0][2] is value
assert configured_whisper_model('small') == 'small'