535 lines
18 KiB
Python
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")
|