mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
84
tests/api/handlers/base/test_upstream_stream_bridge.py
Normal file
84
tests/api/handlers/base/test_upstream_stream_bridge.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.upstream_stream_bridge import (
|
||||
aggregate_upstream_stream_to_internal_response,
|
||||
)
|
||||
from src.core.api_format.conversion import register_default_normalizers
|
||||
from src.core.api_format.conversion.internal import TextBlock
|
||||
|
||||
|
||||
async def _iter_stream_lines(lines: list[str]) -> AsyncIterator[bytes]:
|
||||
for line in lines:
|
||||
yield line.encode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_claude_stream_uses_message_start_usage_when_message_delta_absent() -> None:
|
||||
register_default_normalizers()
|
||||
|
||||
lines = [
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_bridge_usage",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [],
|
||||
"usage": {
|
||||
"input_tokens": 120,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "hello"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
"data: "
|
||||
+ json.dumps({"type": "content_block_stop", "index": 0}, ensure_ascii=False)
|
||||
+ "\n",
|
||||
]
|
||||
|
||||
internal = await aggregate_upstream_stream_to_internal_response(
|
||||
_iter_stream_lines(lines),
|
||||
provider_api_format="claude:cli",
|
||||
provider_name="claude_code",
|
||||
model="claude-sonnet-4-5",
|
||||
request_id="req_bridge_usage",
|
||||
)
|
||||
|
||||
assert internal.usage is not None
|
||||
assert internal.usage.input_tokens == 120
|
||||
assert internal.usage.output_tokens == 0
|
||||
assert internal.usage.cache_read_tokens == 11
|
||||
assert len(internal.content) == 1
|
||||
assert isinstance(internal.content[0], TextBlock)
|
||||
assert internal.content[0].text == "hello"
|
||||
@@ -283,6 +283,40 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
|
||||
assert out_events[-1]["type"] == "message_stop"
|
||||
|
||||
|
||||
def test_claude_stream_message_start_preserves_usage() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
state = StreamState(model="claude-3-sonnet")
|
||||
|
||||
chunk = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_usage_start",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-sonnet",
|
||||
"content": [],
|
||||
"usage": {
|
||||
"input_tokens": 9,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
events = n.stream_chunk_to_internal(chunk, state)
|
||||
assert len(events) == 1
|
||||
start_event = events[0]
|
||||
assert isinstance(start_event, MessageStartEvent)
|
||||
assert start_event.usage is not None
|
||||
assert start_event.usage.input_tokens == 9
|
||||
assert start_event.usage.cache_read_tokens == 3
|
||||
|
||||
out = n.stream_event_from_internal(start_event, StreamState(model="claude-3-sonnet"))
|
||||
assert out[0]["type"] == "message_start"
|
||||
assert out[0]["message"]["usage"]["input_tokens"] == 9
|
||||
assert out[0]["message"]["usage"]["cache_read_input_tokens"] == 3
|
||||
|
||||
|
||||
def test_claude_error_conversion() -> None:
|
||||
n = ClaudeNormalizer()
|
||||
err_resp = {
|
||||
|
||||
39
tests/services/test_admin_provider_defaults.py
Normal file
39
tests/services/test_admin_provider_defaults.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.providers.routes import (
|
||||
_merge_claude_code_advanced_config,
|
||||
_should_enable_format_conversion_by_default,
|
||||
)
|
||||
from src.core.exceptions import InvalidRequestException
|
||||
|
||||
|
||||
def test_claude_code_defaults_format_conversion_enabled() -> None:
|
||||
assert _should_enable_format_conversion_by_default("claude_code") is True
|
||||
|
||||
|
||||
def test_custom_defaults_format_conversion_disabled() -> None:
|
||||
assert _should_enable_format_conversion_by_default("custom") is False
|
||||
|
||||
|
||||
def test_merge_claude_code_advanced_clears_stale_config_for_non_claude_provider() -> None:
|
||||
merged, changed = _merge_claude_code_advanced_config(
|
||||
provider_type="custom",
|
||||
provider_config={"foo": "bar", "claude_code_advanced": {"max_sessions": 9}},
|
||||
claude_code_advanced=None,
|
||||
claude_advanced_in_payload=False,
|
||||
)
|
||||
|
||||
assert merged == {"foo": "bar"}
|
||||
assert changed is True
|
||||
|
||||
|
||||
def test_merge_claude_code_advanced_rejects_non_claude_payload() -> None:
|
||||
with pytest.raises(InvalidRequestException):
|
||||
_merge_claude_code_advanced_config(
|
||||
provider_type="custom",
|
||||
provider_config={},
|
||||
claude_code_advanced={"max_sessions": 9},
|
||||
claude_advanced_in_payload=True,
|
||||
)
|
||||
@@ -30,6 +30,7 @@ class _FakeScheduler:
|
||||
max_candidates: int | None = None,
|
||||
is_stream: bool = False,
|
||||
capability_requirements: dict[str, bool] | None = None,
|
||||
request_body: dict | None = None,
|
||||
) -> tuple[list[Any], str, int]:
|
||||
_ = (
|
||||
db,
|
||||
|
||||
166
tests/services/test_claude_code_distributed_sessions.py
Normal file
166
tests/services/test_claude_code_distributed_sessions.py
Normal 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,
|
||||
)
|
||||
178
tests/services/test_claude_code_envelope.py
Normal file
178
tests/services/test_claude_code_envelope.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||
from src.config.settings import config
|
||||
from src.services.provider.adapters.claude_code.constants import (
|
||||
BETA_CLAUDE_CODE,
|
||||
BETA_CONTEXT_1M,
|
||||
BETA_INTERLEAVED_THINKING,
|
||||
BETA_OAUTH,
|
||||
CLAUDE_CODE_REQUIRED_BETA_TOKENS,
|
||||
DEFAULT_ACCEPT,
|
||||
DEFAULT_ANTHROPIC_VERSION,
|
||||
STREAM_HELPER_METHOD,
|
||||
)
|
||||
from src.services.provider.adapters.claude_code.context import set_claude_code_request_context
|
||||
from src.services.provider.adapters.claude_code.envelope import (
|
||||
claude_code_envelope,
|
||||
merge_anthropic_beta_tokens,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_claude_code_context():
|
||||
set_claude_code_request_context(None)
|
||||
yield
|
||||
set_claude_code_request_context(None)
|
||||
|
||||
|
||||
def test_merge_anthropic_beta_tokens_adds_required_and_deduplicates() -> None:
|
||||
merged = merge_anthropic_beta_tokens(
|
||||
"context-1m-2025-08-07,oauth-2025-04-20,custom-beta,claude-code-20250219"
|
||||
)
|
||||
|
||||
assert merged.split(",") == [
|
||||
BETA_CLAUDE_CODE,
|
||||
BETA_OAUTH,
|
||||
BETA_INTERLEAVED_THINKING,
|
||||
"context-1m-2025-08-07",
|
||||
"custom-beta",
|
||||
]
|
||||
|
||||
|
||||
def test_claude_code_envelope_extra_headers_include_required_defaults(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "internal_user_agent_claude_cli", "claude-code/test")
|
||||
|
||||
headers = claude_code_envelope.extra_headers() or {}
|
||||
|
||||
assert headers.get("anthropic-version") == DEFAULT_ANTHROPIC_VERSION
|
||||
assert headers.get("anthropic-beta") == ",".join(CLAUDE_CODE_REQUIRED_BETA_TOKENS)
|
||||
assert headers.get("Accept") == DEFAULT_ACCEPT
|
||||
assert headers.get("X-App") == "cli"
|
||||
assert headers.get("X-Stainless-Lang") == "js"
|
||||
assert headers.get("Anthropic-Dangerous-Direct-Browser-Access") == "true"
|
||||
assert headers.get("User-Agent") == "claude-code/test"
|
||||
assert "x-stainless-helper-method" not in headers
|
||||
|
||||
|
||||
def test_claude_code_envelope_adds_stream_helper_header_for_stream_request() -> None:
|
||||
_, _ = claude_code_envelope.wrap_request(
|
||||
{"stream": True},
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
headers = claude_code_envelope.extra_headers() or {}
|
||||
assert headers.get("Accept") == DEFAULT_ACCEPT
|
||||
assert headers.get("x-stainless-helper-method") == STREAM_HELPER_METHOD
|
||||
|
||||
|
||||
def test_passthrough_request_builder_drops_context_1m_for_claude_code_oauth() -> None:
|
||||
builder = PassthroughRequestBuilder()
|
||||
endpoint = SimpleNamespace(header_rules=None)
|
||||
key = SimpleNamespace(api_key="unused")
|
||||
|
||||
headers = builder.build_headers(
|
||||
original_headers={"anthropic-beta": BETA_CONTEXT_1M},
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
extra_headers={"anthropic-beta": ",".join(CLAUDE_CODE_REQUIRED_BETA_TOKENS)},
|
||||
pre_computed_auth=("Authorization", "Bearer test-token"),
|
||||
envelope=claude_code_envelope,
|
||||
)
|
||||
|
||||
assert headers.get("anthropic-beta") == ",".join(CLAUDE_CODE_REQUIRED_BETA_TOKENS)
|
||||
|
||||
|
||||
def test_passthrough_request_builder_keeps_context_1m_for_non_claude_code_provider() -> None:
|
||||
builder = PassthroughRequestBuilder()
|
||||
endpoint = SimpleNamespace(header_rules=None)
|
||||
key = SimpleNamespace(api_key="unused")
|
||||
|
||||
headers = builder.build_headers(
|
||||
original_headers={"anthropic-beta": BETA_CONTEXT_1M},
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
extra_headers={"anthropic-beta": ",".join(CLAUDE_CODE_REQUIRED_BETA_TOKENS)},
|
||||
pre_computed_auth=("Authorization", "Bearer test-token"),
|
||||
envelope=None,
|
||||
)
|
||||
|
||||
assert BETA_CONTEXT_1M in str(headers.get("anthropic-beta") or "")
|
||||
|
||||
|
||||
def test_claude_code_envelope_filters_invalid_thinking_blocks_when_enabled() -> None:
|
||||
body = {
|
||||
"thinking": {"type": "enabled"},
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "keep", "signature": "sig_valid"},
|
||||
{"type": "thinking", "thinking": "drop-empty-signature", "signature": ""},
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "drop-dummy-signature",
|
||||
"signature": "skip_thought_signature_validator",
|
||||
},
|
||||
{"type": "redacted_thinking", "data": "keep", "signature": "sig_redacted"},
|
||||
{"type": "redacted_thinking", "data": "drop-no-signature"},
|
||||
{"thinking": "drop-no-type"},
|
||||
{"type": "text", "text": "ok"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
wrapped, _ = claude_code_envelope.wrap_request(
|
||||
body,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
|
||||
content = wrapped["messages"][0]["content"]
|
||||
assert content == [
|
||||
{"type": "thinking", "thinking": "keep", "signature": "sig_valid"},
|
||||
{"type": "redacted_thinking", "data": "keep", "signature": "sig_redacted"},
|
||||
{"type": "text", "text": "ok"},
|
||||
]
|
||||
|
||||
|
||||
def test_claude_code_envelope_drops_all_thinking_blocks_when_disabled() -> None:
|
||||
body = {
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "remove", "signature": "sig_valid"},
|
||||
{"type": "redacted_thinking", "data": "remove", "signature": "sig_redacted"},
|
||||
{"type": "text", "text": "keep"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "remove-user", "signature": "sig_user"},
|
||||
{"type": "text", "text": "keep-user"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
wrapped, _ = claude_code_envelope.wrap_request(
|
||||
body,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
|
||||
assert wrapped["messages"][0]["content"] == [{"type": "text", "text": "keep"}]
|
||||
assert wrapped["messages"][1]["content"] == [{"type": "text", "text": "keep-user"}]
|
||||
192
tests/services/test_claude_code_runtime_controls.py
Normal file
192
tests/services/test_claude_code_runtime_controls.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.exceptions import ConcurrencyLimitError
|
||||
from src.services.provider.adapters.claude_code.constants import TLS_PROFILE_CLAUDE_CODE
|
||||
from src.services.provider.adapters.claude_code.context import (
|
||||
ClaudeCodeRequestContext,
|
||||
build_and_set_claude_code_request_context,
|
||||
build_claude_code_request_context,
|
||||
get_claude_code_request_context,
|
||||
resolve_claude_code_tls_profile,
|
||||
set_claude_code_request_context,
|
||||
)
|
||||
from src.services.provider.adapters.claude_code.envelope import claude_code_envelope
|
||||
from src.utils.ssl_utils import get_ssl_context, get_ssl_context_for_profile
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_claude_code_context():
|
||||
set_claude_code_request_context(None)
|
||||
yield
|
||||
set_claude_code_request_context(None)
|
||||
|
||||
|
||||
def _session_tail(user_id: str) -> str:
|
||||
return user_id.split("_session_")[-1]
|
||||
|
||||
|
||||
def test_build_context_reads_claude_code_advanced_from_provider_config() -> None:
|
||||
ctx = build_claude_code_request_context(
|
||||
provider_config={
|
||||
"claude_code_advanced": {
|
||||
"max_sessions": 3,
|
||||
"session_idle_timeout_minutes": 7,
|
||||
"enable_tls_fingerprint": True,
|
||||
"session_id_masking_enabled": True,
|
||||
}
|
||||
},
|
||||
key_id="key-123",
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
assert ctx.scope_key == "key:key-123"
|
||||
assert ctx.key_id == "key-123"
|
||||
assert ctx.max_sessions == 3
|
||||
assert ctx.session_idle_timeout_minutes == 7
|
||||
assert ctx.enable_tls_fingerprint is True
|
||||
assert ctx.session_id_masking_enabled is True
|
||||
|
||||
|
||||
def test_build_and_set_context_returns_tls_profile_when_enabled() -> None:
|
||||
ctx, tls_profile = build_and_set_claude_code_request_context(
|
||||
provider_config={"claude_code_advanced": {"enable_tls_fingerprint": True}},
|
||||
key_id="key-tls",
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert ctx.key_id == "key-tls"
|
||||
assert get_claude_code_request_context() == ctx
|
||||
assert tls_profile == TLS_PROFILE_CLAUDE_CODE
|
||||
assert resolve_claude_code_tls_profile(ctx) == TLS_PROFILE_CLAUDE_CODE
|
||||
|
||||
|
||||
def test_get_ssl_context_for_claude_code_profile_is_cached() -> None:
|
||||
first = get_ssl_context_for_profile(TLS_PROFILE_CLAUDE_CODE)
|
||||
second = get_ssl_context_for_profile(TLS_PROFILE_CLAUDE_CODE)
|
||||
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_get_ssl_context_for_unknown_profile_falls_back_to_default() -> None:
|
||||
assert get_ssl_context_for_profile("unknown_profile") is get_ssl_context()
|
||||
|
||||
|
||||
def test_wrap_request_masks_session_id_when_enabled() -> None:
|
||||
scope_key = f"key:test-mask-{uuid.uuid4()}"
|
||||
set_claude_code_request_context(
|
||||
ClaudeCodeRequestContext(
|
||||
is_stream=False,
|
||||
scope_key=scope_key,
|
||||
key_id="key-mask",
|
||||
session_id_masking_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
body1 = {
|
||||
"metadata": {
|
||||
"user_id": "user_client_account_main_session_11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
}
|
||||
body2 = {
|
||||
"metadata": {
|
||||
"user_id": "user_client_account_main_session_22222222-2222-2222-2222-222222222222"
|
||||
}
|
||||
}
|
||||
|
||||
wrapped1, _ = claude_code_envelope.wrap_request(
|
||||
body1,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
wrapped2, _ = claude_code_envelope.wrap_request(
|
||||
body2,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
|
||||
tail1 = _session_tail(wrapped1["metadata"]["user_id"])
|
||||
tail2 = _session_tail(wrapped2["metadata"]["user_id"])
|
||||
|
||||
assert tail1 != "11111111-1111-1111-1111-111111111111"
|
||||
assert tail1 == tail2
|
||||
|
||||
|
||||
def test_wrap_request_enforces_max_sessions() -> None:
|
||||
scope_key = f"key:test-limit-{uuid.uuid4()}"
|
||||
set_claude_code_request_context(
|
||||
ClaudeCodeRequestContext(
|
||||
is_stream=False,
|
||||
scope_key=scope_key,
|
||||
key_id="key-limit",
|
||||
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"}
|
||||
}
|
||||
|
||||
claude_code_envelope.wrap_request(
|
||||
first,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ConcurrencyLimitError):
|
||||
claude_code_envelope.wrap_request(
|
||||
second,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
|
||||
|
||||
def test_wrap_request_releases_expired_sessions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
scope_key = f"key:test-expire-{uuid.uuid4()}"
|
||||
set_claude_code_request_context(
|
||||
ClaudeCodeRequestContext(
|
||||
is_stream=False,
|
||||
scope_key=scope_key,
|
||||
key_id="key-expire",
|
||||
max_sessions=1,
|
||||
session_idle_timeout_minutes=1,
|
||||
)
|
||||
)
|
||||
|
||||
ticks = iter([0.0, 61.0])
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.claude_code.envelope.time.monotonic",
|
||||
lambda: next(ticks),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
# 61s > 1min idle timeout,旧会话应过期,第二个会话可进入。
|
||||
claude_code_envelope.wrap_request(
|
||||
second,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
url_model=None,
|
||||
decrypted_auth_config=None,
|
||||
)
|
||||
30
tests/services/test_provider_summary_claude_code_advanced.py
Normal file
30
tests/services/test_provider_summary_claude_code_advanced.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.api.admin.providers.summary import _extract_claude_code_advanced_from_config
|
||||
from src.models.admin_requests import ClaudeCodeAdvancedConfig
|
||||
|
||||
|
||||
def test_extract_claude_code_advanced_valid_dict() -> None:
|
||||
config = {"claude_code_advanced": {"max_sessions": 12}}
|
||||
|
||||
parsed = _extract_claude_code_advanced_from_config(config, provider_id="provider-1")
|
||||
|
||||
assert isinstance(parsed, ClaudeCodeAdvancedConfig)
|
||||
assert parsed.max_sessions == 12
|
||||
assert parsed.session_idle_timeout_minutes == 5
|
||||
|
||||
|
||||
def test_extract_claude_code_advanced_invalid_type_returns_none() -> None:
|
||||
config = {"claude_code_advanced": "not-a-dict"}
|
||||
|
||||
parsed = _extract_claude_code_advanced_from_config(config, provider_id="provider-1")
|
||||
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_extract_claude_code_advanced_invalid_payload_returns_none() -> None:
|
||||
config = {"claude_code_advanced": {"max_sessions": 0}}
|
||||
|
||||
parsed = _extract_claude_code_advanced_from_config(config, provider_id="provider-1")
|
||||
|
||||
assert parsed is None
|
||||
84
tests/services/test_provider_transport_claude_code.py
Normal file
84
tests/services/test_provider_transport_claude_code.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
|
||||
from src.services.provider.adapters.claude_code.plugin import register_all
|
||||
from src.services.provider.transport import build_provider_url
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DummyEndpoint:
|
||||
base_url: str
|
||||
api_format: str
|
||||
custom_path: str | None = None
|
||||
provider: object | None = None
|
||||
|
||||
|
||||
def test_claude_code_claude_cli_uses_messages_path() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://api.anthropic.com",
|
||||
api_format="claude:cli",
|
||||
provider=SimpleNamespace(provider_type="claude_code"),
|
||||
)
|
||||
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert url == "https://api.anthropic.com/v1/messages"
|
||||
|
||||
|
||||
def test_claude_code_claude_cli_does_not_duplicate_messages_suffix() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://api.anthropic.com/v1/messages",
|
||||
api_format="claude:cli",
|
||||
provider=SimpleNamespace(provider_type="claude_code"),
|
||||
)
|
||||
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
assert url == "https://api.anthropic.com/v1/messages"
|
||||
|
||||
|
||||
def test_claude_code_claude_cli_appends_query_params() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://api.anthropic.com/v1",
|
||||
api_format="claude:cli",
|
||||
provider=SimpleNamespace(provider_type="claude_code"),
|
||||
)
|
||||
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
query_params={"beta": "true"},
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
assert url == "https://api.anthropic.com/v1/messages?beta=true"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_code_registers_preset_model_fetcher() -> None:
|
||||
register_all()
|
||||
|
||||
fetcher = UpstreamModelsFetcherRegistry.get("claude_code")
|
||||
assert fetcher is not None
|
||||
|
||||
models, errors, has_success, upstream_metadata = await fetcher(SimpleNamespace(), 1.0)
|
||||
model_ids = {m["id"] for m in models}
|
||||
|
||||
assert "claude-sonnet-4-5-20250929" in model_ids
|
||||
assert "claude-opus-4-6" in model_ids
|
||||
assert errors == []
|
||||
assert has_success is True
|
||||
assert upstream_metadata is None
|
||||
Reference in New Issue
Block a user