Merge pull request #584 from ggozad/fix/reject-unknown-chat-providers
Reject unknown chat model providers and rename gemini to google
This commit is contained in:
commit
eb50c9c587
8 changed files with 120 additions and 29 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Chat model provider `gemini` renamed to `google`, matching pydantic-ai. Update `provider: gemini` to `provider: google`.
|
||||
|
||||
### Added
|
||||
|
||||
- `lancedb.databases` configures a named set of local or remote databases.
|
||||
|
|
@ -33,6 +37,8 @@
|
|||
- Inspector search results mark truncated previews with an ellipsis.
|
||||
- Document titles, URIs, headings and database names render as text, not Rich
|
||||
markup, in `search` output, chat citations and the chat document filter.
|
||||
- `reranking.model.base_url` accepts the endpoint with or without the `/v1` path, matching the `vllm` embedder. Writing `/v1` produced a request to `/v1/v1/rerank`.
|
||||
- An unrecognized chat model provider raises `Unknown model provider '<name>'` instead of reaching pydantic-ai as a `provider:name` string, and outranks the `api_key` check, so an unusable provider is no longer reported as a missing vendor environment variable.
|
||||
|
||||
## [0.78.0] - 2026-08-24
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ qa:
|
|||
|
||||
Same mechanism, opposite direction. Without `extra_body` the Gemma-4 chat template defaults to non-thinking and dumps a verbose answer straight into `content`. With it on, vLLM (started with `--reasoning-parser`) populates the parsed `reasoning` field and leaves `content` as the concise final answer.
|
||||
|
||||
**Provider support:** honored by openai, ollama, anthropic, and groq via pydantic-ai's `ModelSettings.extra_body`. Silently ignored by gemini and bedrock.
|
||||
**Provider support:** honored by openai, ollama, anthropic, and groq via pydantic-ai's `ModelSettings.extra_body`. Silently ignored by google and bedrock.
|
||||
|
||||
## Embedding Providers
|
||||
|
||||
|
|
@ -393,7 +393,7 @@ Any provider supported by Pydantic AI can be used. Examples:
|
|||
# Google Gemini
|
||||
qa:
|
||||
model:
|
||||
provider: gemini
|
||||
provider: google
|
||||
name: gemini-1.5-flash
|
||||
|
||||
# Groq
|
||||
|
|
@ -472,10 +472,10 @@ reranking:
|
|||
model:
|
||||
provider: vllm
|
||||
name: Qwen/Qwen3-Reranker-4B
|
||||
base_url: http://localhost:8001
|
||||
base_url: http://localhost:8001/v1
|
||||
```
|
||||
|
||||
**Note:** vLLM reranking uses the `/v1/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded.
|
||||
**Note:** vLLM reranking posts to the `/v1/rerank` endpoint. As with the embedder, `base_url` may be written with or without the `/v1` path. You need to run a vLLM server separately with a reranking model loaded.
|
||||
|
||||
#### Multimodal reranking
|
||||
|
||||
|
|
@ -487,7 +487,7 @@ reranking:
|
|||
model:
|
||||
provider: vllm
|
||||
name: nvidia/llama-nemotron-rerank-vl-1b-v2
|
||||
base_url: http://localhost:8001
|
||||
base_url: http://localhost:8001/v1
|
||||
```
|
||||
|
||||
Picture chunks are sent as image documents (base64 data URIs) alongside plain text documents in the same rerank request. The flag is supported on the vllm provider only, and the served model must accept multimodal inputs.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class ModelConfig(ConfigModel):
|
|||
`ModelSettings.extra_body`. Provider-side escape hatch for
|
||||
keys haiku.rag doesn't model explicitly (e.g. vLLM's
|
||||
`chat_template_kwargs.enable_thinking: false` for Qwen3).
|
||||
Honored by openai/ollama/anthropic/groq; ignored by gemini/bedrock.
|
||||
Honored by openai/ollama/anthropic/groq; ignored by google/bedrock.
|
||||
"""
|
||||
|
||||
provider: str = "ollama"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pydantic_ai.providers.ollama import OllamaProvider
|
|||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from haiku.rag.config import AppConfig, get_config
|
||||
from haiku.rag.utils import check_api_key_supported
|
||||
from haiku.rag.utils import check_api_key_supported, vllm_base_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PIL import Image as PILImage
|
||||
|
|
@ -241,7 +241,7 @@ def get_embedder(config: AppConfig | None = None) -> EmbedderWrapper:
|
|||
if provider == "vllm":
|
||||
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
|
||||
|
||||
base_url = _vllm_base_url(embedding_model.base_url)
|
||||
base_url = vllm_base_url(embedding_model.base_url)
|
||||
return VLLMMultimodalEmbedder(
|
||||
model_name,
|
||||
vector_dim,
|
||||
|
|
@ -253,13 +253,6 @@ def get_embedder(config: AppConfig | None = None) -> EmbedderWrapper:
|
|||
raise ValueError(f"Unsupported embedding provider: {provider}")
|
||||
|
||||
|
||||
def _vllm_base_url(base_url: str | None) -> str:
|
||||
base_url = base_url or "http://localhost:8000/v1"
|
||||
if not base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/") + "/v1"
|
||||
return base_url
|
||||
|
||||
|
||||
def _get_multimodal_embedder(
|
||||
embedding_model: "EmbeddingModelConfig",
|
||||
) -> EmbedderWrapper:
|
||||
|
|
@ -275,7 +268,7 @@ def _get_multimodal_embedder(
|
|||
if provider == "vllm":
|
||||
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
|
||||
|
||||
base_url = _vllm_base_url(embedding_model.base_url)
|
||||
base_url = vllm_base_url(embedding_model.base_url)
|
||||
return VLLMMultimodalEmbedder(
|
||||
model_name,
|
||||
vector_dim,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import httpx
|
|||
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
from haiku.rag.utils import vllm_base_url
|
||||
|
||||
|
||||
def _document(chunk: Chunk) -> str | dict:
|
||||
|
|
@ -26,7 +27,7 @@ def _document(chunk: Chunk) -> str | dict:
|
|||
class VLLMReranker(RerankerBase):
|
||||
def __init__(self, model: str, base_url: str, api_key: str | None = None):
|
||||
self._model = model
|
||||
self._base_url = base_url
|
||||
self._base_url = vllm_base_url(base_url)
|
||||
self._headers = {
|
||||
"accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -47,7 +48,7 @@ class VLLMReranker(RerankerBase):
|
|||
documents = [_document(chunk) for chunk in chunks]
|
||||
|
||||
response = await self._client.post(
|
||||
f"{self._base_url}/v1/rerank",
|
||||
f"{self._base_url}/rerank",
|
||||
json={"model": self._model, "query": query, "documents": documents},
|
||||
headers=self._headers,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,44 @@ def check_api_key_supported(
|
|||
)
|
||||
|
||||
|
||||
def vllm_base_url(base_url: str | None) -> str:
|
||||
"""Normalize a vLLM endpoint to its OpenAI-compatible `/v1` root.
|
||||
|
||||
Embedders and rerankers take the same endpoint from config, so both accept
|
||||
it written with or without `/v1`.
|
||||
"""
|
||||
base_url = base_url or "http://localhost:8000/v1"
|
||||
if not base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/") + "/v1"
|
||||
return base_url
|
||||
|
||||
|
||||
def _check_provider_known(provider: str) -> None:
|
||||
"""Reject a chat provider pydantic-ai cannot resolve.
|
||||
|
||||
Providers we do not branch on reach pydantic-ai as a `provider:name` string,
|
||||
so without this an unusable name fails deep inside pydantic-ai with nothing
|
||||
naming the config key it came from. Asking pydantic-ai's own resolver rather
|
||||
than keeping a list here leaves a newly added provider working with no
|
||||
release of ours.
|
||||
"""
|
||||
from pydantic_ai.providers import infer_provider_class
|
||||
|
||||
try:
|
||||
infer_provider_class(provider)
|
||||
except ImportError:
|
||||
# pydantic-ai knows the name, its SDK is just not installed here. That
|
||||
# failure names the extra to install, so leave it to be raised in place.
|
||||
return
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Unknown model provider '{provider}'. See "
|
||||
"https://ai.pydantic.dev/models/ for the providers pydantic-ai "
|
||||
"supports. An OpenAI-compatible server (vLLM, sglang, LM Studio) "
|
||||
"uses provider 'openai' with base_url."
|
||||
) from None
|
||||
|
||||
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""Compute cosine similarity between two vectors."""
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
|
|
@ -169,6 +207,7 @@ def get_model(
|
|||
|
||||
provider = model_config.provider
|
||||
model = model_config.name
|
||||
_check_provider_known(provider)
|
||||
check_api_key_supported(model_config, {"openai", "ollama"})
|
||||
|
||||
if provider == "ollama":
|
||||
|
|
@ -261,7 +300,7 @@ def get_model(
|
|||
|
||||
return AnthropicModel(model_name=model, settings=anthropic_settings)
|
||||
|
||||
elif provider == "gemini":
|
||||
elif provider == "google":
|
||||
from pydantic_ai.models.google import GoogleModel
|
||||
|
||||
return GoogleModel(
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ class TestGetReranker:
|
|||
{"base_url": "http://localhost:8000"},
|
||||
{
|
||||
"_model": "BAAI/bge-reranker-v2-m3",
|
||||
"_base_url": "http://localhost:8000",
|
||||
"_base_url": "http://localhost:8000/v1",
|
||||
},
|
||||
{},
|
||||
),
|
||||
|
|
@ -293,11 +293,12 @@ class TestGetReranker:
|
|||
|
||||
|
||||
class _PoolStats:
|
||||
"""Fake httpx.AsyncClient factory counting constructions and closes."""
|
||||
"""Fake httpx.AsyncClient factory recording constructions, closes and URLs."""
|
||||
|
||||
def __init__(self, response_json):
|
||||
self.constructed = 0
|
||||
self.closed = 0
|
||||
self.urls: list[str] = []
|
||||
stats = self
|
||||
|
||||
class FakeResponse:
|
||||
|
|
@ -312,6 +313,7 @@ class _PoolStats:
|
|||
stats.constructed += 1
|
||||
|
||||
async def post(self, url, json, headers):
|
||||
stats.urls.append(url)
|
||||
return FakeResponse()
|
||||
|
||||
async def aclose(self):
|
||||
|
|
@ -339,6 +341,25 @@ async def test_vllm_reranker_reuses_pooled_client(monkeypatch):
|
|||
assert stats.closed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"base_url", ["http://localhost:8000", "http://localhost:8000/v1"]
|
||||
)
|
||||
async def test_vllm_reranker_accepts_base_url_with_or_without_v1(monkeypatch, base_url):
|
||||
"""`reranking.model.base_url` is the same endpoint as the embedder's, which
|
||||
carries /v1, so both spellings must post to /v1/rerank exactly once."""
|
||||
from haiku.rag.reranking.vllm import VLLMReranker
|
||||
|
||||
stats = _PoolStats({"results": [{"index": 0, "relevance_score": 0.9}]})
|
||||
monkeypatch.setattr("httpx.AsyncClient", stats.client_class)
|
||||
|
||||
reranker = VLLMReranker(model="m", base_url=base_url)
|
||||
await reranker.rerank("q", [Chunk(content="a", order=0)])
|
||||
|
||||
assert stats.urls == ["http://localhost:8000/v1/rerank"]
|
||||
await reranker.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vllm_reranker_builds_multimodal_documents(monkeypatch):
|
||||
"""Chunks carrying picture bytes are sent as content-parts documents
|
||||
|
|
|
|||
|
|
@ -390,23 +390,23 @@ def test_get_model_anthropic_thinking_off_disables_adaptive_models():
|
|||
|
||||
|
||||
@pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed")
|
||||
def test_get_model_gemini():
|
||||
"""Test get_model returns GoogleModel for Gemini."""
|
||||
def test_get_model_google():
|
||||
"""Test get_model returns GoogleModel for Google."""
|
||||
from pydantic_ai.models.google import GoogleModel
|
||||
|
||||
model_config = ModelConfig(provider="gemini", name="gemini-2.0-flash-exp")
|
||||
model_config = ModelConfig(provider="google", name="gemini-2.0-flash-exp")
|
||||
result = get_model(model_config)
|
||||
assert isinstance(result, GoogleModel)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed")
|
||||
@pytest.mark.parametrize("enable_thinking", [True, False])
|
||||
def test_get_model_gemini_with_thinking(enable_thinking):
|
||||
"""Test get_model configures thinking for Gemini."""
|
||||
def test_get_model_google_with_thinking(enable_thinking):
|
||||
"""Test get_model configures thinking for Google."""
|
||||
from pydantic_ai.models.google import GoogleModel
|
||||
|
||||
model_config = ModelConfig(
|
||||
provider="gemini",
|
||||
provider="google",
|
||||
name="gemini-2.0-flash-thinking-exp",
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
|
|
@ -536,14 +536,45 @@ def test_get_model_bedrock_rejects_mantle_only_model():
|
|||
get_model(model_config)
|
||||
|
||||
|
||||
def test_get_model_unknown_provider():
|
||||
"""Test get_model returns string format for unknown providers."""
|
||||
def test_get_model_passthrough_for_unbranched_provider():
|
||||
"""A provider pydantic-ai knows but we do not branch on passes through as a
|
||||
string, so a new pydantic-ai provider needs no haiku.rag release."""
|
||||
model_config = ModelConfig(provider="mistral", name="mistral-large-latest")
|
||||
result = get_model(model_config)
|
||||
assert isinstance(result, str)
|
||||
assert result == "mistral:mistral-large-latest"
|
||||
|
||||
|
||||
def test_get_model_accepts_provider_whose_sdk_is_missing(monkeypatch):
|
||||
"""A missing vendor SDK is not an unknown provider: that ImportError names
|
||||
the extra to install, so it must reach the caller unchanged.
|
||||
|
||||
Uses a provider whose SDK *is* installed, so the patch is what produces the
|
||||
ImportError rather than the environment.
|
||||
"""
|
||||
import pydantic_ai.providers
|
||||
|
||||
def _missing_sdk(provider: str):
|
||||
raise ImportError("Please install the `cohere` package")
|
||||
|
||||
monkeypatch.setattr(pydantic_ai.providers, "infer_provider_class", _missing_sdk)
|
||||
result = get_model(ModelConfig(provider="cohere", name="command-r"))
|
||||
|
||||
assert result == "cohere:command-r"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["nonsense", "vllm", "gemini"])
|
||||
def test_get_model_rejects_unknown_provider(provider):
|
||||
"""An unknown provider is named here rather than passed through to fail
|
||||
inside pydantic-ai, where nothing identifies the config it came from.
|
||||
|
||||
`vllm` and `gemini` get no special case: both were haiku.rag's own
|
||||
vocabulary, and both fail the same way as a typo.
|
||||
"""
|
||||
with pytest.raises(ValueError, match=provider):
|
||||
get_model(ModelConfig(provider=provider, name="whatever"))
|
||||
|
||||
|
||||
def test_get_package_versions():
|
||||
"""Test get_package_versions returns expected keys."""
|
||||
from haiku.rag.utils import get_package_versions
|
||||
|
|
|
|||
Loading…
Reference in a new issue