Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion bibr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``, ..."""

Expand All @@ -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; "
Expand Down Expand Up @@ -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``, ..."""
Expand Down
1 change: 1 addition & 0 deletions bibr/local/cli/run_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions bibr/setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | — |
Expand Down
4 changes: 3 additions & 1 deletion tests/clients/test_anthropic_thinking_caps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 4 additions & 0 deletions tests/clients/test_provider_temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
24 changes: 24 additions & 0 deletions tests/local/test_cli_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 53 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading