mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: soft-fail GET /models cancel/timeout on provider switch
Map CancelledError and TimeoutError from models() to RuntimeError so merged_model_choices falls back to config/cache. Stub remote fetch in /provider unit tests so they never hit the network.
This commit is contained in:
@@ -76,11 +76,19 @@ async def fetch_remote_model_ids(
|
|||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
result = method()
|
result = method()
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
if timeout_seconds > 0:
|
try:
|
||||||
async with asyncio.timeout(timeout_seconds):
|
if timeout_seconds > 0:
|
||||||
|
async with asyncio.timeout(timeout_seconds):
|
||||||
|
result = await result
|
||||||
|
else:
|
||||||
result = await result
|
result = await result
|
||||||
else:
|
except TimeoutError as exc:
|
||||||
result = await result
|
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):
|
if not isinstance(result, list):
|
||||||
msg = f"models() returned unexpected type {type(result)!r}"
|
msg = f"models() returned unexpected type {type(result)!r}"
|
||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
|
|||||||
@@ -305,7 +305,11 @@ class ReplState:
|
|||||||
return list(ids)
|
return list(ids)
|
||||||
|
|
||||||
async def merged_model_choices(self, *, refresh: bool = False) -> list[str]:
|
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
|
remote: list[str] | None
|
||||||
try:
|
try:
|
||||||
remote = await self.ensure_remote_models(refresh=refresh)
|
remote = await self.ensure_remote_models(refresh=refresh)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async def test_fetch_remote_model_ids_timeout() -> None:
|
|||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
return ["late"]
|
return ["late"]
|
||||||
|
|
||||||
with pytest.raises(TimeoutError):
|
with pytest.raises(RuntimeError, match="timed out"):
|
||||||
_ = await fetch_remote_model_ids(Slow(), timeout_seconds=0.05)
|
_ = await fetch_remote_model_ids(Slow(), timeout_seconds=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,13 @@ async def test_provider_switch_prompts_when_model_missing(
|
|||||||
state.model = "only-a"
|
state.model = "only-a"
|
||||||
state.rebuild_client()
|
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.
|
# When switching to b, only-a is missing → select_model is invoked interactively.
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"plyngent.cli.slash.select_model",
|
"plyngent.cli.slash.select_model",
|
||||||
@@ -250,7 +257,10 @@ async def test_provider_switch_prompts_when_model_missing(
|
|||||||
assert row.model == "only-b"
|
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
|
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
||||||
|
|
||||||
state.config.providers = {
|
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.provider = state.config.providers["a"]
|
||||||
state.model = "shared"
|
state.model = "shared"
|
||||||
state.rebuild_client()
|
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 await handle_slash(state, "/provider b") is True
|
||||||
assert state.provider_name == "b"
|
assert state.provider_name == "b"
|
||||||
assert state.model == "shared"
|
assert state.model == "shared"
|
||||||
|
|||||||
Reference in New Issue
Block a user