项目文件夹

文件
Ali Khokhar af12e7b2bb Coordinate provider recovery under concurrent load (#1205)
## Problem

Concurrent transient failures could start independent retry, replay,
continuation, and repair loops while holding provider concurrency slots.
This multiplied upstream attempts and could delay or strand terminal
errors under fan-out.

## Changes

| Before | After |
| --- | --- |
| Retry paths owned separate attempt budgets. | One logical-execution
session caps all upstream work at five attempts. |
| Concurrent failures backed off independently. | One provider-owned
recovery episode elects a single half-open probe while followers
coalesce. |
| Backoff occupied stream concurrency. | Concurrency is held only while
an upstream operation or stream is active. |
| Provider catalog calls and stream creation used separate admission
paths. | Every upstream operation uses one provider-generation admission
controller. |
| Cancellation could leave recovery ownership or follower state
unresolved. | Cancellation releases permits, transfers probe ownership,
and unregisters waiting followers. |
| Late in-flight failures could cross an exhausted episode boundary. |
Every coalesced execution retains that generation's terminal outcome. |
| Replay tests allowed loose lifecycle assertions. | Exact SSE contracts
prove retries and continuations emit one unduplicated response. |
| Recovery wrappers could mask final diagnostics. | Final responses and
traces retain the raw provider failure and request ID. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR coordinates provider recovery and retry work under concurrent
load. The main changes are:

- One five-attempt budget for each logical execution.
- Provider-wide recovery episodes with one elected probe.
- Shared admission for streams, catalog calls, rate limits, and
concurrency.
- Concurrency permits held only during active upstream work.
- Cancellation-safe probe ownership and preserved final diagnostics.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

No blocking issues found in the changed code.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Reviewed the coordinated-recovery-01-before.log to understand how the
exhausted generation outcome was not preserved in a late in-flight
failure.
- Reviewed the coordinated-recovery-02-after.log to confirm that the
updated implementation preserves the exhausted generation outcome for
the same focused contract set.
- Validated that the provider-admission-full-current.log shows the
complete requested test file passed under Python 3.14 with uv run pytest
-n 0.

<a
href="https://app.greptile.com/trex/runs/15050270/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/providers/admission.py | Adds shared admission,
retry budgets, recovery episodes, probe election, and cancellation
handling. |
| src/free_claude_code/providers/openai_chat/provider.py | Moves stream
creation, replay, continuation, and repair onto one admission-owned
retry session. |
| src/free_claude_code/providers/stream_recovery.py | Selects replay,
continuation, repair, or final failure using the remaining shared
attempt budget. |
| src/free_claude_code/providers/failure_policy.py | Adds recovery
exhaustion handling and preserves the underlying provider error for
final classification. |
| src/free_claude_code/providers/runtime/factory.py | Creates one
admission controller per provider generation and passes it through
provider factories. |

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant E as Execution
participant A as Admission controller
participant P as Provider
participant F as Concurrent follower

E->>A: Open attempt
A->>P: Send upstream request
P-->>E: Retryable failure
E->>A: Open recovery episode
F->>A: Request admission
A-->>F: Coalesce and wait
E->>A: Claim probe
A->>P: Send half-open probe
alt Probe succeeds
    P-->>E: Valid response
    E->>A: Close recovery episode
    A-->>F: Release waiter
else Probe fails
    P-->>E: Retryable failure
    E->>A: Schedule next probe or finalize error
end
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant E as Execution
participant A as Admission controller
participant P as Provider
participant F as Concurrent follower

E->>A: Open attempt
A->>P: Send upstream request
P-->>E: Retryable failure
E->>A: Open recovery episode
F->>A: Request admission
A-->>F: Coalesce and wait
E->>A: Claim probe
A->>P: Send half-open probe
alt Probe succeeds
    P-->>E: Valid response
    E->>A: Close recovery episode
    A-->>F: Release waiter
else Probe fails
    P-->>E: Retryable failure
    E->>A: Schedule next probe or finalize error
end
```

</a>
</details>

<sub>Reviews (2): Last reviewed commit: ["Harden coordinated retry
lifecycle
invar..."](https://github.com/alishahryar1/free-claude-code/commit/2e871c8649d148b5eb71d21f80bf870ae2d11708)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45554917)</sub>

<!-- /greptile_comment -->
2026-07-19 22:43:29 -07:00

613 行
21 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 (
BEDROCK_DEFAULT_BASE,
COHERE_DEFAULT_BASE,
GITHUB_MODELS_DEFAULT_BASE,
HUGGINGFACE_DEFAULT_BASE,
KIMI_CODE_DEFAULT_BASE,
MINIMAX_DEFAULT_BASE,
OLLAMA_CLOUD_DEFAULT_BASE,
PROVIDER_CATALOG,
SUPPORTED_PROVIDER_IDS,
VERCEL_AI_GATEWAY_DEFAULT_BASE,
ZAI_DEFAULT_BASE,
)
from free_claude_code.providers.admission import ProviderAdmissionController
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.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.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.bedrock_api_key = "test_bedrock_key"
mock.bedrock_base_url = BEDROCK_DEFAULT_BASE
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.ollama_api_key = "test_ollama_cloud_key"
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.kimi_code_proxy = ""
mock.kimi_code_api_key = "test_kimi_code_key"
mock.wafer_proxy = ""
mock.minimax_proxy = ""
mock.opencode_proxy = ""
mock.opencode_go_proxy = ""
mock.vercel_ai_gateway_proxy = ""
mock.bedrock_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.vertex_project_id = "test-vertex-project"
mock.vertex_location = "global"
mock.vertex_proxy = ""
mock.groq_api_key = ""
mock.groq_proxy = ""
mock.cerebras_api_key = ""
mock.cerebras_proxy = ""
mock.ollama_cloud_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.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
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_key"
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_key="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"
def test_bedrock_provider_config_uses_regional_base_key_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["bedrock"]
settings = _make_settings(
bedrock_api_key="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"
@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_kimi_code_provider_config_uses_subscription_key_and_proxy() -> None:
descriptor = PROVIDER_CATALOG["kimi_code"]
settings = _make_settings(
kimi_code_api_key="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
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",
vertex_project_id="test-vertex-project",
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",
bedrock_api_key="test_bedrock_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",
kimi_code_api_key="test_kimi_code_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,
"kimi_code": OpenAIChatProvider,
"minimax": OpenAIChatProvider,
"fireworks": OpenAIChatProvider,
"cloudflare": CloudflareProvider,
"lmstudio": LMStudioProvider,
"llamacpp": OpenAIChatProvider,
"ollama": OpenAIChatProvider,
"ollama_cloud": OpenAIChatProvider,
"wafer": OpenAIChatProvider,
"opencode": OpenAIChatProvider,
"opencode_go": OpenAIChatProvider,
"vercel": OpenAIChatProvider,
"bedrock": OpenAIChatProvider,
"huggingface": OpenAIChatProvider,
"cohere": OpenAIChatProvider,
"github_models": GitHubModelsProvider,
"zai": OpenAIChatProvider,
"gemini": GeminiProvider,
"vertex": VertexProvider,
"groq": OpenAIChatProvider,
"sambanova": OpenAIChatProvider,
"cerebras": OpenAIChatProvider,
}
sentinel_admission = MagicMock(spec=ProviderAdmissionController)
with (
patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"),
patch("httpx.AsyncClient"),
patch(
"free_claude_code.providers.runtime.factory.ProviderAdmissionController",
return_value=sentinel_admission,
) as admission_factory,
):
for provider_id, provider_cls in cases.items():
provider = create_provider(provider_id, settings)
assert isinstance(provider, provider_cls)
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,
)
admission_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_admission_controller() -> 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._admission is second._admission
def test_separate_provider_runtimes_never_share_admission_controllers() -> 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._admission is not second._admission
def test_different_providers_have_independent_admission_controllers() -> 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._admission is not open_router._admission
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")