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

535 lines
18 KiB
Python

import asyncio
import subprocess
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from free_claude_code.application.errors import UnknownProviderError
from free_claude_code.config.nim import NimSettings
from free_claude_code.config.provider_catalog import (
COHERE_DEFAULT_BASE,
GITHUB_MODELS_DEFAULT_BASE,
HUGGINGFACE_DEFAULT_BASE,
MINIMAX_DEFAULT_BASE,
PROVIDER_CATALOG,
SUPPORTED_PROVIDER_IDS,
VERCEL_AI_GATEWAY_DEFAULT_BASE,
ZAI_DEFAULT_BASE,
)
from free_claude_code.providers.cloudflare import CloudflareProvider
from free_claude_code.providers.deepseek import DeepSeekProvider
from free_claude_code.providers.gemini import GeminiProvider
from free_claude_code.providers.github_models import GitHubModelsProvider
from free_claude_code.providers.lmstudio import LMStudioProvider
from free_claude_code.providers.mistral import MistralProvider
from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
from free_claude_code.providers.open_router import OpenRouterProvider
from free_claude_code.providers.openai_chat import (
OPENAI_CHAT_PROFILES,
OpenAIChatProvider,
)
from free_claude_code.providers.rate_limit import ProviderRateLimiter
from free_claude_code.providers.runtime import (
ProviderRuntime,
build_provider_config,
create_provider,
)
def _make_settings(**overrides):
mock = MagicMock()
mock.model = "nvidia_nim/meta/llama3"
mock.model_opus = None
mock.model_sonnet = None
mock.model_haiku = None
mock.nvidia_nim_api_key = "test_key"
mock.open_router_api_key = "test_openrouter_key"
mock.mistral_api_key = "test_mistral_key"
mock.codestral_api_key = "test_codestral_key"
mock.deepseek_api_key = "test_deepseek_key"
mock.wafer_api_key = "test_wafer_key"
mock.minimax_api_key = "test_minimax_key"
mock.opencode_api_key = "test_opencode_key"
mock.vercel_ai_gateway_api_key = "test_vercel_key"
mock.huggingface_api_key = "test_huggingface_key"
mock.cohere_api_key = "test_cohere_key"
mock.github_models_token = "test_github_models_token"
mock.zai_api_key = "test_zai_key"
mock.lm_studio_base_url = "http://localhost:1234/v1"
mock.llamacpp_base_url = "http://localhost:8080/v1"
mock.ollama_base_url = "http://localhost:11434"
mock.nvidia_nim_proxy = ""
mock.open_router_proxy = ""
mock.lmstudio_proxy = ""
mock.llamacpp_proxy = ""
mock.mistral_proxy = ""
mock.codestral_proxy = ""
mock.kimi_proxy = ""
mock.kimi_api_key = "test_kimi_key"
mock.wafer_proxy = ""
mock.minimax_proxy = ""
mock.opencode_proxy = ""
mock.opencode_go_proxy = ""
mock.vercel_ai_gateway_proxy = ""
mock.huggingface_proxy = ""
mock.cohere_proxy = ""
mock.github_models_proxy = ""
mock.zai_proxy = ""
mock.fireworks_proxy = ""
mock.fireworks_api_key = "test_fireworks_key"
mock.cloudflare_api_token = "test_cloudflare_token"
mock.cloudflare_account_id = "test_cloudflare_account"
mock.cloudflare_proxy = ""
mock.gemini_api_key = ""
mock.gemini_proxy = ""
mock.groq_api_key = ""
mock.groq_proxy = ""
mock.cerebras_api_key = ""
mock.cerebras_proxy = ""
mock.provider_rate_limit = 40
mock.provider_rate_window = 60
mock.provider_max_concurrency = 5
mock.http_read_timeout = 300.0
mock.http_write_timeout = 10.0
mock.http_connect_timeout = 10.0
mock.enable_model_thinking = True
mock.log_raw_sse_events = False
mock.log_api_error_tracebacks = False
mock.nim = NimSettings()
for key, value in overrides.items():
setattr(mock, key, value)
return mock
def test_importing_runtime_does_not_eager_load_other_adapters() -> None:
"""Runtime metadata must not import every provider adapter up front."""
code = (
"import sys\n"
"import free_claude_code.providers.runtime\n"
"assert 'free_claude_code.providers.open_router' not in sys.modules\n"
)
proc = subprocess.run(
[sys.executable, "-c", code],
check=False,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stderr or proc.stdout
def test_provider_catalog_covers_advertised_provider_ids():
assert set(PROVIDER_CATALOG) == set(SUPPORTED_PROVIDER_IDS)
assert set(OPENAI_CHAT_PROFILES) < set(PROVIDER_CATALOG)
for descriptor in PROVIDER_CATALOG.values():
assert descriptor.provider_id
def test_ollama_descriptor_uses_local_openai_endpoint_semantics():
descriptor = PROVIDER_CATALOG["ollama"]
assert descriptor.default_base_url == "http://localhost:11434"
assert descriptor.local is True
@pytest.mark.parametrize(
("provider_id", "expected_api_key"),
[
("lmstudio", "lm-studio"),
("llamacpp", "llamacpp"),
("ollama", "ollama"),
],
)
def test_local_provider_factory_resolves_catalog_static_credential(
provider_id: str,
expected_api_key: str,
) -> None:
descriptor = PROVIDER_CATALOG[provider_id]
settings = _make_settings()
config = build_provider_config(descriptor, settings)
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
provider = create_provider(provider_id, settings)
assert config.api_key == expected_api_key
assert isinstance(provider, OpenAIChatProvider)
assert provider._api_key == expected_api_key
def test_zai_descriptor_uses_fixed_cloud_base_url():
descriptor = PROVIDER_CATALOG["zai"]
assert descriptor.default_base_url == ZAI_DEFAULT_BASE
assert descriptor.base_url_attr is None
def test_zai_provider_config_ignores_stale_base_url_setting():
descriptor = PROVIDER_CATALOG["zai"]
config = build_provider_config(
descriptor,
_make_settings(zai_base_url="https://custom.zai.invalid/v1"),
)
assert config.base_url == ZAI_DEFAULT_BASE
def test_minimax_descriptor_uses_expected_endpoint_and_credential():
descriptor = PROVIDER_CATALOG["minimax"]
assert descriptor.default_base_url == MINIMAX_DEFAULT_BASE
assert descriptor.credential_env == "MINIMAX_API_KEY"
def test_cloudflare_descriptor_uses_api_root_not_account_url():
descriptor = PROVIDER_CATALOG["cloudflare"]
assert descriptor.default_base_url == "https://api.cloudflare.com/client/v4"
assert descriptor.base_url_attr is None
def test_create_cloudflare_provider_uses_account_scoped_base_url():
settings = _make_settings(
cloudflare_api_token="test_cloudflare_token",
cloudflare_account_id="test-account",
)
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
provider = create_provider("cloudflare", settings)
assert isinstance(provider, CloudflareProvider)
assert provider._base_url == (
"https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1"
)
def test_opencode_go_provider_config_uses_correct_base_url_and_name():
with patch("httpx.AsyncClient"):
provider = create_provider("opencode_go", _make_settings())
assert isinstance(provider, OpenAIChatProvider)
assert provider._base_url == "https://opencode.ai/zen/go/v1"
assert provider._provider_name == "OPENCODE_GO"
assert provider._api_key == "test_opencode_key"
def test_opencode_go_catalog_uses_opencode_api_key() -> None:
desc = PROVIDER_CATALOG["opencode_go"]
assert desc.credential_env == "OPENCODE_API_KEY"
assert desc.credential_attr == "opencode_api_key"
def test_build_provider_config_opencode_go_uses_opencode_api_key() -> None:
descriptor = PROVIDER_CATALOG["opencode_go"]
settings = _make_settings(opencode_api_key="shared-opencode-token")
config = build_provider_config(descriptor, settings)
assert config.api_key == "shared-opencode-token"
def test_vercel_descriptor_uses_openai_chat_gateway() -> None:
descriptor = PROVIDER_CATALOG["vercel"]
assert descriptor.default_base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE
assert descriptor.credential_env == "AI_GATEWAY_API_KEY"
assert descriptor.proxy_attr == "vercel_ai_gateway_proxy"
def test_huggingface_descriptor_uses_openai_chat_router() -> None:
descriptor = PROVIDER_CATALOG["huggingface"]
assert descriptor.default_base_url == HUGGINGFACE_DEFAULT_BASE
assert descriptor.credential_env == "HUGGINGFACE_API_KEY"
assert descriptor.proxy_attr == "huggingface_proxy"
def test_cohere_descriptor_uses_openai_chat_compatibility_api() -> None:
descriptor = PROVIDER_CATALOG["cohere"]
assert descriptor.default_base_url == COHERE_DEFAULT_BASE
assert descriptor.credential_env == "COHERE_API_KEY"
assert descriptor.proxy_attr == "cohere_proxy"
def test_github_models_descriptor_uses_openai_chat_inference_api() -> None:
descriptor = PROVIDER_CATALOG["github_models"]
assert descriptor.default_base_url == GITHUB_MODELS_DEFAULT_BASE
assert descriptor.credential_env == "GITHUB_MODELS_TOKEN"
assert descriptor.proxy_attr == "github_models_proxy"
def test_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["vercel"]
settings = _make_settings(
vercel_ai_gateway_api_key="vercel-token",
vercel_ai_gateway_proxy="http://proxy.test:8080",
)
config = build_provider_config(descriptor, settings)
assert config.api_key == "vercel-token"
assert config.proxy == "http://proxy.test:8080"
def test_build_provider_config_huggingface_uses_api_key_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["huggingface"]
settings = _make_settings(
huggingface_api_key="hf-token",
huggingface_proxy="http://proxy.test:8080",
)
config = build_provider_config(descriptor, settings)
assert config.api_key == "hf-token"
assert config.proxy == "http://proxy.test:8080"
def test_build_provider_config_cohere_uses_api_key_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["cohere"]
settings = _make_settings(
cohere_api_key="cohere-token",
cohere_proxy="http://proxy.test:8080",
)
config = build_provider_config(descriptor, settings)
assert config.api_key == "cohere-token"
assert config.proxy == "http://proxy.test:8080"
def test_build_provider_config_github_models_uses_token_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["github_models"]
settings = _make_settings(
github_models_token="github-token",
github_models_proxy="http://proxy.test:8080",
)
config = build_provider_config(descriptor, settings)
assert config.api_key == "github-token"
assert config.proxy == "http://proxy.test:8080"
def test_create_provider_uses_openai_chat_openrouter_by_default():
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
provider = create_provider("open_router", _make_settings())
assert isinstance(provider, OpenRouterProvider)
def test_create_provider_instantiates_each_builtin():
settings = _make_settings(
gemini_api_key="test_gemini_key",
groq_api_key="test_groq_key",
cerebras_api_key="test_cerebras_key",
fireworks_api_key="test_fireworks_key",
cloudflare_api_token="test_cloudflare_token",
cloudflare_account_id="test_cloudflare_account",
vercel_ai_gateway_api_key="test_vercel_key",
huggingface_api_key="test_huggingface_key",
cohere_api_key="test_cohere_key",
github_models_token="test_github_models_token",
kimi_api_key="test_kimi_key",
provider_rate_limit=7,
provider_rate_window=11,
provider_max_concurrency=3,
sambanova_api_key="test_sambanova_key",
)
cases = {
"nvidia_nim": NvidiaNimProvider,
"open_router": OpenRouterProvider,
"mistral": MistralProvider,
"mistral_codestral": OpenAIChatProvider,
"deepseek": DeepSeekProvider,
"kimi": OpenAIChatProvider,
"minimax": OpenAIChatProvider,
"fireworks": OpenAIChatProvider,
"cloudflare": CloudflareProvider,
"lmstudio": LMStudioProvider,
"llamacpp": OpenAIChatProvider,
"ollama": OpenAIChatProvider,
"wafer": OpenAIChatProvider,
"opencode": OpenAIChatProvider,
"opencode_go": OpenAIChatProvider,
"vercel": OpenAIChatProvider,
"huggingface": OpenAIChatProvider,
"cohere": OpenAIChatProvider,
"github_models": GitHubModelsProvider,
"zai": OpenAIChatProvider,
"gemini": GeminiProvider,
"groq": OpenAIChatProvider,
"sambanova": OpenAIChatProvider,
"cerebras": OpenAIChatProvider,
}
sentinel_limiter = MagicMock(spec=ProviderRateLimiter)
with (
patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"),
patch("httpx.AsyncClient"),
patch(
"free_claude_code.providers.runtime.factory.ProviderRateLimiter",
return_value=sentinel_limiter,
) as limiter_factory,
):
for provider_id, provider_cls in cases.items():
provider = create_provider(provider_id, settings)
assert isinstance(provider, provider_cls)
assert provider._rate_limiter is sentinel_limiter
limiter_factory.assert_called_once_with(
rate_limit=7,
rate_window=11,
max_concurrency=3,
)
limiter_factory.reset_mock()
assert set(cases) == set(PROVIDER_CATALOG)
def test_provider_runtime_caches_by_provider_id():
runtime = ProviderRuntime(_make_settings())
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
first = runtime.resolve_provider("nvidia_nim")
second = runtime.resolve_provider("nvidia_nim")
assert first is second
def test_provider_runtime_provider_owns_one_limiter() -> None:
runtime = ProviderRuntime(_make_settings())
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
first = runtime.resolve_provider("nvidia_nim")
second = runtime.resolve_provider("nvidia_nim")
assert isinstance(first, NvidiaNimProvider)
assert isinstance(second, NvidiaNimProvider)
assert first._rate_limiter is second._rate_limiter
def test_separate_provider_runtimes_never_share_limiters() -> None:
first_runtime = ProviderRuntime(_make_settings())
second_runtime = ProviderRuntime(_make_settings())
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
first = first_runtime.resolve_provider("nvidia_nim")
second = second_runtime.resolve_provider("nvidia_nim")
assert isinstance(first, NvidiaNimProvider)
assert isinstance(second, NvidiaNimProvider)
assert first is not second
assert first._rate_limiter is not second._rate_limiter
def test_different_providers_in_one_runtime_have_independent_limiters() -> None:
runtime = ProviderRuntime(_make_settings())
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
nim = runtime.resolve_provider("nvidia_nim")
open_router = runtime.resolve_provider("open_router")
assert isinstance(nim, NvidiaNimProvider)
assert isinstance(open_router, OpenRouterProvider)
assert nim._rate_limiter is not open_router._rate_limiter
def test_unknown_provider_raises_unknown_provider_type_error():
with pytest.raises(UnknownProviderError, match="Unknown provider_type"):
create_provider("unknown", _make_settings())
@pytest.mark.asyncio
async def test_provider_runtime_cleanup_runs_all_even_if_one_fails() -> None:
"""Successful providers leave the cache while failed providers remain retryable."""
p1 = MagicMock()
p1.cleanup = AsyncMock(side_effect=RuntimeError("first"))
p2 = MagicMock()
p2.cleanup = AsyncMock()
runtime = ProviderRuntime(_make_settings(), {"a": p1, "b": p2})
with pytest.raises(RuntimeError, match="first"):
await runtime.cleanup()
p1.cleanup.assert_awaited_once()
p2.cleanup.assert_awaited_once()
assert runtime.is_cached("a")
assert not runtime.is_cached("b")
p1.cleanup = AsyncMock()
await runtime.cleanup()
p1.cleanup.assert_awaited_once()
assert not runtime.is_cached("a")
@pytest.mark.asyncio
async def test_cancelled_cleanup_retains_current_and_unvisited_providers() -> None:
first = MagicMock()
second = MagicMock()
third = MagicMock()
second_started = asyncio.Event()
second_attempts = 0
async def cleanup_second() -> None:
nonlocal second_attempts
second_attempts += 1
if second_attempts == 1:
second_started.set()
await asyncio.Event().wait()
first.cleanup = AsyncMock()
second.cleanup = AsyncMock(side_effect=cleanup_second)
third.cleanup = AsyncMock()
runtime = ProviderRuntime(
_make_settings(),
{"first": first, "second": second, "third": third},
)
cleanup_task = asyncio.create_task(runtime.cleanup())
await second_started.wait()
cleanup_task.cancel()
with pytest.raises(asyncio.CancelledError):
await cleanup_task
assert runtime.is_cached("first") is False
assert runtime.is_cached("second") is True
assert runtime.is_cached("third") is True
first.cleanup.assert_awaited_once_with()
third.cleanup.assert_not_awaited()
await runtime.cleanup()
first.cleanup.assert_awaited_once_with()
assert second.cleanup.await_count == 2
third.cleanup.assert_awaited_once_with()
assert runtime.is_cached("first") is False
assert runtime.is_cached("second") is False
assert runtime.is_cached("third") is False
@pytest.mark.asyncio
async def test_provider_runtime_cleanup_exceptiongroup_on_multiple_failures() -> None:
p1 = MagicMock()
p1.cleanup = AsyncMock(side_effect=RuntimeError("a"))
p2 = MagicMock()
p2.cleanup = AsyncMock(side_effect=RuntimeError("b"))
runtime = ProviderRuntime(_make_settings(), {"x": p1, "y": p2})
with pytest.raises(ExceptionGroup) as exc_info:
await runtime.cleanup()
assert len(exc_info.value.exceptions) == 2
assert runtime.is_cached("x")
assert runtime.is_cached("y")
p1.cleanup = AsyncMock()
p2.cleanup = AsyncMock()
await runtime.cleanup()
assert not runtime.is_cached("x")
assert not runtime.is_cached("y")