feat(provider): 增加 Claude Code 适配器、高级配置能力与 OAuth 账号类型统一解析

- 新增 Claude Code provider adapter (context, envelope, plugin, constants)
- 扩展 provider admin 路由,支持 Claude Code 高级配置 (CRUD)
- 统一 OAuth 账号类型解析逻辑,前后端对齐
- 重构 BatchAssignModelsDialog / ModelMappingDialog,简化组件逻辑
- handler 基类增强: request_builder 支持 Claude Code 信封格式
- CLI stream/sync mixin 适配 Claude Code 流式与同步模式
- 扩展 candidate builder / failover / scheduler 对 Claude Code 的支持
- 前端增加请求时间线可视化 (HorizontalRequestTimeline)
- 补充 Claude Code envelope / runtime controls / distributed sessions 等测试

Closes #183
Closes #185

Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-02-27 13:54:46 +08:00
parent f2f2a2dbc4
commit 579b5e4623
55 changed files with 3106 additions and 652 deletions

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import uuid
import pytest
from src.core.exceptions import ConcurrencyLimitError
from src.services.provider.adapters.claude_code.context import (
ClaudeCodeRequestContext,
set_claude_code_request_context,
)
from src.services.provider.adapters.claude_code.envelope import (
claude_code_envelope,
enforce_distributed_session_controls,
)
class _StubRedis:
def __init__(self, *, result=None, exc: Exception | None = None) -> None:
self._result = result
self._exc = exc
self.calls: list[tuple] = []
async def eval(self, *args):
self.calls.append(args)
if self._exc is not None:
raise self._exc
return self._result
@pytest.fixture(autouse=True)
def _reset_claude_code_context():
set_claude_code_request_context(None)
yield
set_claude_code_request_context(None)
@pytest.mark.asyncio
async def test_distributed_session_controls_accept_when_redis_allows(
monkeypatch: pytest.MonkeyPatch,
):
stub = _StubRedis(result=[1, 1])
async def _fake_get_redis_client(*, require_redis: bool = False):
_ = require_redis
return stub
monkeypatch.setattr(
"src.services.provider.adapters.claude_code.envelope.get_redis_client",
_fake_get_redis_client,
)
ctx = ClaudeCodeRequestContext(
scope_key=f"key:test-dist-ok-{uuid.uuid4()}",
key_id="key-ok",
max_sessions=1,
session_idle_timeout_minutes=5,
)
request_body = {
"metadata": {"user_id": "user_a_account_b_session_11111111-1111-1111-1111-111111111111"}
}
await enforce_distributed_session_controls(request_body, ctx)
assert len(stub.calls) == 1
@pytest.mark.asyncio
async def test_distributed_session_controls_reject_when_redis_denies(
monkeypatch: pytest.MonkeyPatch,
):
stub = _StubRedis(result=[0, 1])
async def _fake_get_redis_client(*, require_redis: bool = False):
_ = require_redis
return stub
monkeypatch.setattr(
"src.services.provider.adapters.claude_code.envelope.get_redis_client",
_fake_get_redis_client,
)
ctx = ClaudeCodeRequestContext(
scope_key=f"key:test-dist-deny-{uuid.uuid4()}",
key_id="key-deny",
max_sessions=1,
session_idle_timeout_minutes=5,
)
request_body = {
"metadata": {"user_id": "user_a_account_b_session_22222222-2222-2222-2222-222222222222"}
}
with pytest.raises(ConcurrencyLimitError):
await enforce_distributed_session_controls(request_body, ctx)
@pytest.mark.asyncio
async def test_distributed_session_controls_fallback_to_local_when_redis_error(
monkeypatch: pytest.MonkeyPatch,
):
stub = _StubRedis(exc=RuntimeError("redis unavailable"))
async def _fake_get_redis_client(*, require_redis: bool = False):
_ = require_redis
return stub
monkeypatch.setattr(
"src.services.provider.adapters.claude_code.envelope.get_redis_client",
_fake_get_redis_client,
)
ctx = ClaudeCodeRequestContext(
scope_key=f"key:test-dist-fallback-{uuid.uuid4()}",
key_id="key-fallback",
max_sessions=1,
session_idle_timeout_minutes=5,
)
first = {
"metadata": {"user_id": "user_a_account_b_session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
}
second = {
"metadata": {"user_id": "user_a_account_b_session_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}
}
await enforce_distributed_session_controls(first, ctx)
with pytest.raises(ConcurrencyLimitError):
await enforce_distributed_session_controls(second, ctx)
def test_wrap_request_skips_local_limit_when_distributed_store_available(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"src.services.provider.adapters.claude_code.envelope.get_redis_client_sync",
lambda: object(),
)
scope_key = f"key:test-wrap-skip-{uuid.uuid4()}"
set_claude_code_request_context(
ClaudeCodeRequestContext(
is_stream=False,
scope_key=scope_key,
key_id="key-wrap",
max_sessions=1,
session_idle_timeout_minutes=5,
)
)
first = {
"metadata": {"user_id": "user_a_account_b_session_11111111-1111-1111-1111-111111111111"}
}
second = {
"metadata": {"user_id": "user_a_account_b_session_22222222-2222-2222-2222-222222222222"}
}
claude_code_envelope.wrap_request(
first,
model="claude-sonnet-4-5-20250929",
url_model=None,
decrypted_auth_config=None,
)
claude_code_envelope.wrap_request(
second,
model="claude-sonnet-4-5-20250929",
url_model=None,
decrypted_auth_config=None,
)