Merge remote-tracking branch 'refs/remotes/pr/3499' into integration/ecc-043-20261011

# Conflicts:
#	src/llm/providers/ollama.py
#	src/llm/providers/openai.py
This commit is contained in:
haelyra
2026-10-11 02:45:34 -04:00
11 changed files with 467 additions and 16 deletions
+3 -2
View File
@@ -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 = [
+2 -1
View File
@@ -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 = [
+9 -3
View File
@@ -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
+11
View File
@@ -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
+2 -1
View File
@@ -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",
+4 -2
View File
@@ -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
+50 -6
View File
@@ -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)
+35
View File
@@ -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(
+119
View File
@@ -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"
+32
View File
@@ -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()
+200 -1
View File
@@ -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"