diff --git a/src/llm/providers/astraflow.py b/src/llm/providers/astraflow.py index 2bcc6d6b2..270c4951b 100644 --- a/src/llm/providers/astraflow.py +++ b/src/llm/providers/astraflow.py @@ -16,6 +16,7 @@ from llm.core.interface import ( ) from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.model_config import read_model_env from llm.providers.openai_compat import to_openai_message_param ASTRAFLOW_BASE_URL = "https://api.umodelverse.ai/v1" @@ -54,8 +55,8 @@ class _AstraflowBaseProvider(LLMProvider): ) -> None: self.api_key = api_key or os.environ.get(self.api_key_env) or "" self.base_url = base_url or os.environ.get(self.base_url_env, self.default_base_url) - env_model = os.environ.get(self.model_env) - fallback_model = os.environ.get(self.fallback_model_env) if self.fallback_model_env else None + env_model = read_model_env(self.model_env) + fallback_model = read_model_env(self.fallback_model_env) if self.fallback_model_env else None self.default_model = default_model or env_model or fallback_model or DEFAULT_ASTRAFLOW_MODEL self.client = OpenAI(api_key=self.api_key, base_url=self.base_url, _enforce_credentials=False) self._models = [ diff --git a/src/llm/providers/atlas.py b/src/llm/providers/atlas.py index d626509cc..d5a93889e 100644 --- a/src/llm/providers/atlas.py +++ b/src/llm/providers/atlas.py @@ -16,6 +16,7 @@ from llm.core.interface import ( ) from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.model_config import read_model_env from llm.providers.openai_compat import to_openai_message_param ATLAS_BASE_URL = "https://api.atlascloud.ai/v1" @@ -68,7 +69,7 @@ class AtlasProvider(LLMProvider): or "" ) self.base_url = base_url or os.environ.get(self.base_url_env, self.default_base_url) - env_model = os.environ.get(self.model_env) + env_model = read_model_env(self.model_env) self.default_model = default_model or env_model or DEFAULT_ATLAS_MODEL self.client = OpenAI(api_key=self.api_key, base_url=self.base_url, _enforce_credentials=False) self._models = [ diff --git a/src/llm/providers/claude.py b/src/llm/providers/claude.py index 20b45aedc..d62e353fa 100644 --- a/src/llm/providers/claude.py +++ b/src/llm/providers/claude.py @@ -26,8 +26,14 @@ def _uses_adaptive_thinking_only(model: str) -> bool: class ClaudeProvider(LLMProvider): provider_type = ProviderType.CLAUDE - def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None: + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + default_model: str | None = None, + ) -> None: self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url) + self.default_model = default_model or _DEFAULT_MODEL self._models = [ ModelInfo( name="claude-opus-4-8", @@ -57,7 +63,7 @@ class ClaudeProvider(LLMProvider): def generate(self, input: LLMInput) -> LLMOutput: try: - model = input.model or _DEFAULT_MODEL + model = input.model or self.default_model system_parts = [msg.content for msg in input.messages if msg.role == Role.SYSTEM] api_messages = [ msg.to_dict() for msg in input.messages if msg.role not in (Role.SYSTEM,) @@ -139,4 +145,4 @@ class ClaudeProvider(LLMProvider): return bool(self.client.api_key) def get_default_model(self) -> str: - return _DEFAULT_MODEL + return self.default_model diff --git a/src/llm/providers/model_config.py b/src/llm/providers/model_config.py new file mode 100644 index 000000000..bb14917e5 --- /dev/null +++ b/src/llm/providers/model_config.py @@ -0,0 +1,11 @@ +"""Model environment parsing shared by the resolver and provider adapters.""" + +import os + + +def read_model_env(name: str) -> str | None: + """Read a model ID, treating surrounding whitespace and quotes as config syntax.""" + value = os.environ.get(name, "").strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1].strip() + return value or None diff --git a/src/llm/providers/ollama.py b/src/llm/providers/ollama.py index e0669563c..4571fdb73 100644 --- a/src/llm/providers/ollama.py +++ b/src/llm/providers/ollama.py @@ -25,6 +25,7 @@ from llm.core.types import ( Role, ToolCall, ) +from llm.providers.model_config import read_model_env from llm.providers.reasoning import prefilled_reasoning_from_env, strip_reasoning logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ class OllamaProvider(LLMProvider): else prefilled_reasoning_from_env("OLLAMA_PREFILLED_REASONING") ) self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") - self.default_model = default_model or os.environ.get("OLLAMA_MODEL", "llama3.2") + self.default_model = default_model or read_model_env("OLLAMA_MODEL") or "llama3.2" self._models = [ ModelInfo( name="llama3.2", diff --git a/src/llm/providers/openai.py b/src/llm/providers/openai.py index 34b21a8e5..168786ea9 100644 --- a/src/llm/providers/openai.py +++ b/src/llm/providers/openai.py @@ -33,6 +33,7 @@ class OpenAIProvider(LLMProvider): self, api_key: str | None = None, base_url: str | None = None, + default_model: str | None = None, prefilled_reasoning: bool | None = None, leading_reasoning: bool | None = None, ) -> None: @@ -55,6 +56,7 @@ class OpenAIProvider(LLMProvider): base_url=base_url, _enforce_credentials=False, ) + self.default_model = default_model or "gpt-4o-mini" self._models = [ ModelInfo( name="gpt-4o", @@ -93,7 +95,7 @@ class OpenAIProvider(LLMProvider): def generate(self, input: LLMInput) -> LLMOutput: try: params: dict[str, Any] = { - "model": input.model or "gpt-4o-mini", + "model": input.model or self.default_model, "messages": [to_openai_message_param(msg) for msg in input.messages], "temperature": input.temperature, } @@ -154,4 +156,4 @@ class OpenAIProvider(LLMProvider): return bool(self.client.api_key) def get_default_model(self) -> str: - return "gpt-4o-mini" + return self.default_model diff --git a/src/llm/providers/resolver.py b/src/llm/providers/resolver.py index 4156e4fb7..3b73a1c99 100644 --- a/src/llm/providers/resolver.py +++ b/src/llm/providers/resolver.py @@ -10,6 +10,7 @@ from llm.core.types import ProviderType from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider from llm.providers.atlas import AtlasProvider from llm.providers.claude import ClaudeProvider +from llm.providers.model_config import read_model_env from llm.providers.ollama import OllamaProvider from llm.providers.openai import OpenAIProvider @@ -22,6 +23,16 @@ _PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = { ProviderType.OLLAMA: OllamaProvider, } +# Only built-in adapters have a known default_model constructor contract. +_MODEL_ENV_VARS: dict[type[LLMProvider], tuple[str, ...]] = { + ClaudeProvider: (), + OpenAIProvider: (), + OllamaProvider: ("OLLAMA_MODEL",), + AtlasProvider: (AtlasProvider.model_env,), + AstraflowProvider: (AstraflowProvider.model_env,), + AstraflowCNProvider: (AstraflowCNProvider.model_env, AstraflowProvider.model_env), +} + LLM_ENV_FILE = ".llm.env" @@ -47,20 +58,48 @@ def _read_saved_llm_config(env_path: str | Path = LLM_ENV_FILE) -> dict[str, str return config -def _resolve_provider_type(provider_type: ProviderType | str | None) -> ProviderType | str: +def _resolve_provider_config( + provider_type: ProviderType | str | None, +) -> tuple[ProviderType | str, str | None]: if provider_type is not None: - return provider_type + return provider_type, None env_provider = os.environ.get("LLM_PROVIDER") if env_provider: - return _strip_env_value(env_provider).lower() + return _strip_env_value(env_provider).lower(), None saved_config = _read_saved_llm_config() - return saved_config.get("LLM_PROVIDER", "claude").lower() + saved_provider = saved_config.get("LLM_PROVIDER", "claude").lower() + # A saved model belongs to its saved provider, never to an override. + saved_model = saved_config.get("LLM_MODEL") if saved_config.get("LLM_PROVIDER") else None + return saved_provider, saved_model -def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider: - provider_type = _resolve_provider_type(provider_type) +def _resolve_default_model( + provider_type: ProviderType, provider_cls: type[LLMProvider], saved_model: str | None, +) -> str | None: + for name in _MODEL_ENV_VARS[provider_cls]: + model = read_model_env(name) + if model: + return model + + env_provider = _strip_env_value(os.environ.get("LLM_PROVIDER", "")).lower() + if not env_provider or env_provider == provider_type.value: + env_model = read_model_env("LLM_MODEL") + if env_model: + return env_model + return saved_model or None + + +def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str | None) -> LLMProvider: + """Create a provider, honoring the model selected by llm-select. + + Built-in model defaults prefer explicit kwargs, then existing provider-specific + environment variables, LLM_MODEL, and the saved provider/model pair. Saved + config is only read when neither an explicit nor an environment provider wins. + Custom providers retain their own constructor and configuration semantics. + """ + provider_type, saved_model = _resolve_provider_config(provider_type) if isinstance(provider_type, str): try: @@ -72,6 +111,11 @@ def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) if not provider_cls: raise ValueError(f"No provider registered for type: {provider_type}") + if "default_model" not in kwargs and provider_cls in _MODEL_ENV_VARS: + model = _resolve_default_model(provider_type, provider_cls, saved_model) + if model: + kwargs = {**kwargs, "default_model": model} + return provider_cls(**kwargs) diff --git a/tests/test_claude_provider.py b/tests/test_claude_provider.py index 7a4d54f95..37e151619 100644 --- a/tests/test_claude_provider.py +++ b/tests/test_claude_provider.py @@ -38,6 +38,41 @@ def make_response(content: list[SimpleNamespace], stop_reason: str = "tool_use") ) +@pytest.mark.unit +@pytest.mark.parametrize( + ("default_model", "request_model", "expected_model"), + [ + (None, None, "claude-sonnet-4-6"), + (None, "claude-haiku-4-5", "claude-haiku-4-5"), + ("claude-haiku-4-5", None, "claude-haiku-4-5"), + ("claude-opus-4-8", None, "claude-opus-4-8"), + ("claude-haiku-4-5", "claude-opus-4-8", "claude-opus-4-8"), + ], +) +def test_generate_respects_configured_default_and_request_model( + default_model: str | None, request_model: str | None, expected_model: str +) -> None: + provider_options = {"api_key": "test-key"} + if default_model is not None: + provider_options["default_model"] = default_model + provider = ClaudeProvider(**provider_options) + client = FakeClient(make_response([SimpleNamespace(type="text", text="ok")])) + provider.client = client + expected_default = default_model or "claude-sonnet-4-6" + + assert provider.get_default_model() == expected_default + + provider.generate( + LLMInput(messages=[Message(role=Role.USER, content="Hi")], model=request_model) + ) + + assert client.messages.last_params["model"] == expected_model + if expected_model == "claude-opus-4-8": + assert client.messages.last_params["thinking"] == {"type": "adaptive"} + assert "temperature" not in client.messages.last_params + assert provider.get_default_model() == expected_default + + @pytest.mark.unit def test_generate_collects_text_and_tool_use_blocks() -> None: provider = make_provider( diff --git a/tests/test_model_config.py b/tests/test_model_config.py new file mode 100644 index 000000000..86596936b --- /dev/null +++ b/tests/test_model_config.py @@ -0,0 +1,119 @@ +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +import pytest + +from llm.cli.selector import save_config +from llm.core.interface import LLMProvider +from llm.core.types import ProviderType +from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider +from llm.providers.atlas import AtlasProvider +from llm.providers.ollama import OllamaProvider +from llm.providers.resolver import get_provider + +pytestmark = pytest.mark.unit + + +@dataclass(frozen=True) +class ProviderCase: + provider_type: ProviderType + constructor: Callable[..., LLMProvider] + model_env: str + builtin_model: str + + +PROVIDERS = ( + ProviderCase(ProviderType.OLLAMA, OllamaProvider, "OLLAMA_MODEL", "llama3.2"), + ProviderCase(ProviderType.ATLAS, AtlasProvider, "ATLAS_MODEL", "deepseek-ai/deepseek-v4-pro"), + ProviderCase(ProviderType.ASTRAFLOW, AstraflowProvider, "ASTRAFLOW_MODEL", "gpt-4o-mini"), + ProviderCase(ProviderType.ASTRAFLOW_CN, AstraflowCNProvider, "ASTRAFLOW_CN_MODEL", "gpt-4o-mini"), +) +PROVIDER_IDS = tuple(case.provider_type.value for case in PROVIDERS) +BLANK_MODELS = (" \t ", ' " \t " ') + + +@pytest.fixture(autouse=True) +def isolated_model_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + for name in ( + "LLM_PROVIDER", "LLM_MODEL", "OLLAMA_MODEL", "ATLAS_MODEL", + "ASTRAFLOW_MODEL", "ASTRAFLOW_CN_MODEL", "OLLAMA_BASE_URL", + "ATLAS_BASE_URL", "ASTRAFLOW_BASE_URL", "ASTRAFLOW_CN_BASE_URL", + ): + # Record even absent variables: save_config(persist=True) writes them directly. + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) + for name in ("ATLAS_API_KEY", "ASTRAFLOW_API_KEY", "ASTRAFLOW_CN_API_KEY"): + monkeypatch.setenv(name, "test-key") + + +def set_provider_model( + monkeypatch: pytest.MonkeyPatch, case: ProviderCase, value: str +) -> None: + monkeypatch.setenv(case.model_env, value) + if case.provider_type == ProviderType.ASTRAFLOW_CN: + monkeypatch.setenv("ASTRAFLOW_MODEL", value) + + +@pytest.mark.parametrize("case", PROVIDERS, ids=PROVIDER_IDS) +@pytest.mark.parametrize("blank_model", BLANK_MODELS, ids=("whitespace", "quoted-whitespace")) +@pytest.mark.parametrize("source", ("environment", "saved")) +def test_blank_provider_model_falls_back_to_selected_model( + monkeypatch: pytest.MonkeyPatch, + case: ProviderCase, + blank_model: str, + source: Literal["environment", "saved"], +) -> None: + save_config(case.provider_type.value, "Selected/MixedCase-model", persist=source == "environment") + set_provider_model(monkeypatch, case, blank_model) + + assert get_provider().get_default_model() == "Selected/MixedCase-model" + + +@pytest.mark.parametrize("case", PROVIDERS, ids=PROVIDER_IDS) +@pytest.mark.parametrize("blank_model", BLANK_MODELS, ids=("whitespace", "quoted-whitespace")) +def test_blank_provider_model_without_selection_retains_builtin_default( + monkeypatch: pytest.MonkeyPatch, case: ProviderCase, blank_model: str +) -> None: + set_provider_model(monkeypatch, case, blank_model) + + assert get_provider(case.provider_type).get_default_model() == case.builtin_model + assert case.constructor().get_default_model() == case.builtin_model + + +@pytest.mark.parametrize("case", PROVIDERS, ids=PROVIDER_IDS) +@pytest.mark.parametrize("model", (' "Provider/MixedCase-model" ', " 'Provider/MixedCase-model' ")) +def test_quoted_provider_model_is_normalized_without_changing_case( + monkeypatch: pytest.MonkeyPatch, case: ProviderCase, model: str +) -> None: + monkeypatch.setenv("LLM_MODEL", "selected-model") + monkeypatch.setenv(case.model_env, model) + + assert get_provider(case.provider_type).get_default_model() == "Provider/MixedCase-model" + assert case.constructor().get_default_model() == "Provider/MixedCase-model" + + +@pytest.mark.parametrize("case", PROVIDERS, ids=PROVIDER_IDS) +def test_explicit_default_model_keeps_precedence_and_original_value( + monkeypatch: pytest.MonkeyPatch, case: ProviderCase +) -> None: + save_config(case.provider_type.value, "selected-model", persist=True) + set_provider_model(monkeypatch, case, ' "Provider/MixedCase-model" ') + explicit_model = ' "Explicit/MixedCase-model" ' + + assert get_provider(default_model=explicit_model).get_default_model() == explicit_model + assert case.constructor(default_model=explicit_model).get_default_model() == explicit_model + + +@pytest.mark.parametrize("blank_model", BLANK_MODELS, ids=("whitespace", "quoted-whitespace")) +def test_cn_blank_specific_model_uses_normalized_shared_model( + monkeypatch: pytest.MonkeyPatch, blank_model: str +) -> None: + monkeypatch.setenv("ASTRAFLOW_CN_MODEL", blank_model) + monkeypatch.setenv("ASTRAFLOW_MODEL", ' "Shared/MixedCase-model" ') + monkeypatch.setenv("LLM_MODEL", "selected-model") + + assert get_provider(ProviderType.ASTRAFLOW_CN).get_default_model() == "Shared/MixedCase-model" + assert AstraflowCNProvider().get_default_model() == "Shared/MixedCase-model" diff --git a/tests/test_provider_tools.py b/tests/test_provider_tools.py index c95da7c2a..5f4960b81 100644 --- a/tests/test_provider_tools.py +++ b/tests/test_provider_tools.py @@ -75,6 +75,38 @@ def _openai_response(**overrides) -> SimpleNamespace: return SimpleNamespace(**defaults) +@pytest.mark.unit +@pytest.mark.parametrize( + ("default_model", "request_model", "expected_model"), + [ + (None, None, "gpt-4o-mini"), + (None, "gpt-4o", "gpt-4o"), + ("gpt-4o", None, "gpt-4o"), + ("gpt-4o", "gpt-4-turbo", "gpt-4-turbo"), + ], +) +def test_openai_generate_respects_configured_default_and_request_model( + default_model: str | None, request_model: str | None, expected_model: str +) -> None: + provider_options = {"api_key": "test"} + if default_model is not None: + provider_options["default_model"] = default_model + provider = OpenAIProvider(**provider_options) + client = _OpenAIClient() + provider.client = client + expected_default = default_model or "gpt-4o-mini" + + assert provider.get_default_model() == expected_default + + provider.generate( + LLMInput(messages=[Message(role=Role.USER, content="hi")], model=request_model) + ) + + assert client.completions.params is not None + assert client.completions.params["model"] == expected_model + assert provider.get_default_model() == expected_default + + def test_openai_provider_serializes_tools_for_chat_completions(): provider = OpenAIProvider(api_key="test") client = _OpenAIClient() diff --git a/tests/test_resolver.py b/tests/test_resolver.py index c2a65787f..24c2d80ae 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,6 +1,12 @@ +import io +import json +from pathlib import Path +from unittest.mock import Mock + import pytest -from llm.core.types import ProviderType +from llm.cli.selector import save_config +from llm.core.types import LLMInput, Message, ProviderType, Role from llm.providers import ( AstraflowCNProvider, AstraflowProvider, @@ -9,6 +15,7 @@ from llm.providers import ( OllamaProvider, OpenAIProvider, get_provider, + resolver, ) @@ -36,6 +43,7 @@ class TestGetProvider: def test_get_astraflow_cn_provider(self): provider = get_provider("astraflow_cn") assert isinstance(provider, AstraflowCNProvider) + assert provider.provider_type == ProviderType.ASTRAFLOW_CN def test_get_atlas_provider(self): @@ -100,3 +108,194 @@ class TestGetProvider: provider = get_provider() assert isinstance(provider, AstraflowCNProvider) + + +class TestSelectedModel: + @pytest.fixture(autouse=True) + def isolated_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + for name in ( + "LLM_PROVIDER", "LLM_MODEL", "OLLAMA_MODEL", "ATLAS_MODEL", + "ASTRAFLOW_MODEL", "ASTRAFLOW_CN_MODEL", + ): + # save_config(persist=True) writes os.environ directly; restore it after each test. + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + @pytest.mark.parametrize("persist", [False, True]) + def test_selector_model_reaches_actual_request(self, monkeypatch: pytest.MonkeyPatch, persist: bool) -> None: + save_config("ollama", "mistral", persist=persist) + urlopen = Mock(side_effect=lambda *args, **kwargs: io.BytesIO( + b'{"message":{"content":"ok"},"model":"mistral"}' + )) + monkeypatch.setattr("urllib.request.urlopen", urlopen) + + provider = get_provider() + provider.generate(LLMInput(messages=[Message(Role.USER, "hello")])) + + assert provider.get_default_model() == "mistral" + assert json.loads(urlopen.call_args.args[0].data)["model"] == "mistral" + + provider.generate(LLMInput(messages=[], model="codellama")) + assert json.loads(urlopen.call_args.args[0].data)["model"] == "codellama" + + @pytest.mark.parametrize("provider_type", list(ProviderType)) + @pytest.mark.parametrize("source", ["saved", "environment"]) + def test_selected_model_applies_to_every_builtin( + self, monkeypatch: pytest.MonkeyPatch, provider_type: ProviderType, source: str, + ) -> None: + save_config(provider_type.value, "Selected/MixedCase-model", persist=source == "environment") + + provider = get_provider() + + assert provider.provider_type == provider_type + assert provider.get_default_model() == "Selected/MixedCase-model" + + def test_environment_model_overrides_saved_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + save_config("ollama", "mistral") + monkeypatch.setenv("LLM_MODEL", "codellama") + + assert get_provider().get_default_model() == "codellama" + + @pytest.mark.parametrize("provider_type,model_env", [ + ("ollama", "OLLAMA_MODEL"), + ("atlas", "ATLAS_MODEL"), + ("astraflow", "ASTRAFLOW_MODEL"), + ("astraflow_cn", "ASTRAFLOW_CN_MODEL"), + ("astraflow_cn", "ASTRAFLOW_MODEL"), + ]) + def test_provider_environment_retains_precedence( + self, monkeypatch: pytest.MonkeyPatch, provider_type: str, model_env: str, + ) -> None: + save_config(provider_type, "saved-model") + monkeypatch.setenv("LLM_MODEL", "global-model") + monkeypatch.setenv(model_env, "provider-model") + + assert get_provider().get_default_model() == "provider-model" + + def test_cn_specific_environment_overrides_shared_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + save_config("astraflow_cn", "saved-model") + monkeypatch.setenv("ASTRAFLOW_MODEL", "shared-model") + monkeypatch.setenv("ASTRAFLOW_CN_MODEL", "cn-model") + + assert get_provider().get_default_model() == "cn-model" + + @pytest.mark.parametrize("provider_type", list(ProviderType)) + def test_explicit_default_model_overrides_selection( + self, monkeypatch: pytest.MonkeyPatch, provider_type: ProviderType, + ) -> None: + save_config(provider_type.value, "saved-model", persist=True) + monkeypatch.setenv("OLLAMA_MODEL", "provider-model") + + assert get_provider(default_model="explicit-model").get_default_model() == "explicit-model" + + @pytest.mark.parametrize("default_model", [None, ""]) + def test_explicit_empty_default_retains_constructor_fallback( + self, monkeypatch: pytest.MonkeyPatch, default_model: str | None, + ) -> None: + save_config("ollama", "saved-model", persist=True) + monkeypatch.setenv("OLLAMA_MODEL", "provider-model") + + assert get_provider(default_model=default_model).get_default_model() == "provider-model" + + @pytest.mark.parametrize("explicit", [False, True]) + def test_provider_override_does_not_inherit_saved_model( + self, monkeypatch: pytest.MonkeyPatch, explicit: bool, + ) -> None: + save_config("openai", "gpt-selected") + if explicit: + provider = get_provider("ollama") + else: + monkeypatch.setenv("LLM_PROVIDER", "ollama") + provider = get_provider() + + assert provider.get_default_model() == "llama3.2" + + def test_explicit_provider_does_not_inherit_another_environment_model( + self, monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("LLM_PROVIDER", "openai") + monkeypatch.setenv("LLM_MODEL", "gpt-selected") + + assert get_provider("ollama").get_default_model() == "llama3.2" + + @pytest.mark.parametrize("env_provider", [None, " 'OLLAMA' "]) + def test_environment_model_applies_to_matching_explicit_provider( + self, monkeypatch: pytest.MonkeyPatch, env_provider: str | None, + ) -> None: + if env_provider is not None: + monkeypatch.setenv("LLM_PROVIDER", env_provider) + monkeypatch.setenv("LLM_MODEL", ' "MixedCase/Model" ') + + assert get_provider("ollama").get_default_model() == "MixedCase/Model" + + @pytest.mark.parametrize("source", ["saved", "environment"]) + @pytest.mark.parametrize("model", ["", " ", '""']) + def test_empty_selected_model_retains_default( + self, monkeypatch: pytest.MonkeyPatch, source: str, model: str, + ) -> None: + save_config("ollama", model, persist=source == "environment") + + assert get_provider().get_default_model() == "llama3.2" + + def test_empty_environment_model_falls_back_to_saved_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + save_config("ollama", "mistral") + monkeypatch.setenv("LLM_MODEL", " ") + + assert get_provider().get_default_model() == "mistral" + + def test_quoted_saved_model_preserves_case(self, tmp_path: Path) -> None: + tmp_path.joinpath(".llm.env").write_text( + "LLM_PROVIDER='OLLAMA'\nLLM_MODEL='MixedCase/Model'\n" + ) + + assert get_provider().get_default_model() == "MixedCase/Model" + + def test_unpaired_saved_model_does_not_override_default_provider(self, tmp_path: Path) -> None: + tmp_path.joinpath(".llm.env").write_text("LLM_MODEL=orphan-model\n") + + assert get_provider().get_default_model() == "claude-sonnet-4-6" + + @pytest.mark.parametrize("explicit", [False, True]) + def test_provider_override_does_not_read_invalid_saved_file( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, explicit: bool, + ) -> None: + tmp_path.joinpath(".llm.env").write_bytes(b"\xff\xfe") + monkeypatch.setenv("LLM_MODEL", "mistral") + if explicit: + provider = get_provider("ollama") + else: + monkeypatch.setenv("LLM_PROVIDER", "ollama") + provider = get_provider() + + assert provider.get_default_model() == "mistral" + + def test_registered_provider_keeps_its_constructor_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + class CustomProvider(OllamaProvider): + def __init__(self, base_url: str | None = None) -> None: + super().__init__(base_url=base_url, default_model="custom-default") + + monkeypatch.setattr(resolver, "_PROVIDER_MAP", resolver._PROVIDER_MAP.copy()) + resolver.register_provider(ProviderType.OLLAMA, CustomProvider) + save_config("ollama", "mistral", persist=True) + + provider = get_provider(base_url="http://localhost:1234") + + assert isinstance(provider, CustomProvider) + assert provider.base_url == "http://localhost:1234" + assert provider.get_default_model() == "custom-default" + + def test_registered_provider_receives_explicit_default_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + class CustomProvider(OllamaProvider): + pass + + monkeypatch.setattr(resolver, "_PROVIDER_MAP", resolver._PROVIDER_MAP.copy()) + resolver.register_provider(ProviderType.OLLAMA, CustomProvider) + save_config("ollama", "mistral", persist=True) + + provider = get_provider(default_model="custom-explicit") + + assert isinstance(provider, CustomProvider) + assert provider.get_default_model() == "custom-explicit"