diff --git a/.env.example b/.env.example index 3d5a59bf..7baded02 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,8 @@ # LLM provider: "google" (default), "openai", "anthropic", "groq", or "ollama". # LLM_PROVIDER=google -# Model for that provider. The default is a Gemini model, so set this whenever -# LLM_PROVIDER is not "google" (for example claude-haiku-5-5 with "anthropic"). +# Model for that provider. Unset, it follows LLM_PROVIDER (claude-haiku-5-5 for +# "anthropic", gpt-5-nano for "openai"); set it for your own LLM_BASE_URL server. # LLM_MODEL=gemini-3.5-flash-lite # Google AI API key, for LLM_PROVIDER=google. Legacy aliases GEMINI_API_KEY and LANGEXTRACT_API_KEY also accepted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b89d12..b433ef5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Setting `LLM_PROVIDER` (or `--llm-provider`) without `LLM_MODEL` now picks + that provider's default model: `claude-haiku-5-5` for `anthropic`, + `gpt-5-nano` for `openai`, `llama-3.3-70b-versatile` for `groq` and + `gpt-oss:20b` for `ollama`, the same models `bibr setup` writes. Before, every + provider was sent the Gemini default, so a hand-set Anthropic provider (for + example in a Docker deploy) failed with a 404. An `openai` provider with + `LLM_BASE_URL` still needs `LLM_MODEL`, since only the server knows its + model's name. + - A busy bibr-resolver no longer leaves references unmatched. A resolver call that gets a busy answer (429/502/503/504), times out or loses its connection is now retried up to 3 times with exponential backoff, a timeout only once diff --git a/bibr/config.py b/bibr/config.py index 86e07a22..872d1858 100644 --- a/bibr/config.py +++ b/bibr/config.py @@ -468,6 +468,21 @@ def settings_customise_sources( return (init_settings, env_settings, no_interp, file_secret_settings) +# Model each provider gets when ``LLM_PROVIDER`` is set without ``LLM_MODEL``. +# ``bibr setup`` writes the same model (``setup_wizard.LLM_DEFAULTS``). Without +# this, a hand-set provider kept the Gemini default and the provider answered +# 404 for a model it does not serve. +DEFAULT_LLM_MODELS: dict[str, str] = { + "google": "gemini-3.5-flash-lite", + "openai": "gpt-5-nano", + # Haiku 5.5 is the recommended hosted extraction model + # (docs/guides/extraction-models.md). + "anthropic": "claude-haiku-5-5", + "groq": "llama-3.3-70b-versatile", + "ollama": "gpt-oss:20b", +} + + class LlmOptions(_BibrSettings): """LLM client settings. Env: ``LLM_PROVIDER``, ``LLM_MODEL``, ``LLM_API_KEY``, ...""" @@ -479,7 +494,13 @@ class LlmOptions(_BibrSettings): "Custom adapters added through bibr.clients.providers.register cannot be named here; " "set them on the settings object in code instead.", ) - model: str = Field("gemini-3.5-flash-lite", description="Model name for the selected provider.") + model: str = Field( + DEFAULT_LLM_MODELS["google"], + description="Model name for the selected provider. Unset, it follows LLM_PROVIDER: " + + ", ".join(f"{name} uses {model}" for name, model in DEFAULT_LLM_MODELS.items()) + + ". An openai provider with LLM_BASE_URL keeps this default, so name the served " + "model there.", + ) model_revision: str | None = Field( None, description="Exact model revision/commit deployed (surfaced in qualification_provenance; " @@ -840,6 +861,29 @@ def _parse_chat_template_kwargs(cls, v): return v return v + @model_validator(mode="after") + def _default_model_for_provider(self) -> "LlmOptions": + self.apply_provider_default_model() + return self + + def apply_provider_default_model(self) -> None: + """Give an unset ``model`` the default of the current ``provider``. + + Runs when the settings are built and again after ``--llm-provider`` + swaps the provider in place. The default stays out of + ``model_fields_set``, so a later provider change still counts it as + unset. An openai provider with ``base_url`` points at a self-hosted + server whose model name bibr cannot guess, so it is left alone. + """ + if "model" in self.model_fields_set: + return + if self.provider == "openai" and self.base_url: + return + default = DEFAULT_LLM_MODELS.get(self.provider) + if default is not None and default != self.model: + self.model = default + self.model_fields_set.discard("model") + class OcrOptions(_BibrSettings): """OCR service settings. Env: ``OCR_BACKEND``, ``OCR_BASE_URL``, ...""" diff --git a/bibr/local/cli/run_config.py b/bibr/local/cli/run_config.py index ccd7cec9..234efbb7 100644 --- a/bibr/local/cli/run_config.py +++ b/bibr/local/cli/run_config.py @@ -198,6 +198,7 @@ def _apply_runtime_settings(args) -> None: if args.llm_provider: bibr.config.Settings.llm.provider = args.llm_provider + bibr.config.Settings.llm.apply_provider_default_model() if args.llm_model: bibr.config.Settings.llm.model = args.llm_model if args.no_equations: diff --git a/bibr/setup_wizard.py b/bibr/setup_wizard.py index 6954e33b..046a9259 100644 --- a/bibr/setup_wizard.py +++ b/bibr/setup_wizard.py @@ -24,6 +24,7 @@ from rich.console import Console from rich.prompt import Confirm, Prompt +from bibr.config import DEFAULT_LLM_MODELS from bibr.env_utils import _format_env_value, write_env_text from bibr.env_utils import merge_env as _merge_env # re-exported for tests from bibr.local.cli import ui @@ -62,15 +63,16 @@ "import onnxruntime; print('CUDAExecutionProvider' in onnxruntime.get_available_providers())" ) +# Models come from bibr.config.DEFAULT_LLM_MODELS, which also fills LLM_MODEL +# when LLM_PROVIDER is set by hand. Haiku 5.5 is the recommended hosted +# extraction model: on the 9 Oct harness (252 dev papers) it passes 221 against +# Qwen3.8-27B's 223, at about $0.004 a paper (docs/guides/extraction-models.md). LLM_DEFAULTS: dict[str, dict[str, str]] = { - "google": {"model": "gemini-3.5-flash-lite", "key_env": "GOOGLE_API_KEY"}, - "openai": {"model": "gpt-5-nano", "key_env": "LLM_API_KEY"}, - # Haiku 5.5 is the recommended hosted extraction model: on the 9 Oct harness - # (252 dev papers) it passes 221 against Qwen3.8-27B's 223, at about $0.004 - # a paper (docs/guides/extraction-models.md). - "anthropic": {"model": "claude-haiku-5-5", "key_env": "ANTHROPIC_API_KEY"}, - "groq": {"model": "llama-3.3-70b-versatile", "key_env": "GROQ_API_KEY"}, - "ollama": {"model": "gpt-oss:20b", "key_env": ""}, + "google": {"model": DEFAULT_LLM_MODELS["google"], "key_env": "GOOGLE_API_KEY"}, + "openai": {"model": DEFAULT_LLM_MODELS["openai"], "key_env": "LLM_API_KEY"}, + "anthropic": {"model": DEFAULT_LLM_MODELS["anthropic"], "key_env": "ANTHROPIC_API_KEY"}, + "groq": {"model": DEFAULT_LLM_MODELS["groq"], "key_env": "GROQ_API_KEY"}, + "ollama": {"model": DEFAULT_LLM_MODELS["ollama"], "key_env": ""}, } # Vision model per cloud OCR backend, written as OCR_VISION_MODEL. Unset, the diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 002ad23c..9bfcf19a 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -209,11 +209,13 @@ expected; install a CUDA build of `llama-server` and skip the `gpu` extra. `LLM_PROVIDER` selects the provider used for metadata extraction and LLM fallbacks such as section classification and citation linking: `{{ default_llm_provider }}` (the default), `openai`, `anthropic`, `groq`, or `ollama` (a local Ollama -server). `LLM_MODEL` picks the model for that provider. Its default, -`{{ default_llm_model }}`, does not change with the provider, so set -`LLM_MODEL` whenever you set `LLM_PROVIDER` yourself, for example -`LLM_MODEL=claude-haiku-5-5` with `anthropic`; otherwise the provider is sent -a model name it does not serve. +server). `LLM_MODEL` picks the model for that provider. Left unset, it +follows the provider: `{{ default_llm_model }}` for `google`, +`claude-haiku-5-5` for `anthropic`, `gpt-5-nano` for `openai`, +`llama-3.3-70b-versatile` for `groq` and `gpt-oss:20b` for `ollama`, the same +models `bibr setup` writes. An `openai` provider pointed at your own server +with `LLM_BASE_URL` keeps `{{ default_llm_model }}`, so name the served model +there. For Ollama, `LLM_OLLAMA_BASE_URL` names the server (default `http://localhost:11434`). bibr talks to Ollama's OpenAI-compatible API under diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index fdf8bcb2..c7cb0473 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -460,7 +460,7 @@ Set these in `.env` (run `bibr config example --full` or see the | Variable | Description | Default | |---|---|---| | `LLM_PROVIDER` | LLM backend: `google`, `openai`, `anthropic`, `groq`, or `ollama` | `google` | -| `LLM_MODEL` | Model for the selected provider. The default is a Gemini model, so set it whenever `LLM_PROVIDER` is not `google` (an Anthropic provider sent the Gemini name gets a 404) | `gemini-3.5-flash-lite` | +| `LLM_MODEL` | Model for the selected provider. Unset, it follows `LLM_PROVIDER`: `claude-haiku-5-5` for `anthropic`, `gpt-5-nano` for `openai`, `llama-3.3-70b-versatile` for `groq`, `gpt-oss:20b` for `ollama`. Set it for an `openai` provider with `LLM_BASE_URL` | `gemini-3.5-flash-lite` | | `GOOGLE_API_KEY` | Google AI API key (`google` provider) | — | | `LLM_API_KEY` | OpenAI-compatible API key (`openai` provider) | — | | `ANTHROPIC_API_KEY` | Anthropic API key (`anthropic` provider) | — | diff --git a/tests/clients/test_anthropic_thinking_caps.py b/tests/clients/test_anthropic_thinking_caps.py index 9222c96e..6bd53bcb 100644 --- a/tests/clients/test_anthropic_thinking_caps.py +++ b/tests/clients/test_anthropic_thinking_caps.py @@ -21,7 +21,9 @@ def _provider(**llm): - settings = GlobalSettings(llm={"provider": "anthropic", **llm}) + # Pin a model that takes budget thinking: the provider default, Haiku 5.5, + # rejects it (and every sampling parameter). + settings = GlobalSettings(llm={"provider": "anthropic", "model": "claude-haiku-4-5", **llm}) return AnthropicProvider(settings=settings), settings diff --git a/tests/clients/test_provider_temperature.py b/tests/clients/test_provider_temperature.py index f91da8df..ef615205 100644 --- a/tests/clients/test_provider_temperature.py +++ b/tests/clients/test_provider_temperature.py @@ -18,6 +18,10 @@ def _settings(**llm): + if llm.get("provider") == "anthropic": + # The provider default, Haiku 5.5, rejects every sampling parameter; + # these tests need a Claude model that takes them. + llm.setdefault("model", "claude-haiku-4-5") return GlobalSettings(llm=llm) diff --git a/tests/local/test_cli_helpers.py b/tests/local/test_cli_helpers.py index f070a373..c180c2d4 100644 --- a/tests/local/test_cli_helpers.py +++ b/tests/local/test_cli_helpers.py @@ -30,6 +30,30 @@ def test_apply_runtime_settings_applies_llm_provider(): assert mock_settings.llm.provider == "anthropic" +@pytest.mark.parametrize( + ("llm_model", "expected"), + [(None, "claude-haiku-5-5"), ("claude-sonnet-5-5", "claude-sonnet-5-5")], +) +def test_apply_runtime_settings_llm_provider_brings_its_default_model(llm_model, expected): + """``--llm-provider anthropic`` alone no longer sends the Gemini default model.""" + from bibr.config import LlmOptions + from bibr.local.cli import _apply_runtime_settings + + args = Namespace( + preset=None, + llm_provider="anthropic", + llm_model=llm_model, + no_equations=False, + refs=None, + ref_seg=None, + ) + llm = LlmOptions(_env_file=None, provider="google") + with patch("bibr.config.Settings") as mock_settings: + mock_settings.llm = llm + _apply_runtime_settings(args) + assert (llm.provider, llm.model) == ("anthropic", expected) + + def test_apply_runtime_settings_disables_equations(): from bibr.local.cli import _apply_runtime_settings diff --git a/tests/test_config.py b/tests/test_config.py index 63995220..a14b2d35 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1343,3 +1343,56 @@ def test_model_dump_masks_secret_fields(self): assert "gsk-secret-top-level" not in blob # Non-secret nested values survive round-trip. assert dumped["llm"]["provider"] == s.llm.provider + + +@pytest.mark.parametrize( + ("provider", "model"), + [ + ("google", "gemini-3.5-flash-lite"), + ("anthropic", "claude-haiku-5-5"), + ("openai", "gpt-5-nano"), + ("groq", "llama-3.3-70b-versatile"), + ("ollama", "gpt-oss:20b"), + ], +) +def test_llm_provider_without_model_gets_its_own_default(monkeypatch, provider, model): + """``LLM_PROVIDER=anthropic`` alone used to send the Gemini default (a 404).""" + from bibr.config import DEFAULT_LLM_MODELS, LlmOptions + from bibr.setup_wizard import LLM_DEFAULTS + + monkeypatch.setenv("LLM_PROVIDER", provider) + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + opts = LlmOptions(_env_file=None) + assert opts.model == model + assert "model" not in opts.model_fields_set + assert LLM_DEFAULTS[provider]["model"] == DEFAULT_LLM_MODELS[provider] + + +def test_llm_model_set_explicitly_is_kept(monkeypatch): + from bibr.config import LlmOptions + + monkeypatch.setenv("LLM_PROVIDER", "anthropic") + monkeypatch.setenv("LLM_MODEL", "claude-sonnet-5-5") + assert LlmOptions(_env_file=None).model == "claude-sonnet-5-5" + monkeypatch.setenv("LLM_MODEL", "gemini-3.5-flash-lite") + assert LlmOptions(_env_file=None).model == "gemini-3.5-flash-lite" + + +def test_llm_openai_base_url_keeps_the_default_model(monkeypatch): + """A self-hosted OpenAI-compatible server names its own model; bibr does not guess it.""" + from bibr.config import LlmOptions + + monkeypatch.setenv("LLM_PROVIDER", "openai") + monkeypatch.setenv("LLM_BASE_URL", "http://gpu-host:8000/v1") + monkeypatch.delenv("LLM_MODEL", raising=False) + assert LlmOptions(_env_file=None).model == "gemini-3.5-flash-lite" + + +def test_llm_provider_swap_follows_an_unset_model(): + from bibr.config import LlmOptions + + opts = LlmOptions(_env_file=None, provider="anthropic") + opts.provider = "groq" + opts.apply_provider_default_model() + assert opts.model == "llama-3.3-70b-versatile"