core/config+cli: empty models are bad; prompt model on provider switch

Reject providers with no models into bad_providers and warn with reasons
on chat/providers. When /provider changes and the current model is not
listed, prompt for a new model (keep it when still supported).
This commit is contained in:
2026-07-15 14:17:34 +08:00
parent 64d288a9b1
commit 553d491cbc
7 changed files with 155 additions and 10 deletions
+61
View File
@@ -190,6 +190,67 @@ async def test_model_switch_persists(state: ReplState) -> None:
assert row.model == "m2"
async def test_provider_switch_prompts_when_model_missing(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
state.config.providers = {
"a": OpenAIProvider(
access_key_or_token="sk",
models={"shared": ModelConfig(), "only-a": ModelConfig()},
),
"b": OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"shared": ModelConfig(), "only-b": ModelConfig()},
),
}
state.provider_name = "a"
state.provider = state.config.providers["a"]
state.model = "only-a"
state.rebuild_client()
# When switching to b, only-a is missing → select_model is invoked interactively.
monkeypatch.setattr(
"plyngent.cli.slash.select_model",
lambda provider, preferred=None, interactive=True: "only-b",
)
assert await handle_slash(state, "/provider b") is True
assert state.provider_name == "b"
assert state.model == "only-b"
sid = state.session_id
assert sid is not None
row = await state.memory.get_session(sid)
assert row is not None
assert row.provider_name == "b"
assert row.model == "only-b"
async def test_provider_switch_keeps_shared_model(state: ReplState) -> None:
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
state.config.providers = {
"a": OpenAIProvider(
access_key_or_token="sk",
models={"shared": ModelConfig()},
),
"b": OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"shared": ModelConfig()},
),
}
state.provider_name = "a"
state.provider = state.config.providers["a"]
state.model = "shared"
state.rebuild_client()
assert await handle_slash(state, "/provider b") is True
assert state.provider_name == "b"
assert state.model == "shared"
async def test_delete_slash_confirm(
state: ReplState,
capsys: pytest.CaptureFixture[str],
+16 -2
View File
@@ -52,9 +52,23 @@ def test_select_provider_interactive_choose() -> None:
from tests.test_prompting import ScriptedBackend
providers = {
"a": OpenAIProvider(access_key_or_token="sk"),
"b": OpenAICompatibleProvider(access_key_or_token="sk", url="https://x/v1"),
"a": OpenAIProvider(access_key_or_token="sk", models={"m": ModelConfig()}),
"b": OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"m": ModelConfig()},
),
}
with temporary_backend(ScriptedBackend(["2"])):
name, _ = select_provider(providers)
assert name == "b"
def test_select_model_when_preferred_missing_raises() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"m1": ModelConfig()},
)
with pytest.raises(Exception, match="unknown model"):
_ = select_model(provider, preferred="nope")
+6
View File
@@ -6,6 +6,9 @@ url = ":memory:"
preset = "openai"
access_key_or_token = "sk-1145141919810"
[providers.test1.models]
"gpt-test" = { text = true }
[providers.test2]
preset = "openai-compatible"
url = "https://www.example.com/v1"
@@ -18,6 +21,9 @@ access_key_or_token = "sk-1145141919810"
preset = "anthropic"
access_key_or_token = "sk-1145141919810"
[providers.test3.models]
"claude-test" = { text = true }
[providers.foo1]
preset = "deepseek"
access_key_or_token = "sk-1145141919810"
+23 -1
View File
@@ -81,6 +81,23 @@ def test_read_bad_config() -> None:
assert isinstance(config.bad_providers, Mapping)
def test_provider_with_empty_models_is_bad(tmp_path: Path) -> None:
path = tmp_path / "empty-models.toml"
_ = path.write_text(
"""
[providers.hollow]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk-test"
models = {}
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "hollow" not in config.providers
assert "hollow" in config.bad_providers
def test_read_invalid_config() -> None:
with pytest.raises(ConfigFormatError):
_ = plyngent.config.load(Path(__file__).parent / "plyngent-invalid.toml")
@@ -91,8 +108,13 @@ def test_write_new_config() -> None:
file.unlink(missing_ok=True)
config = plyngent.config.load(file)
assert isinstance(config.providers, Mapping)
from plyngent.config import ModelConfig
config.providers = {
"foo1": OpenAIProvider(access_key_or_token="sk-00301212"),
"foo1": OpenAIProvider(
access_key_or_token="sk-00301212",
models={"gpt-test": ModelConfig()},
),
"foo2": DeepseekProvider(access_key_or_token="sk-00301212"),
}
assert isinstance(config.providers, Mapping)