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

541 lines
18 KiB
Python

import asyncio
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.application.model_metadata import ProviderModelInfo
from free_claude_code.config.nim import NimSettings
from free_claude_code.config.provider_catalog import (
DEEPSEEK_DEFAULT_BASE,
NVIDIA_NIM_DEFAULT_BASE,
OPENROUTER_DEFAULT_BASE,
WAFER_DEFAULT_BASE,
)
from free_claude_code.config.settings import Settings
from free_claude_code.providers.base import BaseProvider, ProviderConfig
from free_claude_code.providers.deepseek import DeepSeekProvider
from free_claude_code.providers.model_listing import ModelListResponseError
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 OpenAIChatProvider
from free_claude_code.providers.runtime import ProviderRuntime
from free_claude_code.providers.runtime.model_cache import ProviderModelCache
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.providers.support import passthrough_rate_limiter, profiled_provider
def _settings(
*,
model: str = "nvidia_nim/nim-model",
model_opus: str | None = None,
model_sonnet: str | None = None,
model_haiku: str | None = None,
nvidia_nim_api_key: str = "",
open_router_api_key: str = "",
deepseek_api_key: str = "",
wafer_api_key: str = "",
opencode_api_key: str = "",
zai_api_key: str = "",
) -> Settings:
return Settings.model_construct(
model=model,
model_opus=model_opus,
model_sonnet=model_sonnet,
model_haiku=model_haiku,
nvidia_nim_api_key=nvidia_nim_api_key,
open_router_api_key=open_router_api_key,
deepseek_api_key=deepseek_api_key,
wafer_api_key=wafer_api_key,
opencode_api_key=opencode_api_key,
zai_api_key=zai_api_key,
log_api_error_tracebacks=False,
)
def _manager(
settings: Settings,
providers: dict[str, BaseProvider] | None = None,
) -> ProviderRuntimeManager:
providers = providers or {}
return ProviderRuntimeManager(
settings,
runtime_factory=lambda snapshot: ProviderRuntime(snapshot, dict(providers)),
)
@pytest.mark.asyncio
async def test_nim_lists_openai_compatible_model_ids() -> None:
config = ProviderConfig(api_key="test-key", base_url=NVIDIA_NIM_DEFAULT_BASE)
with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"):
provider = NvidiaNimProvider(
config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter()
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(data=[SimpleNamespace(id="nvidia/model")]),
):
assert await provider.list_model_ids() == frozenset({"nvidia/model"})
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider",
[
profiled_provider(
"llamacpp",
ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"),
rate_limiter=passthrough_rate_limiter(),
),
profiled_provider(
"ollama",
ProviderConfig(api_key="ollama", base_url="http://localhost:11434"),
rate_limiter=passthrough_rate_limiter(),
),
],
)
async def test_local_openai_chat_providers_list_model_ids(
provider: OpenAIChatProvider,
) -> None:
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(data=[SimpleNamespace(id="local/model")]),
) as mock_list:
assert await provider.list_model_ids() == frozenset({"local/model"})
mock_list.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_deepseek_lists_models_from_root_endpoint() -> None:
provider = DeepSeekProvider(
ProviderConfig(api_key="deepseek-key", base_url=DEEPSEEK_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(data=[SimpleNamespace(id="deepseek-chat")]),
) as mock_list:
assert await provider.list_model_ids() == frozenset({"deepseek-chat"})
mock_list.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_wafer_lists_models_from_default_models_endpoint() -> None:
provider = profiled_provider(
"wafer",
ProviderConfig(api_key="wafer-key", base_url=WAFER_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(data=[SimpleNamespace(id="DeepSeek-V4-Pro")]),
) as mock_list:
assert await provider.list_model_ids() == frozenset({"DeepSeek-V4-Pro"})
mock_list.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_openrouter_lists_only_tool_capable_models() -> None:
provider = OpenRouterProvider(
ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(
data=[
SimpleNamespace(
id="tool-model",
supported_parameters=["tools", "max_tokens"],
),
SimpleNamespace(
id="tool-choice-model",
supported_parameters=["tool_choice"],
),
SimpleNamespace(
id="chat-only",
supported_parameters=["max_tokens", "temperature"],
),
SimpleNamespace(id="missing-metadata", supported_parameters=None),
]
),
) as mock_list:
assert await provider.list_model_ids() == frozenset(
{"tool-model", "tool-choice-model"}
)
mock_list.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_openrouter_lists_tool_metadata_with_thinking_support() -> None:
provider = OpenRouterProvider(
ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(
data=[
SimpleNamespace(
id="reasoning-tool-model",
supported_parameters=[
"tools",
"reasoning",
"include_reasoning",
],
),
SimpleNamespace(
id="plain-tool-model",
supported_parameters=["tool_choice", "include_reasoning"],
),
SimpleNamespace(
id="chat-only",
supported_parameters=["reasoning", "max_tokens"],
),
]
),
):
infos = await provider.list_model_infos()
assert infos == frozenset(
{
ProviderModelInfo("reasoning-tool-model", supports_thinking=True),
ProviderModelInfo("plain-tool-model", supports_thinking=False),
}
)
@pytest.mark.asyncio
async def test_openrouter_lists_empty_set_when_no_tool_capable_models() -> None:
provider = OpenRouterProvider(
ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(
data=[
SimpleNamespace(id="chat-only", supported_parameters=["max_tokens"]),
SimpleNamespace(id="missing-metadata", supported_parameters=None),
]
),
):
assert await provider.list_model_ids() == frozenset()
@pytest.mark.asyncio
async def test_openrouter_model_metadata_rejects_malformed_ids() -> None:
provider = OpenRouterProvider(
ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE),
rate_limiter=passthrough_rate_limiter(),
)
with (
patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(
data=[SimpleNamespace(supported_parameters=["tools", "reasoning"])]
),
),
pytest.raises(ModelListResponseError, match="malformed"),
):
await provider.list_model_infos()
@pytest.mark.asyncio
async def test_model_listing_rejects_malformed_payload() -> None:
provider = profiled_provider(
"llamacpp",
ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"),
rate_limiter=passthrough_rate_limiter(),
)
with (
patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
return_value=SimpleNamespace(data=[SimpleNamespace()]),
),
pytest.raises(ModelListResponseError, match="malformed"),
):
await provider.list_model_ids()
@pytest.mark.asyncio
async def test_model_listing_propagates_upstream_errors() -> None:
provider = profiled_provider(
"llamacpp",
ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"),
rate_limiter=passthrough_rate_limiter(),
)
with (
patch.object(
provider._client.models,
"list",
new_callable=AsyncMock,
side_effect=RuntimeError("upstream unavailable"),
),
pytest.raises(RuntimeError, match="upstream unavailable"),
):
await provider.list_model_ids()
class FakeProvider(BaseProvider):
def __init__(
self,
model_ids: frozenset[str] | None = None,
*,
model_infos: frozenset[ProviderModelInfo] | None = None,
error: BaseException | None = None,
started: asyncio.Event | None = None,
peer_started: asyncio.Event | None = None,
):
super().__init__(
ProviderConfig(api_key="test", base_url="https://test.invalid")
)
self._model_ids = model_ids or frozenset()
self._model_infos = model_infos
self._error = error
self._started = started
self._peer_started = peer_started
self.cleaned = False
def preflight_stream(
self, request: Any, *, thinking_enabled: bool | None = None
) -> None:
return None
async def cleanup(self) -> None:
self.cleaned = True
async def _before_model_list(self) -> None:
if self._started is not None:
self._started.set()
if self._peer_started is not None:
await self._peer_started.wait()
if self._error is not None:
raise self._error
async def list_model_ids(self) -> frozenset[str]:
await self._before_model_list()
if self._model_infos is not None:
return frozenset(info.model_id for info in self._model_infos)
return self._model_ids
async def list_model_infos(self) -> frozenset[ProviderModelInfo]:
await self._before_model_list()
if self._model_infos is not None:
return self._model_infos
return frozenset(ProviderModelInfo(model_id) for model_id in self._model_ids)
async def stream_response(
self,
request: Any,
input_tokens: int = 0,
*,
request_id: str | None = None,
thinking_enabled: bool | None = None,
) -> AsyncIterator[str]:
if False:
yield ""
@pytest.mark.asyncio
async def test_runtime_validation_succeeds_for_all_configured_models() -> None:
settings = _settings(model_opus="open_router/anthropic/claude-opus")
runtime = _manager(
settings,
{
"nvidia_nim": FakeProvider(frozenset({"nim-model"})),
"open_router": FakeProvider(frozenset({"anthropic/claude-opus"})),
},
)
await runtime.validate_configured_models()
assert runtime.cached_model_ids() == {
"nvidia_nim": frozenset({"nim-model"}),
"open_router": frozenset({"anthropic/claude-opus"}),
}
@pytest.mark.asyncio
async def test_runtime_validation_reports_missing_model_with_sources() -> None:
settings = _settings(model_sonnet="nvidia_nim/nim-model")
runtime = _manager(
settings,
{"nvidia_nim": FakeProvider(frozenset({"different-model"}))},
)
with pytest.raises(ApplicationUnavailableError) as exc_info:
await runtime.validate_configured_models()
message = exc_info.value.message
assert "sources=MODEL,MODEL_SONNET" in message
assert "provider=nvidia_nim" in message
assert "model=nim-model" in message
assert "problem=missing model" in message
@pytest.mark.asyncio
async def test_runtime_validation_aggregates_multiple_failures() -> None:
settings = _settings(model_opus="open_router/anthropic/claude-opus")
runtime = _manager(
settings,
{
"nvidia_nim": FakeProvider(frozenset({"different-model"})),
"open_router": FakeProvider(
error=ModelListResponseError("bad model-list shape")
),
},
)
with pytest.raises(ApplicationUnavailableError) as exc_info:
await runtime.validate_configured_models()
message = exc_info.value.message
assert "sources=MODEL provider=nvidia_nim model=nim-model" in message
assert "problem=missing model" in message
assert "sources=MODEL_OPUS provider=open_router model=anthropic/claude-opus" in (
message
)
assert "problem=malformed model-list response" in message
@pytest.mark.asyncio
async def test_runtime_validation_queries_providers_concurrently() -> None:
nim_started = asyncio.Event()
router_started = asyncio.Event()
settings = _settings(model_opus="open_router/anthropic/claude-opus")
runtime = _manager(
settings,
{
"nvidia_nim": FakeProvider(
frozenset({"nim-model"}),
started=nim_started,
peer_started=router_started,
),
"open_router": FakeProvider(
frozenset({"anthropic/claude-opus"}),
started=router_started,
peer_started=nim_started,
),
},
)
await asyncio.wait_for(runtime.validate_configured_models(), timeout=1.0)
@pytest.mark.asyncio
async def test_runtime_refresh_model_list_cache_uses_configured_remote_keys_and_referenced_local() -> (
None
):
settings = _settings(
model="lmstudio/local-qwen",
open_router_api_key="open-router-key",
)
runtime = _manager(
settings,
{
"open_router": FakeProvider(frozenset({"anthropic/claude-sonnet"})),
"lmstudio": FakeProvider(frozenset({"local-qwen"})),
"ollama": FakeProvider(frozenset({"llama3.1"})),
},
)
await runtime.refresh_model_list_cache()
assert runtime.cached_model_ids() == {
"open_router": frozenset({"anthropic/claude-sonnet"}),
"lmstudio": frozenset({"local-qwen"}),
}
@pytest.mark.asyncio
async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -> None:
settings = _settings(
model="nvidia_nim/cached-model",
nvidia_nim_api_key="nim-key",
)
runtime = _manager(
settings,
{"nvidia_nim": FakeProvider(error=RuntimeError("upstream down"))},
)
runtime.cache_model_infos(
"nvidia_nim",
{ProviderModelInfo("cached-model")},
)
await runtime.refresh_model_list_cache()
assert runtime.cached_model_ids() == {"nvidia_nim": frozenset({"cached-model"})}
def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None:
cache = ProviderModelCache()
cache.cache_model_infos(
"open_router",
{
ProviderModelInfo("reasoning-model", supports_thinking=True),
ProviderModelInfo("plain-model", supports_thinking=False),
},
)
assert cache.cached_model_ids() == {
"open_router": frozenset({"reasoning-model", "plain-model"})
}
assert (
cache.cached_model_supports_thinking("open_router", "reasoning-model") is True
)
assert cache.cached_model_supports_thinking("open_router", "plain-model") is False
assert cache.cached_prefixed_model_infos() == (
ProviderModelInfo("open_router/plain-model", supports_thinking=False),
ProviderModelInfo("open_router/reasoning-model", supports_thinking=True),
)
def test_runtime_model_id_cache_keeps_unknown_thinking_support() -> None:
cache = ProviderModelCache()
cache.cache_model_ids("open_router", {"plain-model"})
assert cache.cached_model_ids() == {"open_router": frozenset({"plain-model"})}
assert cache.cached_model_supports_thinking("open_router", "plain-model") is None
assert cache.cached_prefixed_model_infos() == (
ProviderModelInfo("open_router/plain-model", supports_thinking=None),
)
def test_runtime_cached_prefixed_model_refs_are_deterministic() -> None:
cache = ProviderModelCache()
cache.cache_model_ids("deepseek", {"deepseek-chat"})
cache.cache_model_ids("open_router", {"z-model", "a-model"})
assert cache.cached_prefixed_model_refs() == (
"open_router/a-model",
"open_router/z-model",
"deepseek/deepseek-chat",
)