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: 4 additions & 0 deletions src/providers/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Helpers for inferring catalog model types from known model ids.

Singleton registry of initialized inference providers.

## [resolve.py](resolve.py)

Soft-fallback helpers for request-path model resolution.

## [types.py](types.py)

Shared literals and aliases for the inference provider registry.
Expand Down
18 changes: 17 additions & 1 deletion src/providers/inference/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ModelNotAvailableError(Exception):
def __init__(self, name: str, model_type: str, provider_id: str) -> None:
"""Initialize with the unavailable model identity.

Parameters:
Args:
name: Local catalog model name that was requested.
model_type: Requested model type (llm, embedding, or rerank).
provider_id: Provider that rejected the request.
Expand Down Expand Up @@ -54,3 +54,19 @@ def __init__(self, provider_id: ProviderId) -> None:
"""
super().__init__(f"Inference provider {provider_id!r} is not registered")
self.provider_id = provider_id


class NoModelConfiguredError(Exception):
"""Raised when no model can be resolved after soft fallbacks."""

def __init__(self, model_type: ModelType) -> None:
"""Initialize with the unresolved model capability.

Args:
model_type: Model type that could not be resolved.

"""
super().__init__(
f"No {model_type!r} model is configured or available in the catalog"
)
self.model_type = model_type
137 changes: 135 additions & 2 deletions src/providers/inference/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Final, Optional
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, overload

from pydantic_ai.embeddings import EmbeddingModel
from pydantic_ai.models import Model
from sentence_transformers import CrossEncoder

