feat: add configurable AI model pool
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from app.integrations.model_router import ModelRouter
|
||||
|
||||
|
||||
def test_auto_route_requires_orchestrator_choice_with_multiple_candidates() -> None:
|
||||
pool = [
|
||||
{
|
||||
"id": "image-a",
|
||||
"name": "Image A",
|
||||
"model_id": "image-a-model",
|
||||
"category": "multimodal",
|
||||
"provider": "provider-a",
|
||||
"enabled": True,
|
||||
"capabilities": ["image_generation"],
|
||||
},
|
||||
{
|
||||
"id": "image-b",
|
||||
"name": "Image B",
|
||||
"model_id": "image-b-model",
|
||||
"category": "multimodal",
|
||||
"provider": "provider-b",
|
||||
"enabled": True,
|
||||
"capabilities": ["image_generation"],
|
||||
},
|
||||
]
|
||||
tests = {
|
||||
"model:image-a": {"ok": True},
|
||||
"model:image-b": {"ok": True},
|
||||
}
|
||||
router = ModelRouter({"model_pool": pool, "image_routing_mode": "auto"}, tests)
|
||||
|
||||
with pytest.raises(RuntimeError, match="总调度返回模型实例 ID"):
|
||||
router.choose("image")
|
||||
assert router.choose("image", "image-b")["model_id"] == "image-b-model"
|
||||
|
||||
|
||||
def test_manual_route_uses_the_user_selected_model() -> None:
|
||||
pool = [
|
||||
{
|
||||
"id": "spatial",
|
||||
"name": "Spatial",
|
||||
"model_id": "spatial-model",
|
||||
"category": "multimodal",
|
||||
"provider": "custom",
|
||||
"enabled": True,
|
||||
"capabilities": ["spatial_understanding"],
|
||||
}
|
||||
]
|
||||
router = ModelRouter(
|
||||
{
|
||||
"model_pool": pool,
|
||||
"spatial_routing_mode": "manual",
|
||||
"spatial_model_id": "spatial",
|
||||
},
|
||||
{"model:spatial": {"ok": True}},
|
||||
)
|
||||
|
||||
assert router.choose("spatial")["id"] == "spatial"
|
||||
@@ -1,10 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.runtime_settings import (
|
||||
EncryptedSettingsStore,
|
||||
RuntimeSettingsTestResult,
|
||||
generate_secret,
|
||||
test_model_pool_item as run_model_pool_test,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,27 +46,122 @@ def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -
|
||||
"s3_public_endpoint": "http://localhost:9000",
|
||||
"s3_access_key_id": "access",
|
||||
"s3_secret_access_key": "secret",
|
||||
"ai_provider": "lingke",
|
||||
"ai_base_url": "https://example.com/v1",
|
||||
"ai_api_key": "api-secret",
|
||||
"orchestrator_model": "planner",
|
||||
"vision_model": "vision",
|
||||
"image_model": "gpt-image-2",
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "planner",
|
||||
"name": "总调度",
|
||||
"model_id": "planner-model",
|
||||
"category": "language",
|
||||
"provider": "lingke",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "planner-secret",
|
||||
"capabilities": ["orchestration"],
|
||||
},
|
||||
{
|
||||
"id": "designer",
|
||||
"name": "空间与生图",
|
||||
"model_id": "designer-model",
|
||||
"category": "multimodal",
|
||||
"provider": "lingke",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "designer-secret",
|
||||
"capabilities": ["spatial_understanding", "image_generation"],
|
||||
},
|
||||
],
|
||||
"orchestrator_model_id": "planner",
|
||||
"spatial_routing_mode": "auto",
|
||||
"image_routing_mode": "manual",
|
||||
"image_model_id": "designer",
|
||||
}
|
||||
)
|
||||
assert store.public_response().readiness.ready is False
|
||||
|
||||
for target in ("infrastructure", "storage", "ai_models"):
|
||||
for target in ("infrastructure", "storage", "model:planner", "model:designer"):
|
||||
store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
|
||||
|
||||
assert store.public_response().readiness.ready is True
|
||||
|
||||
store.update({"image_model": "another-image-model"})
|
||||
pool = store.merged_values()["model_pool"]
|
||||
pool[1]["model_id"] = "another-image-model"
|
||||
store.update({"model_pool": pool})
|
||||
assert store.public_response().readiness.ready is False
|
||||
assert store.public_response().tests.get("ai_models") is None
|
||||
assert store.public_response().tests["model:designer"]["ok"] is False
|
||||
assert "重新测试" in store.public_response().tests["model:designer"]["message"]
|
||||
|
||||
|
||||
def test_model_pool_keys_are_encrypted_and_redacted(tmp_path: Path) -> None:
|
||||
store = create_store(tmp_path)
|
||||
store.update(
|
||||
{
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "planner",
|
||||
"name": "总调度",
|
||||
"model_id": "planner-model",
|
||||
"category": "language",
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "nested-model-secret",
|
||||
"capabilities": ["orchestration"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
public_model = store.public_response().values["model_pool"][0]
|
||||
assert "api_key" not in public_model
|
||||
assert public_model["api_key_configured"] is True
|
||||
assert b"nested-model-secret" not in store.data_path.read_bytes()
|
||||
|
||||
|
||||
def test_secret_generators_use_expected_lengths() -> None:
|
||||
assert len(generate_secret("hex24")) == 48
|
||||
assert len(generate_secret("hex32")) == 64
|
||||
assert len(generate_secret("base64_32")) == 44
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_individual_model_error_names_the_failing_model(tmp_path: Path, monkeypatch) -> None:
|
||||
store = create_store(tmp_path)
|
||||
store.update(
|
||||
{
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "image-model",
|
||||
"name": "客厅效果图模型",
|
||||
"model_id": "wrong-image-id",
|
||||
"category": "multimodal",
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "secret",
|
||||
"capabilities": ["image_generation"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"data": [{"id": "available-image-id"}]}
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args, **kwargs) -> FakeResponse:
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||
result = await run_model_pool_test(store.merged_values(), "image-model")
|
||||
|
||||
assert result.ok is False
|
||||
assert "客厅效果图模型" in result.message
|
||||
assert "wrong-image-id" in result.message
|
||||
|
||||
Reference in New Issue
Block a user