diff --git a/src/plyngent/cli/models_source.py b/src/plyngent/cli/models_source.py index 5415664..dbcc494 100644 --- a/src/plyngent/cli/models_source.py +++ b/src/plyngent/cli/models_source.py @@ -76,11 +76,19 @@ async def fetch_remote_model_ids( raise TypeError(msg) result = method() if inspect.isawaitable(result): - if timeout_seconds > 0: - async with asyncio.timeout(timeout_seconds): + try: + if timeout_seconds > 0: + async with asyncio.timeout(timeout_seconds): + result = await result + else: result = await result - else: - result = await result + except TimeoutError as exc: + msg = f"models() timed out after {timeout_seconds}s" + raise RuntimeError(msg) from exc + except asyncio.CancelledError as exc: + # SSL/HTTP stacks may surface cancel instead of TimeoutError; treat as soft fail. + msg = "models() was cancelled or timed out" + raise RuntimeError(msg) from exc if not isinstance(result, list): msg = f"models() returned unexpected type {type(result)!r}" raise TypeError(msg) diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index df7b63a..b2471d5 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -305,7 +305,11 @@ class ReplState: return list(ids) async def merged_model_choices(self, *, refresh: bool = False) -> list[str]: - """Config plus remote catalog; remote fetch best-effort when refresh/missing.""" + """Config plus remote catalog; remote fetch best-effort when refresh/missing. + + Network / timeout / cancel failures fall back to cache or config-only ids + so ``/provider`` switches never hang or fail solely on GET /models. + """ remote: list[str] | None try: remote = await self.ensure_remote_models(refresh=refresh) diff --git a/tests/test_cli/test_models_source.py b/tests/test_cli/test_models_source.py index d96dde4..7b4b7c8 100644 --- a/tests/test_cli/test_models_source.py +++ b/tests/test_cli/test_models_source.py @@ -67,7 +67,7 @@ async def test_fetch_remote_model_ids_timeout() -> None: await asyncio.sleep(10) return ["late"] - with pytest.raises(TimeoutError): + with pytest.raises(RuntimeError, match="timed out"): _ = await fetch_remote_model_ids(Slow(), timeout_seconds=0.05) diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index a724fe6..c502324 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -234,6 +234,13 @@ async def test_provider_switch_prompts_when_model_missing( state.model = "only-a" state.rebuild_client() + # No real GET /models — catalog is config-only for unit tests. + async def _no_remote(*, refresh: bool = False) -> list[str]: + del refresh + return [] + + monkeypatch.setattr(state, "ensure_remote_models", _no_remote) + # When switching to b, only-a is missing → select_model is invoked interactively. monkeypatch.setattr( "plyngent.cli.slash.select_model", @@ -250,7 +257,10 @@ async def test_provider_switch_prompts_when_model_missing( assert row.model == "only-b" -async def test_provider_switch_keeps_shared_model(state: ReplState) -> None: +async def test_provider_switch_keeps_shared_model( + state: ReplState, + monkeypatch: pytest.MonkeyPatch, +) -> None: from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider state.config.providers = { @@ -268,6 +278,12 @@ async def test_provider_switch_keeps_shared_model(state: ReplState) -> None: state.provider = state.config.providers["a"] state.model = "shared" state.rebuild_client() + + async def _no_remote(*, refresh: bool = False) -> list[str]: + del refresh + return [] + + monkeypatch.setattr(state, "ensure_remote_models", _no_remote) assert await handle_slash(state, "/provider b") is True assert state.provider_name == "b" assert state.model == "shared"