refactor: 拆分调度器为独立子模块,增强 Sub2API 多认证方式支持

调度器重构:
- 将 CacheAwareScheduler 拆分为 candidate_builder、candidate_sorter、
  concurrency_checker、restriction_checker、scheduling_config、schemas、utils 等独立模块
- 删除旧的 _candidate_builder.py 和 _candidate_sorter.py
- 新增调度并发拒绝 Prometheus 指标

Sub2API 架构增强:
- 支持账号密码登录和 Refresh Token 两种认证方式
- 实现 JWT 自动刷新和 Token Rotation 持久化
- 前端 ProviderAuthDialog 支持多认证方式切换和 credentials_schema 动态渲染
- 验证接口返回 updated_credentials 以同步轮换后的 token

其他改进:
- 并发管理器增加 RPM guard 和动态预留逻辑
- RequestCandidate 支持 mark_skipped 附加 extra_data
- TaskService 增强健壮性
- 补充相关单元测试和契约测试
This commit is contained in:
fawney19
2026-02-15 16:32:23 +08:00
parent 8a670f5524
commit 1c16b77a92
27 changed files with 2159 additions and 645 deletions

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from src.services.cache.candidate_sorter import CandidateSorter
from src.services.cache.scheduling_config import SchedulingConfig
from src.services.cache.schemas import ProviderCandidate
def _make_candidate(
*,
key_id: str,
global_priority: int,
needs_conversion: bool,
provider_keep_priority_on_conversion: bool,
) -> ProviderCandidate:
provider = SimpleNamespace(
id=f"p_{key_id}",
name=f"prov_{key_id}",
provider_priority=1,
keep_priority_on_conversion=provider_keep_priority_on_conversion,
)
endpoint = SimpleNamespace(id=f"e_{key_id}")
key = SimpleNamespace(
id=key_id,
internal_priority=1,
global_priority_by_format={"openai:chat": global_priority},
)
return ProviderCandidate(
provider=provider,
endpoint=endpoint,
key=key,
needs_conversion=needs_conversion,
provider_api_format="openai:chat",
) # type: ignore[arg-type]
def test_priority_sort_global_key_does_not_demote_when_global_keep_priority_enabled() -> None:
db = MagicMock()
config = SchedulingConfig(
priority_mode=SchedulingConfig.PRIORITY_MODE_GLOBAL_KEY,
scheduling_mode=SchedulingConfig.SCHEDULING_MODE_FIXED_ORDER,
)
sorter = CandidateSorter(config)
exact = _make_candidate(
key_id="k_exact",
global_priority=10,
needs_conversion=False,
provider_keep_priority_on_conversion=False,
)
demoted = _make_candidate(
key_id="k_demote",
global_priority=1,
needs_conversion=True,
provider_keep_priority_on_conversion=False,
)
# 全局 keep_priority_on_conversion=True不做 needs_conversion 降级分组,纯按 global_priority 排序
with patch(
"src.services.cache.candidate_sorter.SystemConfigService.is_keep_priority_on_conversion",
return_value=True,
):
result = sorter._apply_priority_mode_sort([exact, demoted], db, None, "openai:chat")
assert [c.key.id for c in result] == ["k_demote", "k_exact"]
def test_priority_sort_global_key_demotes_convertible_when_global_keep_priority_disabled() -> None:
db = MagicMock()
config = SchedulingConfig(
priority_mode=SchedulingConfig.PRIORITY_MODE_GLOBAL_KEY,
scheduling_mode=SchedulingConfig.SCHEDULING_MODE_FIXED_ORDER,
)
sorter = CandidateSorter(config)
exact = _make_candidate(
key_id="k_exact",
global_priority=10,
needs_conversion=False,
provider_keep_priority_on_conversion=False,
)
demoted = _make_candidate(
key_id="k_demote",
global_priority=1,
needs_conversion=True,
provider_keep_priority_on_conversion=False,
)
# 全局 keep_priority_on_conversion=False需要降级的 convertible 候选整体排后
with patch(
"src.services.cache.candidate_sorter.SystemConfigService.is_keep_priority_on_conversion",
return_value=False,
):
result = sorter._apply_priority_mode_sort([exact, demoted], db, None, "openai:chat")
assert [c.key.id for c in result] == ["k_exact", "k_demote"]
def test_priority_sort_global_key_provider_keep_priority_overrides_demotion_group() -> None:
db = MagicMock()
config = SchedulingConfig(
priority_mode=SchedulingConfig.PRIORITY_MODE_GLOBAL_KEY,
scheduling_mode=SchedulingConfig.SCHEDULING_MODE_FIXED_ORDER,
)
sorter = CandidateSorter(config)
exact = _make_candidate(
key_id="k_exact",
global_priority=10,
needs_conversion=False,
provider_keep_priority_on_conversion=False,
)
convertible_keep = _make_candidate(
key_id="k_keep",
global_priority=1,
needs_conversion=True,
provider_keep_priority_on_conversion=True,
)
convertible_demote = _make_candidate(
key_id="k_demote",
global_priority=0,
needs_conversion=True,
provider_keep_priority_on_conversion=False,
)
with patch(
"src.services.cache.candidate_sorter.SystemConfigService.is_keep_priority_on_conversion",
return_value=False,
):
result = sorter._apply_priority_mode_sort(
[exact, convertible_demote, convertible_keep],
db,
None,
"openai:chat",
)
assert [c.key.id for c in result] == ["k_keep", "k_exact", "k_demote"]
def test_priority_sort_provider_mode_demotes_convertible_when_global_keep_priority_disabled() -> (
None
):
db = MagicMock()
config = SchedulingConfig(
priority_mode=SchedulingConfig.PRIORITY_MODE_PROVIDER,
scheduling_mode=SchedulingConfig.SCHEDULING_MODE_FIXED_ORDER,
)
sorter = CandidateSorter(config)
exact = _make_candidate(
key_id="k_exact",
global_priority=10,
needs_conversion=False,
provider_keep_priority_on_conversion=False,
)
demoted = _make_candidate(
key_id="k_demote",
global_priority=1,
needs_conversion=True,
provider_keep_priority_on_conversion=False,
)
with patch(
"src.services.cache.candidate_sorter.SystemConfigService.is_keep_priority_on_conversion",
return_value=False,
):
result = sorter._apply_priority_mode_sort([demoted, exact], db, None, "openai:chat")
assert [c.key.id for c in result] == ["k_exact", "k_demote"]
def test_priority_sort_provider_mode_does_not_demote_when_global_keep_priority_enabled() -> None:
db = MagicMock()
config = SchedulingConfig(
priority_mode=SchedulingConfig.PRIORITY_MODE_PROVIDER,
scheduling_mode=SchedulingConfig.SCHEDULING_MODE_FIXED_ORDER,
)
sorter = CandidateSorter(config)
exact = _make_candidate(
key_id="k_exact",
global_priority=10,
needs_conversion=False,
provider_keep_priority_on_conversion=False,
)
demoted = _make_candidate(
key_id="k_demote",
global_priority=1,
needs_conversion=True,
provider_keep_priority_on_conversion=False,
)
with patch(
"src.services.cache.candidate_sorter.SystemConfigService.is_keep_priority_on_conversion",
return_value=True,
):
result = sorter._apply_priority_mode_sort([demoted, exact], db, None, "openai:chat")
assert [c.key.id for c in result] == ["k_demote", "k_exact"]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from src.services.request.candidate import RequestCandidateService
def test_mark_candidate_skipped_merges_extra_data_and_sets_fields() -> None:
candidate = SimpleNamespace(
status="available",
skip_reason=None,
finished_at=None,
status_code=None,
concurrent_requests=None,
extra_data={"needs_conversion": True},
)
query = MagicMock()
query.filter.return_value.first.return_value = candidate
db = MagicMock()
db.query.return_value = query
RequestCandidateService.mark_candidate_skipped(
db=db,
candidate_id="c1",
skip_reason="并发限制",
status_code=429,
concurrent_requests=12,
extra_data={"concurrency_denied": True},
)
assert candidate.status == "skipped"
assert candidate.skip_reason == "并发限制"
assert candidate.status_code == 429
assert candidate.concurrent_requests == 12
assert candidate.extra_data == {"needs_conversion": True, "concurrency_denied": True}
assert candidate.finished_at is not None
db.flush.assert_called_once()