from providers.inference.azure.provider import AzureInferenceProvider
from providers.inference.bedrock.provider import BedrockInferenceProvider
from providers.inference.errors import ProviderNotFoundError
from providers.inference.errors import (
NoModelConfiguredError,
ProviderNotFoundError,
)
from providers.inference.openai.provider import OpenAIInferenceProvider
from providers.inference.resolve import configured_defaults, conversation_last_used
from providers.inference.vertexai.provider import VertexAIInferenceProvider
from providers.inference.watsonx.provider import WatsonxInferenceProvider
from utils.types import Singleton
Expand Down Expand Up @@ -100,3 +108,128 @@ def list_models(
for provider in self._providers.values():
models.extend(provider.list_catalog_models(model_type=model_type))
return models

@overload
def resolve_model_instance(
self,
model_type: Literal["llm"],
provider_id: Optional[ProviderId] = None,
model_id: Optional[str] = None,
conversation_id: Optional[str] = None,
) -> Model: ...

@overload
def resolve_model_instance(
self,
model_type: Literal["embedding"],
provider_id: ProviderId,
model_id: str,
) -> EmbeddingModel: ...

@overload
def resolve_model_instance(
self,
model_type: Literal["rerank"],
provider_id: ProviderId,
model_id: str,
) -> CrossEncoder: ...

def resolve_model_instance(
self,
model_type: ModelType,
provider_id: Optional[ProviderId] = None,
model_id: Optional[str] = None,
conversation_id: Optional[str] = None,
) -> Model | EmbeddingModel | CrossEncoder:
"""Resolve a typed model instance from the cached provider catalog.

Embedding and rerank require both ``provider_id`` and ``model_id``.
For llm those ids may be omitted; resolution then falls through
conversation last-used, configured defaults, then the first available llm.

Args:
model_type: Required model capability to resolve.
provider_id: Local provider id; required for embedding and rerank.
model_id: Local catalog model name; required for embedding and rerank.
conversation_id: Optional conversation used for llm last-used fallback.

Returns:
A per-request Model, EmbeddingModel, or CrossEncoder instance.

Raises:
ProviderNotFoundError: Explicit provider id is not registered.
ModelNotAvailableError: Explicit model is missing from the catalog.
NoModelConfiguredError: No llm remains after soft fallbacks.

"""
if provider_id is not None and model_id is not None:
Comment thread
asimurka marked this conversation as resolved.
Resolved
Comment thread
asimurka marked this conversation as resolved.
Resolved
return self._get_model_instance(provider_id, model_id, model_type)
Comment thread
asimurka marked this conversation as resolved.
Resolved

# Soft fallbacks (llm only): conversation last-used → defaults → first llm.
if last_used := conversation_last_used(conversation_id):
last_provider_id, last_model_id = last_used
if self._is_llm_available(last_provider_id, last_model_id):
return self._get_model_instance(last_provider_id, last_model_id, "llm")

if defaults := configured_defaults():
default_provider_id, default_model_id = defaults
if self._is_llm_available(default_provider_id, default_model_id):
return self._get_model_instance(
default_provider_id, default_model_id, "llm"
)

if instance := self._first_available_llm():
return instance

raise NoModelConfiguredError("llm")

def _get_model_instance(
self,
provider_id: ProviderId,
model_id: str,
model_type: ModelType,
) -> Model | EmbeddingModel | CrossEncoder:
"""Get a typed model instance from a registered provider.

Args:
provider_id: Local provider id.
model_id: Local catalog model name.
model_type: Required model capability.

Returns:
The instantiated model for the requested type.

Raises:
ProviderNotFoundError: Provider id is not registered.
ModelNotAvailableError: Model id is not in the catalog.

"""
return self.get_provider(provider_id).get_model_instance(model_id, model_type)

def _is_llm_available(self, provider_id: ProviderId, model_id: str) -> bool:
"""Return whether an llm catalog pair exists for the provider.

Args:
provider_id: Candidate provider id.
model_id: Candidate local catalog model name.

Returns:
True when the provider is registered and exposes the llm.

"""
if provider := self.find_provider(provider_id):
return provider.get_catalog_model(model_id, "llm") is not None
return False

def _first_available_llm(self) -> Optional[Model]:
"""Return the first llm instance from registration order.

Returns:
The first available llm instance, or None when the catalog has none.

"""
for provider in self.list_providers():
models = provider.list_catalog_models(model_type="llm")
if models:
return provider.get_model_instance(models[0].name, "llm")
return None
Comment thread
asimurka marked this conversation as resolved.
Resolved
60 changes: 60 additions & 0 deletions src/providers/inference/resolve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Soft-fallback helpers for request-path model resolution."""

from __future__ import annotations

from typing import Optional

from configuration import configuration
from log import get_logger
from providers.inference.types import ProviderId
from utils.endpoints import retrieve_conversation

logger = get_logger(__name__)


def conversation_last_used(
conversation_id: Optional[str],
) -> Optional[tuple[ProviderId, str]]:
"""Load last-used provider/model ids from a conversation when present.

Args:
conversation_id: Conversation id to look up, or None to skip.

Returns:
``(provider_id, model_id)`` from the conversation, or None when the
conversation id is omitted or not found.

"""
if conversation_id is None:
return None

conversation = retrieve_conversation(conversation_id)
Comment thread
asimurka marked this conversation as resolved.
Resolved
Comment thread
asimurka marked this conversation as resolved.
Resolved
if conversation is None:
logger.warning(
"Conversation %r not found while resolving llm; soft-skipping "
"last-used fallback",
conversation_id,
)
return None

return conversation.last_used_provider, conversation.last_used_model


def configured_defaults() -> Optional[tuple[ProviderId, str]]:
"""Return configured default provider/model ids when both are set.

Returns:
``(provider_id, model_id)`` from inference configuration, or None
when either default is unset.

"""
inference = configuration.inference
provider_id = inference.default_provider
model_id = inference.default_model
if not provider_id or not model_id:
logger.warning(
"Inference default provider/model unset; soft-skipping configured "
"default fallback"
)
return None
return provider_id, model_id
4 changes: 4 additions & 0 deletions tests/unit/providers/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ Unit tests for model type inference helpers.

Unit tests for the ProviderRegistry singleton.

## [test_resolve.py](test_resolve.py)

Unit tests for soft-fallback resolution helpers.

4 changes: 2 additions & 2 deletions tests/unit/providers/inference/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ async def _discover_models(self) -> list[DiscoveredModel]:
return []

def _create_llm(self, model: CatalogModel) -> Model:
"""Return a PydanticAI test chat model."""
return TestModel()
"""Return a PydanticAI test chat model tagged with the catalog name."""
return TestModel(model_name=model.name)

def _create_embedding(self, model: CatalogModel) -> EmbeddingModel:
"""Return a PydanticAI test embedding model."""
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/providers/inference/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from providers.inference.errors import (
ModelNotAvailableError,
ModelTypeNotSupportedError,
NoModelConfiguredError,
ProviderNotFoundError,
)
from providers.inference.types import ModelType
Expand Down Expand Up @@ -41,6 +42,14 @@ def test_unknown_provider_type_error_message_and_attributes() -> None:
assert str(error) == "Inference provider 'fake-openai' is not registered"


def test_no_model_configured_error_message_and_attributes() -> None:
"""Error message and attributes identify the unresolved model type."""
error = NoModelConfiguredError("llm")

assert error.model_type == "llm"
assert str(error) == ("No 'llm' model is configured or available in the catalog")


@pytest.mark.parametrize("model_type", ["llm", "embedding", "rerank"])
def test_get_model_instance_raises_model_not_available_error(
model_type: ModelType,
Expand Down
Loading
Loading