feat(pool): 增加 OAuth 账号池管理功能

- 新增 pool manager / strategy / health_policy / cost_tracker / redis_ops 等核心模块
- 新增 pool admin API 路由与 schemas
- 新增 OAuth 账号类型解析 (oauth_plan)
- 前端增加 PoolManagement 页面、PoolConfigDialog、PoolImportDialog、PoolStatusCard 组件
- 补充 pool config / cost tracker / health policy / manager / strategy / trace 等测试
This commit is contained in:
fawney19
2026-02-27 13:58:58 +08:00
parent 579b5e4623
commit 76a6d0ce8e
35 changed files with 5761 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
"""Tests for pool manager trace data collection and attachment."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from src.services.provider.pool.config import PoolConfig
from src.services.provider.pool.manager import PoolManager
from src.services.provider.pool.trace import PoolSchedulingTrace
def _make_candidate(key_id: str, *, is_skipped: bool = False) -> SimpleNamespace:
return SimpleNamespace(
key=SimpleNamespace(id=key_id),
is_skipped=is_skipped,
skip_reason=None,
)
# ---------------------------------------------------------------------------
# Trace attachment to candidates
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_trace_attached_to_first_candidate() -> None:
pool = PoolManager("prov-1", PoolConfig())
c1 = _make_candidate("key-1")
c2 = _make_candidate("key-2")
with (
patch(
"src.services.provider.pool.redis_ops.get_sticky_binding",
new_callable=AsyncMock,
return_value=None,
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cooldowns",
new_callable=AsyncMock,
return_value={"key-1": (None, None), "key-2": (None, None)},
),
patch(
"src.services.provider.pool.redis_ops.get_lru_scores",
new_callable=AsyncMock,
return_value={"key-1": 100.0, "key-2": 50.0},
),
):
result = await pool.reorder_candidates("sess-1", [c1, c2])
# First candidate should have _pool_scheduling_trace
trace = getattr(result[0], "_pool_scheduling_trace", None)
assert trace is not None
assert isinstance(trace, PoolSchedulingTrace)
assert trace.total_keys == 2
assert trace.provider_id == "prov-1"
@pytest.mark.asyncio
async def test_pool_extra_data_on_selected_candidate() -> None:
pool = PoolManager("prov-1", PoolConfig())
c1 = _make_candidate("key-1")
with (
patch(
"src.services.provider.pool.redis_ops.get_sticky_binding",
new_callable=AsyncMock,
return_value=None,
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cooldowns",
new_callable=AsyncMock,
return_value={"key-1": (None, None)},
),
patch(
"src.services.provider.pool.redis_ops.get_lru_scores",
new_callable=AsyncMock,
return_value={},
),
):
result = await pool.reorder_candidates(None, [c1])
extra = getattr(result[0], "_pool_extra_data", None)
assert extra is not None
assert "pool_selection" in extra
@pytest.mark.asyncio
async def test_pool_extra_data_on_skipped_candidate() -> None:
pool = PoolManager("prov-1", PoolConfig())
c1 = _make_candidate("key-1")
c2 = _make_candidate("key-2")
with (
patch(
"src.services.provider.pool.redis_ops.get_sticky_binding",
new_callable=AsyncMock,
return_value=None,
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cooldowns",
new_callable=AsyncMock,
return_value={
"key-1": ("rate_limited_429", 120),
"key-2": (None, None),
},
),
patch(
"src.services.provider.pool.redis_ops.get_lru_scores",
new_callable=AsyncMock,
return_value={},
),
):
result = await pool.reorder_candidates(None, [c1, c2])
# key-1 is skipped with pool_skip extra data
skipped = [r for r in result if r.is_skipped]
assert len(skipped) == 1
extra = getattr(skipped[0], "_pool_extra_data", None)
assert extra is not None
assert extra["pool_skip"]["type"] == "cooldown"
assert extra["pool_skip"]["cooldown_reason"] == "rate_limited_429"
assert extra["pool_skip"]["cooldown_ttl"] == 120
@pytest.mark.asyncio
async def test_trace_build_summary_matches() -> None:
pool = PoolManager("prov-1", PoolConfig(cost_limit_per_key_tokens=1000))
c1 = _make_candidate("key-1")
c2 = _make_candidate("key-2")
c3 = _make_candidate("key-3")
with (
patch(
"src.services.provider.pool.redis_ops.get_sticky_binding",
new_callable=AsyncMock,
return_value=None,
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cooldowns",
new_callable=AsyncMock,
return_value={
"key-1": ("overloaded_529", 60),
"key-2": (None, None),
"key-3": (None, None),
},
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cost_totals",
new_callable=AsyncMock,
return_value={"key-2": 1500, "key-3": 200},
),
):
result = await pool.reorder_candidates(None, [c1, c2, c3])
trace = getattr(result[0], "_pool_scheduling_trace", None)
assert trace is not None
summary = trace.build_summary(success_key_id="key-3")
assert summary["total_keys"] == 3
assert summary["skipped_cooldown"] == 1 # key-1
assert summary["skipped_cost"] == 1 # key-2
assert summary["attempted"] == 1 # key-3
assert summary["success_key_id"] == "key-3"[:8]
@pytest.mark.asyncio
async def test_sticky_trace_info() -> None:
pool = PoolManager("prov-1", PoolConfig(sticky_session_ttl_seconds=3600))
c1 = _make_candidate("key-1")
c2 = _make_candidate("key-2")
with (
patch(
"src.services.provider.pool.redis_ops.get_sticky_binding",
new_callable=AsyncMock,
return_value="key-2",
),
patch(
"src.services.provider.pool.redis_ops.batch_get_cooldowns",
new_callable=AsyncMock,
return_value={"key-1": (None, None), "key-2": (None, None)},
),
patch(
"src.services.provider.pool.redis_ops.get_lru_scores",
new_callable=AsyncMock,
return_value={},
),
):
result = await pool.reorder_candidates("sess-1", [c1, c2])
trace = getattr(result[0], "_pool_scheduling_trace", None)
assert trace is not None
assert trace.sticky_session_used is True
# key-2 (sticky hit) should be first
assert result[0].key.id == "key-2"
extra = getattr(result[0], "_pool_extra_data", None)
assert extra is not None
assert extra["pool_selection"]["sticky_hit"] is True