mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-19 07:23:36 +00:00
Some checks are pending
## Why
Fallback rows could choose a model and reasoning effort but always used
the provider's first API key. Users need to choose which numbered key
each fallback uses, including different keys for the same model.
## How
Add a Key N selector and persist a validated `api_key_number`,
defaulting existing entries to Key 1. Reject duplicate model/key pairs
and key removals that leave a saved fallback's selection unavailable.
Carry the number through routing and the request's retained runtime
generation. Cache and construct clients per provider/key pair so
concurrent attempts keep their selected credentials.
Share rate and concurrency capacity across a provider's keys, while
recovery belongs to the actual credential. A healthy selected fallback
key receives an attempt after another key exhausts rate-limit retries.
Reordering a key preserves its recovery state, and replacing it uses a
separate state. Keep the existing retry classification, attempt count,
and backoff.
Refresh key choices after provider edits while preserving unsaved model
drafts. Display removed numbers as unavailable and reset the choice to
Key 1 when a row changes provider. Numbers follow the current order in
the provider's settings. Providers with another authentication method
show a disabled default selection.
The Admin editor targets desktop viewports, as requested. Its existing
rows and add row keep the three selections aligned horizontally.
Local CI passes, including 9,049 Python tests and 277 browser tests.
Added regressions capture actual authentication headers after 403 and
exhausted 429 failures across both public APIs and all three custom
endpoint formats, and check shared capacity, client coalescing, retained
generations, credential reorder/replacement, and Admin edits.
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=79105240"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>
<!-- greptile-impact-verdict --> No outstanding blocking findings were
identified. The reviewed changes are safe to merge.
<details open><summary><h3>Summary</h3></summary>
Adds a numbered API-key choice to each fallback model and carries that
choice through routing and provider construction.
- Fallback rows can choose a numbered API key.
- Fallback attempts send requests with their selected API keys.
- Provider clients keep separate keys while sharing provider limits.
</details>
<!-- greptile_confidence_score:5 -->
<sub>Reviews (3) · Last reviewed commit: ["Keep credential recovery
separate from
p..."](c7c33ac61b)
· [Reviewed by
Greptile](https://www.greptile.com/?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=alishahryar1/free-claude-code)</sub>
<!-- /greptile_comment -->
1744 lines
63 KiB
Python
1744 lines
63 KiB
Python
import asyncio
|
|
import gc
|
|
import subprocess
|
|
import sys
|
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from free_claude_code.application.errors import (
|
|
ApplicationUnavailableError,
|
|
UnknownProviderError,
|
|
)
|
|
from free_claude_code.config.api_keys import normalize_api_keys
|
|
from free_claude_code.config.nim import NimSettings
|
|
from free_claude_code.config.provider_catalog import (
|
|
AGNES_DEFAULT_BASE,
|
|
ATLASCLOUD_DEFAULT_BASE,
|
|
BEDROCK_DEFAULT_BASE,
|
|
CHEAPERINFERENCE_DEFAULT_BASE,
|
|
CHUTES_DEFAULT_BASE,
|
|
CLINE_DEFAULT_BASE,
|
|
COHERE_DEFAULT_BASE,
|
|
DEEPINFRA_DEFAULT_BASE,
|
|
EXPERIENTIAL_DEFAULT_BASE,
|
|
FEATHERLESS_DEFAULT_BASE,
|
|
HUGGINGFACE_DEFAULT_BASE,
|
|
KIMI_CODE_DEFAULT_BASE,
|
|
LLM7_DEFAULT_BASE,
|
|
MINIMAX_DEFAULT_BASE,
|
|
NEBIUS_DEFAULT_BASE,
|
|
OLLAMA_CLOUD_DEFAULT_BASE,
|
|
OPPER_DEFAULT_BASE,
|
|
ORCAROUTER_DEFAULT_BASE,
|
|
POOLSIDE_DEFAULT_BASE,
|
|
PROVIDER_CATALOG,
|
|
QWENCLOUD_CODING_DEFAULT_BASE,
|
|
QWENCLOUD_DEFAULT_BASE,
|
|
SCALEWAY_DEFAULT_BASE,
|
|
SILICONFLOW_DEFAULT_BASE,
|
|
SUPPORTED_PROVIDER_IDS,
|
|
TOGETHER_DEFAULT_BASE,
|
|
VERCEL_AI_GATEWAY_DEFAULT_BASE,
|
|
WANDB_INFERENCE_DEFAULT_BASE,
|
|
XAI_DEFAULT_BASE,
|
|
XKIRO_DEFAULT_BASE,
|
|
ZAI_API_DEFAULT_BASE,
|
|
ZAI_CODING_DEFAULT_BASE,
|
|
ZENMUX_DEFAULT_BASE,
|
|
)
|
|
from free_claude_code.providers.admission import ProviderAdmissionController
|
|
from free_claude_code.providers.admission_policy import ProviderAdmissionLimits
|
|
from free_claude_code.providers.admission_registry import ProviderAdmissionRegistry
|
|
from free_claude_code.providers.alibaba_cloud import AlibabaCloudProvider
|
|
from free_claude_code.providers.anthropic import AnthropicProvider
|
|
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_copilot.provider import GitHubCopilotProvider
|
|
from free_claude_code.providers.groq import GroqProvider
|
|
from free_claude_code.providers.kilo import KiloProvider
|
|
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_api import OpenAIAPIProvider
|
|
from free_claude_code.providers.openai_chat import (
|
|
OPENAI_CHAT_PROFILES,
|
|
OpenAIChatProvider,
|
|
)
|
|
from free_claude_code.providers.openai_codex.provider import OpenAICodexProvider
|
|
from free_claude_code.providers.opencode import OpenCodeProvider
|
|
from free_claude_code.providers.runtime import (
|
|
ProviderRuntime,
|
|
build_provider_config,
|
|
create_provider,
|
|
)
|
|
from free_claude_code.providers.vertex import VertexProvider
|
|
|
|
|
|
def _make_settings(**overrides):
|
|
mock = MagicMock()
|
|
mock.model = "nvidia_nim/meta/llama3"
|
|
mock.model_fable = None
|
|
mock.model_opus = None
|
|
mock.model_sonnet = None
|
|
mock.model_haiku = None
|
|
mock.openai_api_keys = "test_openai_api_key"
|
|
mock.azure_openai_api_keys = "test_azure_openai_key"
|
|
mock.azure_openai_base_url = "https://test-resource.openai.azure.com/openai/v1/"
|
|
mock.nvidia_nim_api_keys = "test_key"
|
|
mock.open_router_api_keys = "test_openrouter_key"
|
|
mock.xai_api_keys = "test_xai_key"
|
|
mock.anthropic_api_keys = "test_anthropic_key"
|
|
mock.anthropic_workspace_id = "workspace"
|
|
mock.anthropic_proxy = None
|
|
mock.alibaba_cloud_api_keys = "test_alibaba_key"
|
|
mock.alibaba_cloud_base_url = None
|
|
mock.alibaba_cloud_proxy = None
|
|
mock.qwencloud_api_keys = "test_qwencloud_key"
|
|
mock.qwencloud_coding_api_keys = "test_qwencloud_coding_key"
|
|
mock.together_api_keys = "test_together_key"
|
|
mock.deepinfra_api_keys = "test_deepinfra_key"
|
|
mock.siliconflow_api_keys = "test_siliconflow_key"
|
|
mock.nebius_api_keys = "test_nebius_key"
|
|
mock.scw_secret_keys = "test_scw_key"
|
|
mock.opper_api_keys = "test_opper_key"
|
|
mock.chutes_api_keys = "test_chutes_key"
|
|
mock.featherless_api_keys = "test_featherless_key"
|
|
mock.mistral_api_keys = "test_mistral_key"
|
|
mock.codestral_api_keys = "test_codestral_key"
|
|
mock.deepseek_api_keys = "test_deepseek_key"
|
|
mock.wafer_api_keys = "test_wafer_key"
|
|
mock.minimax_api_keys = "test_minimax_key"
|
|
mock.opencode_api_keys = "test_opencode_key"
|
|
mock.vercel_ai_gateway_api_keys = "test_vercel_key"
|
|
mock.bedrock_api_keys = "test_bedrock_key"
|
|
mock.bedrock_base_url = BEDROCK_DEFAULT_BASE
|
|
mock.huggingface_api_keys = "test_huggingface_key"
|
|
mock.cohere_api_keys = "test_cohere_key"
|
|
mock.zai_api_keys = "test_zai_key"
|
|
mock.tokenrouter_api_keys = "test_tokenrouter_key"
|
|
mock.nararoute_api_keys = "test_nararoute_key"
|
|
mock.agnes_api_keys = "test_agnes_key"
|
|
mock.zenmux_api_keys = "test_zenmux_key"
|
|
mock.wandb_api_keys = "test_wandb_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.ollama_api_keys = "test_ollama_cloud_key"
|
|
mock.poolside_api_keys = "test_poolside_key"
|
|
mock.llm7_api_keys = "test_llm7_key"
|
|
mock.lightning_api_keys = "test_lightning_key"
|
|
mock.experiential_api_keys = "test_experiential_key"
|
|
mock.cheaperinference_api_keys = "test_cheaperinference_key"
|
|
mock.orcarouter_api_keys = "test_orcarouter_key"
|
|
mock.atlascloud_api_keys = "test_atlascloud_key"
|
|
mock.xkiro_api_keys = "test_xkiro_key"
|
|
mock.nvidia_nim_proxy = None
|
|
mock.open_router_proxy = None
|
|
mock.lmstudio_proxy = None
|
|
mock.llamacpp_proxy = None
|
|
mock.mistral_proxy = None
|
|
mock.codestral_proxy = None
|
|
mock.kimi_proxy = None
|
|
mock.kimi_api_keys = "test_kimi_key"
|
|
mock.kimi_code_proxy = None
|
|
mock.kimi_code_api_keys = "test_kimi_code_key"
|
|
mock.wafer_proxy = None
|
|
mock.minimax_proxy = None
|
|
mock.opencode_zen_proxy = None
|
|
mock.opencode_go_proxy = None
|
|
mock.vercel_ai_gateway_proxy = None
|
|
mock.bedrock_proxy = None
|
|
mock.huggingface_proxy = None
|
|
mock.cohere_proxy = None
|
|
mock.zai_proxy = None
|
|
mock.zai_api_proxy = None
|
|
mock.tokenrouter_proxy = None
|
|
mock.nararoute_proxy = None
|
|
mock.agnes_proxy = None
|
|
mock.zenmux_proxy = None
|
|
mock.wandb_proxy = None
|
|
mock.fireworks_proxy = None
|
|
mock.fireworks_api_keys = "test_fireworks_key"
|
|
mock.novita_proxy = None
|
|
mock.novita_api_keys = "test_novita_key"
|
|
mock.cloudflare_api_tokens = "test_cloudflare_token"
|
|
mock.cloudflare_account_id = "test_cloudflare_account"
|
|
mock.cloudflare_proxy = None
|
|
mock.gemini_api_keys = ""
|
|
mock.gemini_proxy = None
|
|
mock.vertex_project_id = "test-vertex-project"
|
|
mock.vertex_location = "global"
|
|
mock.vertex_proxy = None
|
|
mock.groq_api_keys = ""
|
|
mock.groq_proxy = None
|
|
mock.cline_api_keys = ""
|
|
mock.cline_pass_proxy = None
|
|
mock.cerebras_api_keys = ""
|
|
mock.cerebras_proxy = None
|
|
mock.ollama_cloud_proxy = None
|
|
mock.poolside_proxy = None
|
|
mock.llm7_proxy = None
|
|
mock.lightning_proxy = None
|
|
mock.experiential_proxy = None
|
|
mock.cheaperinference_proxy = None
|
|
mock.orcarouter_proxy = None
|
|
mock.atlascloud_proxy = None
|
|
mock.xkiro_proxy = None
|
|
mock.kilo_api_keys = "test_kilo_key"
|
|
mock.kilo_proxy = None
|
|
mock.openai_proxy = None
|
|
mock.openai_api_proxy = None
|
|
mock.xai_proxy = None
|
|
mock.qwencloud_proxy = None
|
|
mock.qwencloud_coding_proxy = None
|
|
mock.together_proxy = None
|
|
mock.deepinfra_proxy = None
|
|
mock.siliconflow_proxy = None
|
|
mock.nebius_proxy = None
|
|
mock.scw_proxy = None
|
|
mock.opper_proxy = None
|
|
mock.chutes_proxy = None
|
|
mock.featherless_proxy = None
|
|
mock.azure_openai_proxy = None
|
|
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.log_raw_sse_events = False
|
|
mock.log_api_error_tracebacks = False
|
|
mock.nim = NimSettings()
|
|
for key, value in overrides.items():
|
|
setattr(mock, key, value)
|
|
for attr in {
|
|
d.credential_attr for d in PROVIDER_CATALOG.values() if d.credential_attr
|
|
}:
|
|
setattr(mock, attr, normalize_api_keys(mock.__dict__.get(attr)))
|
|
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
|
|
|
|
|
|
def test_ollama_cloud_descriptor_uses_direct_authenticated_endpoint():
|
|
descriptor = PROVIDER_CATALOG["ollama_cloud"]
|
|
|
|
assert descriptor.default_base_url == OLLAMA_CLOUD_DEFAULT_BASE
|
|
assert descriptor.credential_env == "OLLAMA_API_KEY"
|
|
assert descriptor.credential_attr == "ollama_api_keys"
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.local is False
|
|
|
|
|
|
def test_ollama_cloud_provider_config_uses_key_and_proxy():
|
|
descriptor = PROVIDER_CATALOG["ollama_cloud"]
|
|
settings = _make_settings(
|
|
ollama_api_keys="ollama-cloud-token",
|
|
ollama_cloud_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
|
|
assert config.api_key == "ollama-cloud-token"
|
|
assert config.base_url == OLLAMA_CLOUD_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poolside_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["poolside"]
|
|
settings = _make_settings(
|
|
poolside_api_keys="poolside-token",
|
|
poolside_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"poolside",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Poolside AI"
|
|
assert descriptor.credential_env == "POOLSIDE_API_KEY"
|
|
assert descriptor.credential_attr == "poolside_api_keys"
|
|
assert descriptor.credential_url == "https://platform.poolside.ai/"
|
|
assert descriptor.default_base_url == POOLSIDE_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "poolside_proxy"
|
|
assert config.api_key == "poolside-token"
|
|
assert config.base_url == POOLSIDE_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm7_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["llm7"]
|
|
settings = _make_settings(
|
|
llm7_api_keys="llm7-token",
|
|
llm7_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"llm7",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "LLM7.io"
|
|
assert descriptor.credential_env == "LLM7_API_KEY"
|
|
assert descriptor.credential_attr == "llm7_api_keys"
|
|
assert descriptor.credential_url == "https://dash.llm7.io/"
|
|
assert descriptor.default_base_url == LLM7_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "llm7_proxy"
|
|
assert config.api_key == "llm7-token"
|
|
assert config.base_url == LLM7_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_experiential_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["experiential"]
|
|
settings = _make_settings(
|
|
experiential_api_keys="experiential-token",
|
|
experiential_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"experiential",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Experiential Labs"
|
|
assert descriptor.credential_env == "EXPLABS_API_KEY"
|
|
assert descriptor.credential_attr == "experiential_api_keys"
|
|
assert (
|
|
descriptor.credential_url
|
|
== "https://platform.experientiallabs.ai/settings/api-keys"
|
|
)
|
|
assert descriptor.default_base_url == EXPERIENTIAL_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "experiential_proxy"
|
|
assert config.api_key == "experiential-token"
|
|
assert config.base_url == EXPERIENTIAL_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cheaperinference_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["cheaperinference"]
|
|
settings = _make_settings(
|
|
cheaperinference_api_keys="ci_live_token",
|
|
cheaperinference_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"cheaperinference",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Cheaper Inference"
|
|
assert descriptor.credential_env == "CHEAPER_INFERENCE_API_KEY"
|
|
assert descriptor.credential_attr == "cheaperinference_api_keys"
|
|
assert descriptor.credential_url == "https://cheaperinference.com/signup"
|
|
assert descriptor.default_base_url == CHEAPERINFERENCE_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "cheaperinference_proxy"
|
|
assert config.api_key == "ci_live_token"
|
|
assert config.base_url == "https://api.cheaperinference.com/v1"
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orcarouter_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["orcarouter"]
|
|
settings = _make_settings(
|
|
orcarouter_api_keys="orcarouter-token",
|
|
orcarouter_base_url="https://unused.example/v1",
|
|
orcarouter_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"orcarouter",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "OrcaRouter"
|
|
assert descriptor.credential_env == "ORCAROUTER_API_KEY"
|
|
assert descriptor.credential_attr == "orcarouter_api_keys"
|
|
assert descriptor.credential_url == "https://www.orcarouter.ai/console"
|
|
assert descriptor.default_base_url == ORCAROUTER_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "orcarouter_proxy"
|
|
assert config.api_key == "orcarouter-token"
|
|
assert config.base_url == ORCAROUTER_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_atlascloud_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["atlascloud"]
|
|
settings = _make_settings(
|
|
atlascloud_api_keys="atlascloud-token",
|
|
atlascloud_base_url="https://unused.example/v1",
|
|
atlascloud_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"atlascloud",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Atlas Cloud"
|
|
assert descriptor.credential_env == "ATLASCLOUD_API_KEY"
|
|
assert descriptor.credential_attr == "atlascloud_api_keys"
|
|
assert descriptor.credential_url == "https://console.atlascloud.ai/api-keys"
|
|
assert descriptor.default_base_url == ATLASCLOUD_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "atlascloud_proxy"
|
|
assert config.api_key == "atlascloud-token"
|
|
assert config.base_url == ATLASCLOUD_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_xai_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["xai"]
|
|
settings = _make_settings(
|
|
xai_api_keys="xai-token",
|
|
xai_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"xai",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "xAI (Grok)"
|
|
assert descriptor.credential_env == "XAI_API_KEY"
|
|
assert config.api_key == "xai-token"
|
|
assert config.base_url == XAI_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qwencloud_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["qwencloud"]
|
|
settings = _make_settings(
|
|
qwencloud_api_keys="qwencloud-token",
|
|
qwencloud_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"qwencloud",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "QwenCloud Token Plan"
|
|
assert descriptor.credential_env == "QWENCLOUD_API_KEY"
|
|
assert config.api_key == "qwencloud-token"
|
|
assert config.base_url == QWENCLOUD_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cline_pass_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["cline_pass"]
|
|
settings = _make_settings(
|
|
cline_api_keys="cline-programmatic-token",
|
|
cline_pass_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"cline_pass",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "ClinePass"
|
|
assert descriptor.credential_env == "CLINE_API_KEY"
|
|
assert descriptor.credential_attr == "cline_api_keys"
|
|
assert descriptor.credential_url == "https://app.cline.bot"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "cline-programmatic-token"
|
|
assert config.base_url == CLINE_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
assert provider._provider_name == "CLINE_PASS"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qwencloud_coding_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["qwencloud_coding"]
|
|
settings = _make_settings(
|
|
qwencloud_coding_api_keys="qwencloud-coding-token",
|
|
qwencloud_coding_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"qwencloud_coding",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "QwenCloud Coding Plan"
|
|
assert descriptor.credential_env == "QWENCLOUD_CODING_API_KEY"
|
|
assert config.api_key == "qwencloud-coding-token"
|
|
assert config.base_url == QWENCLOUD_CODING_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_together_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["together"]
|
|
settings = _make_settings(
|
|
together_api_keys="together-token",
|
|
together_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"together",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Together AI"
|
|
assert descriptor.credential_env == "TOGETHER_API_KEY"
|
|
assert config.api_key == "together-token"
|
|
assert config.base_url == TOGETHER_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deepinfra_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["deepinfra"]
|
|
settings = _make_settings(
|
|
deepinfra_api_keys="deepinfra-token",
|
|
deepinfra_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"deepinfra",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "DeepInfra"
|
|
assert descriptor.credential_env == "DEEPINFRA_API_KEY"
|
|
assert config.api_key == "deepinfra-token"
|
|
assert config.base_url == DEEPINFRA_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_siliconflow_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["siliconflow"]
|
|
settings = _make_settings(
|
|
siliconflow_api_keys="siliconflow-token",
|
|
siliconflow_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"siliconflow",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "SiliconFlow"
|
|
assert descriptor.credential_env == "SILICONFLOW_API_KEY"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "siliconflow-token"
|
|
assert config.base_url == SILICONFLOW_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nebius_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["nebius"]
|
|
settings = _make_settings(
|
|
nebius_api_keys="nebius-token",
|
|
nebius_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"nebius",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Nebius Token Factory"
|
|
assert descriptor.credential_env == "NEBIUS_API_KEY"
|
|
assert descriptor.credential_url == (
|
|
"https://tokenfactory.nebius.com/project/api-keys"
|
|
)
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "nebius-token"
|
|
assert config.base_url == NEBIUS_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scaleway_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["scaleway"]
|
|
settings = _make_settings(
|
|
scw_secret_keys="scw-token",
|
|
scw_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"scaleway",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Scaleway"
|
|
assert descriptor.credential_env == "SCW_SECRET_KEY"
|
|
assert descriptor.credential_url == "https://console.scaleway.com/iam/api-keys"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "scw-token"
|
|
assert config.base_url == SCALEWAY_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opper_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["opper"]
|
|
settings = _make_settings(
|
|
opper_api_keys="opper-token",
|
|
opper_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"opper",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Opper"
|
|
assert descriptor.credential_env == "OPPER_API_KEY"
|
|
assert descriptor.credential_url == "https://platform.opper.ai/"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "opper-token"
|
|
assert config.base_url == OPPER_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chutes_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["chutes"]
|
|
settings = _make_settings(
|
|
chutes_api_keys="chutes-token",
|
|
chutes_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"chutes",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Chutes"
|
|
assert descriptor.credential_env == "CHUTES_API_KEY"
|
|
assert descriptor.credential_url == (
|
|
"https://chutes.ai/docs/getting-started/authentication"
|
|
)
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "chutes-token"
|
|
assert config.base_url == CHUTES_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_featherless_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["featherless"]
|
|
settings = _make_settings(
|
|
featherless_api_keys="featherless-token",
|
|
featherless_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"featherless",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Featherless AI"
|
|
assert descriptor.credential_env == "FEATHERLESS_API_KEY"
|
|
assert descriptor.credential_url == "https://featherless.ai/account/api-keys"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "featherless-token"
|
|
assert config.base_url == FEATHERLESS_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agnes_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["agnes"]
|
|
settings = _make_settings(
|
|
agnes_api_keys="agnes-token",
|
|
agnes_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"agnes",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Agnes AI"
|
|
assert descriptor.credential_env == "AGNES_API_KEY"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "agnes-token"
|
|
assert config.base_url == AGNES_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zenmux_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["zenmux"]
|
|
settings = _make_settings(
|
|
zenmux_api_keys="zenmux-token",
|
|
zenmux_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"zenmux",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "ZenMux"
|
|
assert descriptor.credential_env == "ZENMUX_API_KEY"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "zenmux-token"
|
|
assert config.base_url == ZENMUX_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wandb_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["wandb"]
|
|
settings = _make_settings(
|
|
wandb_api_keys="wandb-token",
|
|
wandb_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"wandb",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "W&B Inference"
|
|
assert descriptor.credential_env == "WANDB_API_KEY"
|
|
assert descriptor.credential_url == "https://wandb.ai/settings"
|
|
assert descriptor.base_url_attr is None
|
|
assert config.api_key == "wandb-token"
|
|
assert config.base_url == WANDB_INFERENCE_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
def test_bedrock_provider_config_uses_regional_base_key_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["bedrock"]
|
|
settings = _make_settings(
|
|
bedrock_api_keys="bedrock-token",
|
|
bedrock_base_url="https://bedrock-mantle.eu-west-1.api.aws/v1",
|
|
bedrock_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
|
|
assert descriptor.credential_env == "AWS_BEARER_TOKEN_BEDROCK"
|
|
assert descriptor.default_base_url == BEDROCK_DEFAULT_BASE
|
|
assert config.api_key == "bedrock-token"
|
|
assert config.base_url == "https://bedrock-mantle.eu-west-1.api.aws/v1"
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
|
|
|
|
def test_azure_openai_provider_config_uses_resource_base_key_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["azure_openai"]
|
|
settings = _make_settings(
|
|
azure_openai_api_keys="azure-token",
|
|
azure_openai_base_url=("https://resource.openai.azure.com/openai/v1/"),
|
|
azure_openai_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
|
|
assert descriptor.default_base_url is None
|
|
assert descriptor.configuration_attrs() == (
|
|
"azure_openai_api_keys",
|
|
"azure_openai_base_url",
|
|
)
|
|
assert config.api_key == "azure-token"
|
|
assert config.base_url == "https://resource.openai.azure.com/openai/v1/"
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
|
|
|
|
def test_azure_openai_provider_config_reports_missing_resource_url() -> None:
|
|
descriptor = PROVIDER_CATALOG["azure_openai"]
|
|
|
|
with pytest.raises(
|
|
ApplicationUnavailableError,
|
|
match="AZURE_OPENAI_BASE_URL is not set",
|
|
):
|
|
build_provider_config(
|
|
descriptor,
|
|
_make_settings(
|
|
azure_openai_api_keys="azure-token",
|
|
azure_openai_base_url="",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider_id", "expected_api_key"),
|
|
[
|
|
("lmstudio", "lm-studio"),
|
|
("llamacpp", "llamacpp"),
|
|
("ollama", "ollama"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async 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.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
provider_id,
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert config.api_key == expected_api_key
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
assert provider._api_key == expected_api_key
|
|
|
|
|
|
def test_zai_coding_descriptor_uses_fixed_cloud_base_url():
|
|
descriptor = PROVIDER_CATALOG["zai"]
|
|
|
|
assert descriptor.display_name == "Z.ai Coding Plan"
|
|
assert descriptor.default_base_url == ZAI_CODING_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_CODING_DEFAULT_BASE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zai_api_provider_config_uses_shared_key_general_base_and_own_proxy():
|
|
descriptor = PROVIDER_CATALOG["zai_api"]
|
|
settings = _make_settings(
|
|
zai_api_keys="shared-zai-key",
|
|
zai_api_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"zai_api",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "Z.ai API"
|
|
assert descriptor.credential_env == "ZAI_API_KEY"
|
|
assert descriptor.credential_attr == "zai_api_keys"
|
|
assert descriptor.default_base_url == ZAI_API_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "zai_api_proxy"
|
|
assert config.api_key == "shared-zai-key"
|
|
assert config.base_url == ZAI_API_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
assert provider._provider_name == "ZAI_API"
|
|
|
|
|
|
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_kimi_code_provider_config_uses_subscription_key_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["kimi_code"]
|
|
settings = _make_settings(
|
|
kimi_code_api_keys="subscription-token",
|
|
kimi_code_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
|
|
assert descriptor.credential_env == "KIMI_CODE_API_KEY"
|
|
assert config.api_key == "subscription-token"
|
|
assert config.base_url == KIMI_CODE_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_cloudflare_provider_uses_account_scoped_base_url():
|
|
settings = _make_settings(
|
|
cloudflare_api_tokens="test_cloudflare_token",
|
|
cloudflare_account_id="test-account",
|
|
)
|
|
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"cloudflare",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert isinstance(provider, CloudflareProvider)
|
|
assert provider._base_url == (
|
|
"https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opencode_zen_provider_config_uses_explicit_id_and_name():
|
|
with patch("httpx.AsyncClient"):
|
|
provider = await create_provider(
|
|
"opencode_zen",
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
assert isinstance(provider, OpenCodeProvider)
|
|
assert str(provider._client.base_url).rstrip("/") == "https://opencode.ai/zen/v1"
|
|
assert provider._opencode_profile.provider_name == "OPENCODE_ZEN"
|
|
assert provider._client.api_key == "test_opencode_key"
|
|
assert provider._responses._admission is provider._chat._admission
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opencode_go_provider_config_uses_correct_base_url_and_name():
|
|
with patch("httpx.AsyncClient"):
|
|
provider = await create_provider(
|
|
"opencode_go",
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
assert isinstance(provider, OpenCodeProvider)
|
|
assert str(provider._client.base_url).rstrip("/") == "https://opencode.ai/zen/go/v1"
|
|
assert provider._opencode_profile.provider_name == "OPENCODE_GO"
|
|
assert provider._client.api_key == "test_opencode_key"
|
|
assert provider._responses._admission is provider._chat._admission
|
|
|
|
|
|
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_keys"
|
|
|
|
|
|
def test_build_provider_config_opencode_go_uses_opencode_api_key() -> None:
|
|
descriptor = PROVIDER_CATALOG["opencode_go"]
|
|
settings = _make_settings(opencode_api_keys="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_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["vercel"]
|
|
settings = _make_settings(
|
|
vercel_ai_gateway_api_keys="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_keys="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_keys="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"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_provider_uses_openai_chat_openrouter_by_default():
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"open_router",
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
assert isinstance(provider, OpenRouterProvider)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_provider_instantiates_each_builtin():
|
|
settings = _make_settings(
|
|
gemini_api_keys="test_gemini_key",
|
|
vertex_project_id="test-vertex-project",
|
|
groq_api_keys="test_groq_key",
|
|
cline_api_keys="test_cline_key",
|
|
cerebras_api_keys="test_cerebras_key",
|
|
fireworks_api_keys="test_fireworks_key",
|
|
novita_api_keys="test_novita_key",
|
|
cloudflare_api_tokens="test_cloudflare_token",
|
|
cloudflare_account_id="test_cloudflare_account",
|
|
vercel_ai_gateway_api_keys="test_vercel_key",
|
|
bedrock_api_keys="test_bedrock_key",
|
|
huggingface_api_keys="test_huggingface_key",
|
|
cohere_api_keys="test_cohere_key",
|
|
kimi_api_keys="test_kimi_key",
|
|
kimi_code_api_keys="test_kimi_code_key",
|
|
provider_rate_limit=7,
|
|
provider_rate_window=11,
|
|
provider_max_concurrency=3,
|
|
sambanova_api_keys="test_sambanova_key",
|
|
)
|
|
cases = {
|
|
"nvidia_nim": NvidiaNimProvider,
|
|
"openai": OpenAICodexProvider,
|
|
"openai_api": OpenAIAPIProvider,
|
|
"github_copilot": GitHubCopilotProvider,
|
|
"cline_pass": OpenAIChatProvider,
|
|
"xai": OpenAIChatProvider,
|
|
"anthropic": AnthropicProvider,
|
|
"alibaba_cloud": AlibabaCloudProvider,
|
|
"qwencloud": OpenAIChatProvider,
|
|
"qwencloud_coding": OpenAIChatProvider,
|
|
"together": OpenAIChatProvider,
|
|
"deepinfra": OpenAIChatProvider,
|
|
"siliconflow": OpenAIChatProvider,
|
|
"nebius": OpenAIChatProvider,
|
|
"scaleway": OpenAIChatProvider,
|
|
"opper": OpenAIChatProvider,
|
|
"chutes": OpenAIChatProvider,
|
|
"featherless": OpenAIChatProvider,
|
|
"azure_openai": OpenAIChatProvider,
|
|
"open_router": OpenRouterProvider,
|
|
"mistral": MistralProvider,
|
|
"mistral_codestral": OpenAIChatProvider,
|
|
"deepseek": DeepSeekProvider,
|
|
"kimi": OpenAIChatProvider,
|
|
"kimi_code": OpenAIChatProvider,
|
|
"minimax": OpenAIChatProvider,
|
|
"fireworks": OpenAIChatProvider,
|
|
"novita": OpenAIChatProvider,
|
|
"cloudflare": CloudflareProvider,
|
|
"lmstudio": LMStudioProvider,
|
|
"llamacpp": OpenAIChatProvider,
|
|
"ollama": OpenAIChatProvider,
|
|
"ollama_cloud": OpenAIChatProvider,
|
|
"wafer": OpenAIChatProvider,
|
|
"opencode_zen": OpenCodeProvider,
|
|
"poolside": OpenAIChatProvider,
|
|
"llm7": OpenAIChatProvider,
|
|
"lightning": OpenAIChatProvider,
|
|
"experiential": OpenAIChatProvider,
|
|
"cheaperinference": OpenAIChatProvider,
|
|
"orcarouter": OpenAIChatProvider,
|
|
"atlascloud": OpenAIChatProvider,
|
|
"xkiro": OpenAIChatProvider,
|
|
"opencode_go": OpenCodeProvider,
|
|
"vercel": OpenAIChatProvider,
|
|
"bedrock": OpenAIChatProvider,
|
|
"huggingface": OpenAIChatProvider,
|
|
"cohere": OpenAIChatProvider,
|
|
"zai": OpenAIChatProvider,
|
|
"zai_api": OpenAIChatProvider,
|
|
"tokenrouter": OpenAIChatProvider,
|
|
"nararoute": OpenAIChatProvider,
|
|
"agnes": OpenAIChatProvider,
|
|
"zenmux": OpenAIChatProvider,
|
|
"wandb": OpenAIChatProvider,
|
|
"gemini": GeminiProvider,
|
|
"vertex": VertexProvider,
|
|
"groq": GroqProvider,
|
|
"sambanova": OpenAIChatProvider,
|
|
"kilo": KiloProvider,
|
|
"cerebras": OpenAIChatProvider,
|
|
}
|
|
sentinel_admission = MagicMock(spec=ProviderAdmissionController)
|
|
auth = MagicMock()
|
|
injected_factories = {
|
|
"openai": lambda config, _settings, admission: OpenAICodexProvider(
|
|
config,
|
|
auth=auth,
|
|
admission=admission,
|
|
),
|
|
"github_copilot": lambda config, _settings, admission: GitHubCopilotProvider(
|
|
config,
|
|
auth=auth,
|
|
admission=admission,
|
|
),
|
|
}
|
|
|
|
with (
|
|
patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"),
|
|
patch("free_claude_code.providers.github_copilot.provider.AsyncOpenAI"),
|
|
patch("free_claude_code.providers.openai_codex.provider.AsyncOpenAI"),
|
|
patch("free_claude_code.providers.openai_api.provider.AsyncOpenAI"),
|
|
patch("httpx.AsyncClient"),
|
|
patch(
|
|
"free_claude_code.providers.admission.ProviderAdmissionController",
|
|
return_value=sentinel_admission,
|
|
) as admission_factory,
|
|
):
|
|
for provider_id, provider_cls in cases.items():
|
|
provider = await create_provider(
|
|
provider_id,
|
|
settings,
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(settings)
|
|
),
|
|
provider_loaders={
|
|
key: lambda factory=factory: factory
|
|
for key, factory in injected_factories.items()
|
|
},
|
|
)
|
|
|
|
assert isinstance(provider, provider_cls)
|
|
if isinstance(provider, OpenCodeProvider):
|
|
assert provider._chat._admission is sentinel_admission
|
|
assert provider._responses._admission is sentinel_admission
|
|
else:
|
|
assert provider._admission is sentinel_admission
|
|
admission_factory.assert_called_once_with(
|
|
provider_name=provider_id,
|
|
rate_limit=7,
|
|
rate_window=11,
|
|
max_concurrency=3,
|
|
capacity=ANY,
|
|
)
|
|
admission_factory.reset_mock()
|
|
|
|
assert set(cases) == set(PROVIDER_CATALOG)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_runtime_caches_by_provider_id():
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
first = await runtime.resolve_provider("nvidia_nim")
|
|
second = await runtime.resolve_provider("nvidia_nim")
|
|
|
|
assert first is second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancelled", [False, True])
|
|
async def test_provider_creation_retries_after_shared_failure(cancelled):
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
provider = MagicMock(cleanup=AsyncMock())
|
|
attempts = 0
|
|
|
|
async def construct(_id, _settings, _admission_registry, _api_key_number):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
attempt = attempts
|
|
entered.set()
|
|
await release.wait()
|
|
if attempt == 1:
|
|
if cancelled:
|
|
raise asyncio.CancelledError()
|
|
raise RuntimeError("construction failed")
|
|
return provider
|
|
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
provider_constructor=construct,
|
|
)
|
|
waiting = []
|
|
try:
|
|
for attempt in (1, 2):
|
|
entered.clear()
|
|
release.clear()
|
|
waiting = [
|
|
asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
for _ in range(3)
|
|
]
|
|
# Give the whole group a chance to join the held constructor.
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
assert attempts == attempt
|
|
assert entered.is_set()
|
|
release.set()
|
|
results = await asyncio.gather(*waiting, return_exceptions=True)
|
|
if attempt == 1:
|
|
error = asyncio.CancelledError if cancelled else RuntimeError
|
|
assert all(isinstance(result, error) for result in results)
|
|
else:
|
|
assert all(result is provider for result in results)
|
|
assert await runtime.resolve_provider("nvidia_nim") is provider
|
|
assert attempts == 2
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(*waiting, return_exceptions=True)
|
|
await runtime.cleanup()
|
|
provider.cleanup.assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finished_creation_callback_cannot_forget_a_new_retry():
|
|
retry_entered, release = asyncio.Event(), asyncio.Event()
|
|
provider = MagicMock(cleanup=AsyncMock())
|
|
attempts = 0
|
|
retry = None
|
|
|
|
async def construct(_id, _settings, _admission_registry, _api_key_number):
|
|
nonlocal attempts, retry
|
|
attempts += 1
|
|
if attempts == 1:
|
|
# Queue a real acquisition ahead of the failed task's callbacks.
|
|
retry = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
raise RuntimeError("construction failed")
|
|
retry_entered.set()
|
|
await release.wait()
|
|
return provider
|
|
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
provider_constructor=construct,
|
|
)
|
|
later = None
|
|
try:
|
|
with pytest.raises(RuntimeError, match="construction failed"):
|
|
await runtime.resolve_provider("nvidia_nim")
|
|
await asyncio.wait_for(retry_entered.wait(), 1)
|
|
later = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
await asyncio.sleep(0)
|
|
release.set()
|
|
assert retry is not None
|
|
assert await retry is provider
|
|
assert await later is provider
|
|
assert attempts == 2
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(
|
|
*(task for task in (retry, later) if task is not None),
|
|
return_exceptions=True,
|
|
)
|
|
await runtime.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_acquisition_keeps_construction_available_to_next_caller():
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
provider = MagicMock(cleanup=AsyncMock())
|
|
attempts = 0
|
|
|
|
async def construct(_id, _settings, _admission_registry, _api_key_number):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
entered.set()
|
|
await release.wait()
|
|
return provider
|
|
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
provider_constructor=construct,
|
|
)
|
|
first = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
second = None
|
|
try:
|
|
await entered.wait()
|
|
first.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first
|
|
second = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
await asyncio.sleep(0)
|
|
release.set()
|
|
assert await second is provider
|
|
assert attempts == 1
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(
|
|
*(task for task in (first, second) if task is not None),
|
|
return_exceptions=True,
|
|
)
|
|
await runtime.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_abandoned_creation_failure_has_no_unretrieved_exception():
|
|
entered, release, failed = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
|
loop = asyncio.get_running_loop()
|
|
previous_handler = loop.get_exception_handler()
|
|
errors = []
|
|
|
|
async def construct(_id, _settings, _admission_registry, _api_key_number):
|
|
entered.set()
|
|
await release.wait()
|
|
failed.set()
|
|
raise RuntimeError("abandoned construction failed")
|
|
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
provider_constructor=construct,
|
|
)
|
|
waiting = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
loop.set_exception_handler(lambda _loop, context: errors.append(context))
|
|
try:
|
|
await entered.wait()
|
|
waiting.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiting
|
|
del waiting
|
|
release.set()
|
|
await failed.wait()
|
|
await asyncio.sleep(0)
|
|
del runtime
|
|
gc.collect()
|
|
# Python 3.14's shield explicitly logs abandoned failures even if observed.
|
|
# It must not also report a task exception that FCC failed to retrieve.
|
|
assert all(
|
|
error["message"] == "RuntimeError exception in shielded future"
|
|
for error in errors
|
|
)
|
|
finally:
|
|
release.set()
|
|
loop.set_exception_handler(previous_handler)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_drains_construction_that_finishes_during_cancellation():
|
|
entered = asyncio.Event()
|
|
provider = MagicMock(cleanup=AsyncMock())
|
|
|
|
async def construct(_id, _settings, _admission_registry, _api_key_number):
|
|
entered.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
return provider
|
|
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
provider_constructor=construct,
|
|
)
|
|
waiting = asyncio.create_task(runtime.resolve_provider("nvidia_nim"))
|
|
try:
|
|
await entered.wait()
|
|
await runtime.cleanup()
|
|
assert await waiting is provider
|
|
provider.cleanup.assert_awaited_once_with()
|
|
with pytest.raises(ApplicationUnavailableError, match="shutting down"):
|
|
await runtime.resolve_provider("nvidia_nim")
|
|
await runtime.cleanup()
|
|
provider.cleanup.assert_awaited_once_with()
|
|
finally:
|
|
await runtime.cleanup()
|
|
await asyncio.gather(waiting, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_runtime_provider_owns_one_admission_controller() -> None:
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
first = await runtime.resolve_provider("nvidia_nim")
|
|
second = await runtime.resolve_provider("nvidia_nim")
|
|
|
|
assert isinstance(first, NvidiaNimProvider)
|
|
assert isinstance(second, NvidiaNimProvider)
|
|
assert first._admission is second._admission
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_separate_provider_runtimes_never_share_admission_controllers() -> None:
|
|
first_runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
second_runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
first = await first_runtime.resolve_provider("nvidia_nim")
|
|
second = await second_runtime.resolve_provider("nvidia_nim")
|
|
|
|
assert isinstance(first, NvidiaNimProvider)
|
|
assert isinstance(second, NvidiaNimProvider)
|
|
assert first is not second
|
|
assert first._admission is not second._admission
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_providers_have_independent_admission_controllers() -> None:
|
|
runtime = ProviderRuntime(
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
)
|
|
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
nim = await runtime.resolve_provider("nvidia_nim")
|
|
open_router = await runtime.resolve_provider("open_router")
|
|
|
|
assert isinstance(nim, NvidiaNimProvider)
|
|
assert isinstance(open_router, OpenRouterProvider)
|
|
assert nim._admission is not open_router._admission
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_provider_raises_unknown_provider_type_error():
|
|
with pytest.raises(UnknownProviderError, match="Unknown provider_type"):
|
|
(
|
|
await create_provider(
|
|
"unknown",
|
|
_make_settings(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_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(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
{("a", 1): p1, ("b", 1): 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(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
{("first", 1): first, ("second", 1): second, ("third", 1): 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(),
|
|
ProviderAdmissionRegistry(
|
|
ProviderAdmissionLimits.from_settings(_make_settings())
|
|
),
|
|
{("x", 1): p1, ("y", 1): 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")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_xkiro_provider_config_uses_key_base_and_proxy() -> None:
|
|
descriptor = PROVIDER_CATALOG["xkiro"]
|
|
settings = _make_settings(
|
|
xkiro_api_keys="xkiro-token",
|
|
xkiro_base_url="https://unused.example/v1",
|
|
xkiro_proxy="http://proxy.test:8080",
|
|
)
|
|
|
|
config = build_provider_config(descriptor, settings)
|
|
with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"):
|
|
provider = await create_provider(
|
|
"xkiro",
|
|
settings,
|
|
ProviderAdmissionRegistry(ProviderAdmissionLimits.from_settings(settings)),
|
|
)
|
|
|
|
assert descriptor.display_name == "xKiro"
|
|
assert descriptor.credential_env == "XKIRO_API_KEY"
|
|
assert descriptor.credential_attr == "xkiro_api_keys"
|
|
assert descriptor.credential_url == "https://xkiro.com/dashboard/api/keys"
|
|
assert descriptor.default_base_url == XKIRO_DEFAULT_BASE
|
|
assert descriptor.base_url_attr is None
|
|
assert descriptor.proxy_attr == "xkiro_proxy"
|
|
assert config.api_key == "xkiro-token"
|
|
assert config.base_url == XKIRO_DEFAULT_BASE
|
|
assert config.proxy == "http://proxy.test:8080"
|
|
assert isinstance(provider, OpenAIChatProvider)
|
|
|
|
|
|
def test_xkiro_requires_key_despite_public_catalog():
|
|
with pytest.raises(ApplicationUnavailableError, match="XKIRO_API_KEY is not set"):
|
|
build_provider_config(
|
|
PROVIDER_CATALOG["xkiro"], _make_settings(xkiro_api_keys=None)
|
|
)
|