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,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"

View File

@@ -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 = {

View 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,
)

View File

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

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

View 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"}]

View 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,
)

View 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

View 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