Model providers: OpenAI/xAI/Ollama + run several at once (registry)
Extends the #215 abstraction: - OpenAICompatibleLLMProvider / OpenAICompatibleEmbeddingProvider — one impl (via the official openai SDK) covers OpenAI, xAI (api.x.ai/v1), Ollama (…:11434/v1), OpenRouter, etc.; they differ only by base_url, key, and model. - Registry factory: build_llm_providers() / build_embedding_providers() return every provider whose credentials are configured, so you can run several concurrently. get_llm_provider(name)/get_embedding_provider(name) select by name, falling back to default_*_provider, then Null. - Per-provider env config (ANTHROPIC_*, OPENAI_*, XAI_*, OLLAMA_*) + DEFAULT_LLM_PROVIDER / DEFAULT_EMBEDDING_PROVIDER; documented in .env.example. Defaults keep AI off (empty registry). Embeddings now have real backends (OpenAI/Ollama), still separate from the LLM since Anthropic offers no embeddings endpoint. Tests cover multi-provider selection, default resolution, disabled-without-credentials, and null fail-loud. Full suite 87 passed. Relates to #215. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -1,43 +1,84 @@
|
||||
"""Model-provider selection + the null-provider fail-loud behavior.
|
||||
|
||||
No network: we only assert which provider the factory returns and that the null
|
||||
providers raise a clear error. (Live LLM/embedding calls aren't unit-tested.)
|
||||
"""Model-provider registry: configure several vendors at once, select by name,
|
||||
default selection, and the null fail-loud behavior. No network — we only assert
|
||||
which provider the factory returns and that null providers raise.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_embedding_provider, get_llm_provider
|
||||
from app.api.deps import (
|
||||
build_embedding_providers,
|
||||
build_llm_providers,
|
||||
get_embedding_provider,
|
||||
get_llm_provider,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.integrations.models.anthropic_provider import AnthropicLLMProvider
|
||||
from app.integrations.models.base import ModelProviderNotConfigured
|
||||
from app.integrations.models.null import NullEmbeddingProvider, NullLLMProvider
|
||||
from app.integrations.models.openai_compat import (
|
||||
OpenAICompatibleEmbeddingProvider,
|
||||
OpenAICompatibleLLMProvider,
|
||||
)
|
||||
|
||||
|
||||
async def test_default_llm_is_null_and_fails_loud(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "model_provider", "null")
|
||||
def _reset(monkeypatch):
|
||||
s = get_settings()
|
||||
for attr, val in {
|
||||
"default_llm_provider": "null",
|
||||
"default_embedding_provider": "null",
|
||||
"anthropic_api_key": None,
|
||||
"openai_api_key": None,
|
||||
"xai_api_key": None,
|
||||
"ollama_enabled": False,
|
||||
}.items():
|
||||
monkeypatch.setattr(s, attr, val)
|
||||
return s
|
||||
|
||||
|
||||
async def test_default_is_null_and_fails_loud(monkeypatch):
|
||||
_reset(monkeypatch)
|
||||
provider = get_llm_provider()
|
||||
assert isinstance(provider, NullLLMProvider)
|
||||
with pytest.raises(ModelProviderNotConfigured):
|
||||
await provider.complete(prompt="hello")
|
||||
assert isinstance(get_embedding_provider(), NullEmbeddingProvider)
|
||||
|
||||
|
||||
async def test_anthropic_selected_when_configured(monkeypatch):
|
||||
s = get_settings()
|
||||
monkeypatch.setattr(s, "model_provider", "anthropic")
|
||||
monkeypatch.setattr(s, "anthropic_api_key", "sk-ant-test-key")
|
||||
monkeypatch.setattr(s, "llm_model", "claude-opus-4-8")
|
||||
assert isinstance(get_llm_provider(), AnthropicLLMProvider) # no network call
|
||||
async def test_multiple_llm_providers_at_once(monkeypatch):
|
||||
s = _reset(monkeypatch)
|
||||
monkeypatch.setattr(s, "anthropic_api_key", "sk-ant-x")
|
||||
monkeypatch.setattr(s, "openai_api_key", "sk-openai-x")
|
||||
monkeypatch.setattr(s, "xai_api_key", "xai-x")
|
||||
monkeypatch.setattr(s, "ollama_enabled", True)
|
||||
monkeypatch.setattr(s, "default_llm_provider", "anthropic")
|
||||
|
||||
registry = build_llm_providers()
|
||||
assert set(registry) == {"anthropic", "openai", "xai", "ollama"}
|
||||
# Select any by name.
|
||||
assert isinstance(get_llm_provider("anthropic"), AnthropicLLMProvider)
|
||||
assert isinstance(get_llm_provider("openai"), OpenAICompatibleLLMProvider)
|
||||
assert isinstance(get_llm_provider("xai"), OpenAICompatibleLLMProvider)
|
||||
assert isinstance(get_llm_provider("ollama"), OpenAICompatibleLLMProvider)
|
||||
# Default resolves to the configured default.
|
||||
assert isinstance(get_llm_provider(), AnthropicLLMProvider)
|
||||
# Unknown name → null.
|
||||
assert isinstance(get_llm_provider("nope"), NullLLMProvider)
|
||||
|
||||
|
||||
async def test_anthropic_without_key_falls_back_to_null(monkeypatch):
|
||||
s = get_settings()
|
||||
monkeypatch.setattr(s, "model_provider", "anthropic")
|
||||
monkeypatch.setattr(s, "anthropic_api_key", None)
|
||||
async def test_provider_disabled_without_credentials(monkeypatch):
|
||||
s = _reset(monkeypatch)
|
||||
monkeypatch.setattr(s, "default_llm_provider", "openai") # default names openai…
|
||||
# …but no openai key → registry empty → null fallback.
|
||||
assert build_llm_providers() == {}
|
||||
assert isinstance(get_llm_provider(), NullLLMProvider)
|
||||
|
||||
|
||||
async def test_embedding_default_is_null_and_fails_loud():
|
||||
provider = get_embedding_provider()
|
||||
assert isinstance(provider, NullEmbeddingProvider)
|
||||
with pytest.raises(ModelProviderNotConfigured):
|
||||
await provider.embed(["text"])
|
||||
async def test_embedding_providers(monkeypatch):
|
||||
s = _reset(monkeypatch)
|
||||
monkeypatch.setattr(s, "openai_api_key", "sk-openai-x")
|
||||
monkeypatch.setattr(s, "ollama_enabled", True)
|
||||
monkeypatch.setattr(s, "default_embedding_provider", "openai")
|
||||
registry = build_embedding_providers()
|
||||
assert set(registry) == {"openai", "ollama"}
|
||||
assert isinstance(get_embedding_provider(), OpenAICompatibleEmbeddingProvider)
|
||||
assert isinstance(get_embedding_provider("ollama"), OpenAICompatibleEmbeddingProvider)
|
||||
|
||||
Reference in New Issue
Block a user