Files
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

313 lines
9.9 KiB
Python

"""Tests for Cloudflare Workers AI OpenAI-compatible chat provider."""
from collections.abc import AsyncIterator
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT
from free_claude_code.core.anthropic.models import Message, MessagesRequest
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
from free_claude_code.providers.base import ProviderConfig
from free_claude_code.providers.cloudflare import (
CloudflareProvider,
cloudflare_ai_base_url,
)
from tests.providers.support import passthrough_rate_limiter
_ACCOUNT_ID = "account-123"
_BASE_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/v1"
_MODEL_SEARCH_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/models/search"
@pytest.fixture
def cloudflare_config() -> ProviderConfig:
return ProviderConfig(
api_key="test-cloudflare-token",
base_url=CLOUDFLARE_AI_REST_ROOT,
rate_limit=10,
rate_window=60,
enable_thinking=True,
)
@pytest.fixture
def cloudflare_provider(cloudflare_config: ProviderConfig) -> CloudflareProvider:
return CloudflareProvider(
cloudflare_config,
account_id=_ACCOUNT_ID,
rate_limiter=passthrough_rate_limiter(),
)
def _request(model: str = "@cf/moonshotai/kimi-k2.6") -> MessagesRequest:
return MessagesRequest(
model=model,
max_tokens=100,
messages=[Message(role="user", content="hi")],
)
def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace:
return SimpleNamespace(
choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)],
usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8),
)
async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]:
for chunk in chunks:
yield chunk
def test_cloudflare_ai_base_url_uses_account_scoped_openai_chat_root() -> None:
assert cloudflare_ai_base_url(CLOUDFLARE_AI_REST_ROOT, "account/with slash") == (
f"{CLOUDFLARE_AI_REST_ROOT}/accounts/account%2Fwith%20slash/ai/v1"
)
def test_missing_account_id_raises_authentication_error(
cloudflare_config: ProviderConfig,
) -> None:
with pytest.raises(ApplicationUnavailableError, match="CLOUDFLARE_ACCOUNT_ID"):
CloudflareProvider(
cloudflare_config, account_id=" ", rate_limiter=passthrough_rate_limiter()
)
def test_init_composes_account_scoped_openai_chat_base_url(
cloudflare_config: ProviderConfig,
) -> None:
with (
patch(
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
) as mock_openai,
patch("httpx.AsyncClient") as mock_httpx_client,
):
provider = CloudflareProvider(
cloudflare_config,
account_id=_ACCOUNT_ID,
rate_limiter=passthrough_rate_limiter(),
)
assert provider._api_key == "test-cloudflare-token"
assert provider._base_url == _BASE_URL
assert provider._model_search_url == _MODEL_SEARCH_URL
assert provider._provider_name == "CLOUDFLARE"
assert mock_openai.call_args.kwargs["base_url"] == _BASE_URL
assert mock_openai.call_args.kwargs["api_key"] == "test-cloudflare-token"
assert mock_httpx_client.called
def test_model_list_headers_use_bearer_auth(
cloudflare_provider: CloudflareProvider,
) -> None:
assert cloudflare_provider._model_list_headers() == {
"Authorization": "Bearer test-cloudflare-token"
}
def test_build_request_body_preserves_literal_cf_model_id_and_controls_thinking(
cloudflare_provider: CloudflareProvider,
) -> None:
request = MessagesRequest.model_validate(
{
"model": "@cf/moonshotai/kimi-k2.6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100,
"thinking": {"type": "enabled", "budget_tokens": 2048},
}
)
body = cloudflare_provider._build_request_body(request, thinking_enabled=True)
assert body["model"] == "@cf/moonshotai/kimi-k2.6"
assert body["max_completion_tokens"] == 100
assert "max_tokens" not in body
assert body["extra_body"]["chat_template_kwargs"]["thinking"] is True
def test_build_request_body_disabled_thinking_sets_cloudflare_template_flag(
cloudflare_provider: CloudflareProvider,
) -> None:
request = MessagesRequest.model_validate(
{
"model": "@cf/moonshotai/kimi-k2.6",
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"},
}
)
body = cloudflare_provider._build_request_body(request, thinking_enabled=True)
assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False
def test_build_request_body_preserves_user_extra_body_without_overriding_thinking(
cloudflare_provider: CloudflareProvider,
) -> None:
request = MessagesRequest.model_validate(
{
"model": "@cf/moonshotai/kimi-k2.6",
"messages": [{"role": "user", "content": "Hello"}],
"extra_body": {"chat_template_kwargs": {"thinking": False}},
}
)
body = cloudflare_provider._build_request_body(request, thinking_enabled=True)
assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False
@pytest.mark.asyncio
async def test_lists_models_from_cloudflare_model_search_endpoint(
cloudflare_provider: CloudflareProvider,
) -> None:
response = httpx.Response(
200,
json={
"object": "list",
"data": [
{"id": "@cf/moonshotai/kimi-k2.6", "object": "model"},
{"id": "@cf/meta/llama-4-scout-17b-16e-instruct", "object": "model"},
],
},
request=httpx.Request("GET", _MODEL_SEARCH_URL),
)
with patch.object(
cloudflare_provider._model_list_client,
"get",
new_callable=AsyncMock,
return_value=response,
) as mock_get:
assert await cloudflare_provider.list_model_ids() == frozenset(
{
"@cf/moonshotai/kimi-k2.6",
"@cf/meta/llama-4-scout-17b-16e-instruct",
}
)
mock_get.assert_awaited_once_with(
_MODEL_SEARCH_URL,
params={"format": "openrouter"},
headers={"Authorization": "Bearer test-cloudflare-token"},
)
@pytest.mark.asyncio
async def test_stream_uses_openai_chat_completions(
cloudflare_provider: CloudflareProvider,
) -> None:
delta = SimpleNamespace(
content="Hello from Cloudflare",
reasoning_content=None,
reasoning=None,
tool_calls=None,
)
with patch.object(
cloudflare_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta)),
) as mock_create:
events = [
event async for event in cloudflare_provider.stream_response(_request())
]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("text") == "Hello from Cloudflare"
for event in parsed
)
assert mock_create.call_args.kwargs["model"] == "@cf/moonshotai/kimi-k2.6"
assert mock_create.call_args.kwargs["stream"] is True
@pytest.mark.asyncio
async def test_stream_maps_cloudflare_reasoning_delta_to_thinking(
cloudflare_provider: CloudflareProvider,
) -> None:
delta = SimpleNamespace(
content=None,
reasoning_content=None,
reasoning="Cloudflare reasoning",
tool_calls=None,
)
with patch.object(
cloudflare_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta)),
):
events = [
event async for event in cloudflare_provider.stream_response(_request())
]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("thinking") == "Cloudflare reasoning"
for event in parsed
)
@pytest.mark.asyncio
async def test_stream_maps_openai_tool_calls_to_tool_use(
cloudflare_provider: CloudflareProvider,
) -> None:
tool_call = SimpleNamespace(
index=0,
id="call_1",
function=SimpleNamespace(name="echo", arguments='{"value":"x"}'),
)
delta = SimpleNamespace(
content=None,
reasoning_content=None,
reasoning=None,
tool_calls=[tool_call],
)
request = MessagesRequest.model_validate(
{
"model": "@cf/moonshotai/kimi-k2.6",
"messages": [{"role": "user", "content": "Use the tool"}],
"tools": [
{
"name": "echo",
"description": "Echo a value",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
],
}
)
with patch.object(
cloudflare_provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=_stream(_chunk(delta, finish_reason="tool_calls")),
):
events = [event async for event in cloudflare_provider.stream_response(request)]
parsed = parse_sse_text("".join(events))
assert any(
event.event == "content_block_start"
and event.data.get("content_block", {}).get("type") == "tool_use"
and event.data.get("content_block", {}).get("name") == "echo"
for event in parsed
)
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}'
for event in parsed
)