feat: route background ai workloads by model
This commit is contained in:
@@ -9,6 +9,7 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.models import Base
|
||||
from app.models.ai_config import ModelConfig
|
||||
from app.models.logs import AiRequestLog
|
||||
from app.services.admin_service import AdminDashboardService
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
|
||||
|
||||
@@ -58,3 +59,52 @@ def test_ai_request_log_estimates_cost_from_model_price():
|
||||
assert log.question_type == "knowledge_grounded"
|
||||
assert "命中知识库" in (log.route_reason or "")
|
||||
|
||||
|
||||
def test_dashboard_breaks_cost_down_by_actual_scene_and_model():
|
||||
with _db() as db:
|
||||
model = ModelConfig(
|
||||
id=1,
|
||||
provider="test",
|
||||
api_type="openai_compatible",
|
||||
model_name="report-model",
|
||||
api_url="https://example.com",
|
||||
api_key="secret",
|
||||
input_price_per_1k=Decimal("0.002"),
|
||||
output_price_per_1k=Decimal("0.006"),
|
||||
currency="CNY",
|
||||
timeout_second=30,
|
||||
)
|
||||
db.add(model)
|
||||
db.commit()
|
||||
AiRequestLogService.write_success(
|
||||
db,
|
||||
session_id=None,
|
||||
message_id=None,
|
||||
user_id=3,
|
||||
model_id=1,
|
||||
model_name="report-model",
|
||||
prompt="weekly report",
|
||||
knowledge_ids="",
|
||||
retrieve_count=0,
|
||||
input_token=1000,
|
||||
output_token=500,
|
||||
cost_ms=120,
|
||||
question_type="background_report",
|
||||
route_reason="场景分流:周期报告",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
stats = AdminDashboardService.stats(db)
|
||||
|
||||
assert stats["costBreakdown"] == [
|
||||
{
|
||||
"scene": "background_report",
|
||||
"modelName": "report-model",
|
||||
"requestCount": 1,
|
||||
"inputToken": 1000,
|
||||
"outputToken": 500,
|
||||
"totalToken": 1500,
|
||||
"estimatedCost": 0.005,
|
||||
"currency": "CNY",
|
||||
}
|
||||
]
|
||||
|
||||
154
ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py
Normal file
154
ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.api.admin_settings import enable_model, set_default_model
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import ModelConfig, SystemConfig
|
||||
from app.models.logs import AiRequestLog
|
||||
from app.schemas.admin import DefaultModelRequest, EnableModelRequest
|
||||
from app.services.model_routing_service import ModelRoutingService
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
|
||||
|
||||
def _db() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def _model(
|
||||
model_id: int,
|
||||
name: str,
|
||||
*,
|
||||
is_default: int,
|
||||
allow_report: int,
|
||||
allow_summary: int,
|
||||
) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
id=model_id,
|
||||
provider="test",
|
||||
api_type="openai_compatible",
|
||||
model_name=name,
|
||||
api_url="https://example.com",
|
||||
api_key="secret",
|
||||
enabled=1,
|
||||
is_default=is_default,
|
||||
allow_report=allow_report,
|
||||
allow_summary=allow_summary,
|
||||
allow_fixed_info=1,
|
||||
allow_deep_chat=1,
|
||||
input_price_per_1k=Decimal("0.002"),
|
||||
output_price_per_1k=Decimal("0.006"),
|
||||
currency="CNY",
|
||||
timeout_second=30,
|
||||
)
|
||||
|
||||
|
||||
def test_background_scenario_routes_without_changing_live_default_model():
|
||||
with _db() as db:
|
||||
main = _model(1, "main-model", is_default=1, allow_report=0, allow_summary=1)
|
||||
report = _model(2, "report-model", is_default=0, allow_report=1, allow_summary=0)
|
||||
db.add_all([main, report])
|
||||
db.commit()
|
||||
|
||||
report_route = ModelRoutingService.resolve(db, "report")
|
||||
summary_route = ModelRoutingService.resolve(db, "summary")
|
||||
|
||||
assert report_route.model is report
|
||||
assert report_route.fallback_used is False
|
||||
assert summary_route.model is main
|
||||
assert ModelClientService._get_enabled_model(db) is main
|
||||
|
||||
|
||||
def test_background_scenario_falls_back_to_default_model():
|
||||
with _db() as db:
|
||||
main = _model(1, "main-model", is_default=1, allow_report=0, allow_summary=0)
|
||||
db.add(main)
|
||||
db.commit()
|
||||
|
||||
route = ModelRoutingService.resolve(db, "report")
|
||||
|
||||
assert route.model is main
|
||||
assert route.fallback_used is True
|
||||
assert "回退默认主模型" in route.reason
|
||||
|
||||
|
||||
def test_tracked_background_generation_records_actual_route_tokens_and_cost():
|
||||
with _db() as db:
|
||||
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
|
||||
report = _model(2, "report-model", is_default=0, allow_report=1, allow_summary=0)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
completion = TrackedGenerationService.generate(
|
||||
db,
|
||||
prompt="生成本周报告",
|
||||
scenario="report",
|
||||
user_id=7,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
log = db.query(AiRequestLog).one()
|
||||
assert completion.model_name == "report-model"
|
||||
assert log.model_id == report.id
|
||||
assert log.user_id == 7
|
||||
assert log.question_type == "background_report"
|
||||
assert log.total_token == completion.input_token + completion.output_token
|
||||
assert log.estimated_cost is not None
|
||||
assert "周期报告" in (log.route_reason or "")
|
||||
|
||||
|
||||
def test_model_pool_keeps_one_default_and_rejects_disabling_last_default():
|
||||
with _db() as db:
|
||||
admin = Admin(id=1, username="admin", password="hash", name="管理员", status=1)
|
||||
main = _model(1, "main-model", is_default=1, allow_report=1, allow_summary=1)
|
||||
alternate = _model(2, "alternate-model", is_default=0, allow_report=1, allow_summary=1)
|
||||
alternate.enabled = 0
|
||||
db.add_all([admin, main, alternate])
|
||||
db.commit()
|
||||
|
||||
enable_model(
|
||||
EnableModelRequest(modelId=alternate.id, enabled=1),
|
||||
db=db,
|
||||
current_admin=admin,
|
||||
)
|
||||
db.refresh(main)
|
||||
db.refresh(alternate)
|
||||
assert main.is_default == 1
|
||||
assert alternate.enabled == 1
|
||||
|
||||
set_default_model(
|
||||
DefaultModelRequest(modelId=alternate.id),
|
||||
db=db,
|
||||
current_admin=admin,
|
||||
)
|
||||
db.refresh(main)
|
||||
db.refresh(alternate)
|
||||
assert main.is_default == 0
|
||||
assert alternate.is_default == 1
|
||||
|
||||
enable_model(
|
||||
EnableModelRequest(modelId=main.id, enabled=0),
|
||||
db=db,
|
||||
current_admin=admin,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
enable_model(
|
||||
EnableModelRequest(modelId=alternate.id, enabled=0),
|
||||
db=db,
|
||||
current_admin=admin,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -12,9 +12,9 @@ from app.models.chat import ChatSession, TopicSession
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement
|
||||
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
|
||||
from app.models.user import User
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
|
||||
from app.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
|
||||
|
||||
def _db() -> Session:
|
||||
@@ -150,9 +150,9 @@ def test_failed_async_report_is_retried(monkeypatch):
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
ModelClientService,
|
||||
"generate_text_or_raise",
|
||||
staticmethod(lambda _db, _prompt: (_ for _ in ()).throw(RuntimeError("模型暂时不可用"))),
|
||||
TrackedGenerationService,
|
||||
"generate",
|
||||
staticmethod(lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("模型暂时不可用"))),
|
||||
)
|
||||
|
||||
report_id = PeriodicReportWorker.claim_next(db, worker_id="retry-worker", now=_now() + timedelta(seconds=1))
|
||||
|
||||
Reference in New Issue
Block a user