mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
设置 - 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>
349 lines
17 KiB
Python
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'
|