feat: route background ai workloads by model

This commit is contained in:
2026-07-31 17:25:16 +08:00
parent 0008903e8d
commit da313f88ed
22 changed files with 714 additions and 63 deletions

View File

@@ -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",
}
]

View 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

View File

@@ -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))