Skip to content

Commit 5827b36

Browse files
authored
Merge pull request #2960 from asimurka/LCORE-4703-openai-inference-provider
LCORE-4703: Implemented OpenAI inference provider
2 parents 9794767 + 6d2a7e8 commit 5827b36

8 files changed

Lines changed: 341 additions & 1 deletion

File tree

‎src/providers/inference/base.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ def __init__(self, entry: EntryT) -> None:
4141
self._models: list[CatalogModel] = []
4242
self._entry: EntryT = entry
4343

44+
@property
45+
def _get_provider(self) -> ProviderT:
46+
"""Return the initialized PAI provider.
47+
48+
Raises:
49+
RuntimeError: If the provider has not been initialized or does not
50+
use a PAI provider instance.
51+
52+
"""
53+
if self._provider is None:
54+
raise RuntimeError(
55+
f"Provider {self.provider_id!r} has not been initialized"
56+
)
57+
return self._provider
58+
4459
async def initialize(self) -> None:
4560
"""Build the PydanticAI provider and load the model catalog.
4661
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Helpers for inferring catalog model types from known model ids."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional, TypeAliasType, get_args
6+
7+
from pydantic_ai.embeddings import KnownEmbeddingModelName
8+
from pydantic_ai.models import KnownModelName
9+
10+
from providers.inference.types import ModelType
11+
12+
13+
def _model_ids_from_known_names(known_names: TypeAliasType) -> frozenset[str]:
14+
"""Extract unprefixed model ids from a Pydantic AI known-name type alias.
15+
16+
Args:
17+
known_names: A Pydantic AI known-name type alias.
18+
19+
Returns:
20+
The set of model ids with provider prefixes removed.
21+
22+
"""
23+
return frozenset(name.split(":", 1)[-1] for name in get_args(known_names.__value__))
24+
25+
26+
KNOWN_LLM_IDS: frozenset[str] = _model_ids_from_known_names(KnownModelName)
27+
KNOWN_EMBEDDING_IDS: frozenset[str] = _model_ids_from_known_names(
28+
KnownEmbeddingModelName
29+
)
30+
31+
32+
def infer_model_type_from_pai_known_names(
33+
provider_model_id: str,
34+
) -> Optional[ModelType]:
35+
"""Infer model type from Pydantic AI's known model names.
36+
37+
Args:
38+
provider_model_id: Model id as reported by the inference provider.
39+
40+
Returns:
41+
The inferred model type, or None if the model id is unknown.
42+
43+
"""
44+
if provider_model_id in KNOWN_EMBEDDING_IDS:
45+
return "embedding"
46+
if provider_model_id in KNOWN_LLM_IDS:
47+
return "llm"
48+
return None
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""OpenAI inference provider package."""
2+
3+
from providers.inference.openai.provider import OpenAIInferenceProvider
4+
5+
__all__ = ["OpenAIInferenceProvider"]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""OpenAI runtime inference provider backed by Pydantic AI."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydantic_ai.embeddings import EmbeddingModel
8+
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
9+
from pydantic_ai.models import Model
10+
from pydantic_ai.models.openai import OpenAIResponsesModel
11+
from pydantic_ai.providers.openai import OpenAIProvider
12+
from sentence_transformers import CrossEncoder
13+
14+
from models.config import OpenAIInferenceProviderEntry
15+
from providers.inference.base import InferenceProvider
16+
from providers.inference.catalog import CatalogModel, DiscoveredModel
17+
from providers.inference.errors import ModelTypeNotSupportedError
18+
from providers.inference.model_type_inference import (
19+
infer_model_type_from_pai_known_names,
20+
)
21+
from providers.inference.types import ModelType
22+
23+
24+
class OpenAIInferenceProvider(
25+
InferenceProvider[OpenAIInferenceProviderEntry, OpenAIProvider]
26+
):
27+
"""Inference provider for OpenAI HTTP APIs."""
28+
29+
def _build_provider(self) -> OpenAIProvider:
30+
"""Build the Pydantic AI OpenAI provider from configuration.
31+
32+
Returns:
33+
A Pydantic AI OpenAI provider using the configured API key and
34+
base URL.
35+
36+
"""
37+
config = self._entry.config
38+
api_key = (
39+
config.api_key.get_secret_value() if config.api_key is not None else None
40+
)
41+
return OpenAIProvider(api_key=api_key, base_url=config.base_url)
42+
43+
async def _discover_models(self) -> list[DiscoveredModel]:
44+
"""List models from the OpenAI models endpoint.
45+
46+
Returns:
47+
Discovered models with inferred types, before allow-list filtering.
48+
49+
"""
50+
response = await self._get_provider.client.models.list()
51+
return [
52+
DiscoveredModel(
53+
provider_model_id=model.id,
54+
model_type=self._infer_model_type(model.id),
55+
)
56+
for model in response.data
57+
]
58+
59+
def _create_llm(self, model: CatalogModel) -> Model:
60+
"""Create a Responses API chat model for a catalog entry.
61+
62+
Args:
63+
model: Catalog entry describing the LLM to instantiate.
64+
65+
Returns:
66+
A Pydantic AI OpenAI Responses model bound to this provider.
67+
68+
"""
69+
return OpenAIResponsesModel(
70+
model.provider_model_id,
71+
provider=self._get_provider,
72+
)
73+
74+
def _create_embedding(self, model: CatalogModel) -> EmbeddingModel:
75+
"""Create an embedding model for a catalog entry.
76+
77+
Args:
78+
model: Catalog entry describing the embedding model to instantiate.
79+
80+
Returns:
81+
A Pydantic AI OpenAI embedding model bound to this provider.
82+
83+
"""
84+
return OpenAIEmbeddingModel(
85+
model.provider_model_id,
86+
provider=self._get_provider,
87+
)
88+
89+
def _create_rerank(self, model: CatalogModel) -> CrossEncoder:
90+
"""Reject rerank requests; this provider has no rerank API.
91+
92+
Args:
93+
model: Catalog entry that requested rerank support.
94+
95+
Raises:
96+
ModelTypeNotSupportedError: Always, because rerank is unsupported.
97+
98+
"""
99+
_ = model
100+
raise ModelTypeNotSupportedError("rerank", self.provider_id)
101+
102+
def _infer_model_type(
103+
self,
104+
provider_model_id: str,
105+
**kwargs: Any,
106+
) -> ModelType:
107+
"""Infer model type from Pydantic AI known LLM and embedding names.
108+
109+
Args:
110+
provider_model_id: Model id from OpenAI or from an allow-list entry.
111+
**kwargs: Unused; accepted for base-class compatibility.
112+
113+
Returns:
114+
embedding when the id is a known embedding model, otherwise llm.
115+
116+
"""
117+
_ = kwargs
118+
return infer_model_type_from_pai_known_names(provider_model_id) or "llm"

‎src/providers/inference/registry.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
from providers.inference.base import InferenceProvider
99
from providers.inference.catalog import CatalogModel
1010
from providers.inference.errors import ProviderNotFoundError
11+
from providers.inference.openai.provider import OpenAIInferenceProvider
1112
from providers.inference.types import ModelType, ProviderId, ProviderType
1213
from utils.types import Singleton
1314

14-
PROVIDER_CLASSES: Final[dict[ProviderType, type[InferenceProvider[Any, Any]]]] = {}
15+
PROVIDER_CLASSES: Final[dict[ProviderType, type[InferenceProvider[Any, Any]]]] = {
16+
"openai": OpenAIInferenceProvider,
17+
}
1518

1619

1720
class ProviderRegistry(metaclass=Singleton):
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit tests for the OpenAI inference provider."""
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Unit tests for the OpenAI inference provider."""
2+
3+
# pylint: disable=redefined-outer-name,protected-access
4+
5+
from typing import Any
6+
7+
import pytest
8+
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
9+
from pydantic_ai.models.openai import OpenAIResponsesModel
10+
from pydantic_ai.providers.openai import OpenAIProvider
11+
12+
from models.config import OpenAIInferenceProviderEntry
13+
from providers.inference.catalog import CatalogModel
14+
from providers.inference.errors import ModelTypeNotSupportedError
15+
from providers.inference.openai.provider import OpenAIInferenceProvider
16+
17+
18+
@pytest.fixture
19+
def provider() -> OpenAIInferenceProvider:
20+
"""Create an OpenAI provider."""
21+
entry = OpenAIInferenceProviderEntry.model_validate(
22+
{
23+
"type": "openai",
24+
"id": "openai",
25+
"config": {
26+
"api_key": "test-key",
27+
"base_url": "https://example.test/v1",
28+
},
29+
}
30+
)
31+
return OpenAIInferenceProvider(entry)
32+
33+
34+
def test_build_provider_uses_config(provider: OpenAIInferenceProvider) -> None:
35+
"""Builds a Pydantic AI provider with configured credentials and URL."""
36+
pai_provider = provider._build_provider()
37+
38+
assert isinstance(pai_provider, OpenAIProvider)
39+
assert str(pai_provider.client.base_url).rstrip("/") == "https://example.test/v1"
40+
assert pai_provider.client.api_key == "test-key"
41+
42+
43+
@pytest.mark.asyncio
44+
@pytest.mark.parametrize(
45+
("model_id", "expected_type"),
46+
[
47+
("gpt-4o-mini", "llm"),
48+
("text-embedding-3-small", "embedding"),
49+
("custom-model", "llm"),
50+
],
51+
)
52+
async def test_discover_models_infers_model_types(
53+
provider: OpenAIInferenceProvider,
54+
mocker: Any,
55+
model_id: str,
56+
expected_type: str,
57+
) -> None:
58+
"""Discovers OpenAI model IDs and applies the model-type inference fallback."""
59+
response = mocker.MagicMock()
60+
response.data = [mocker.MagicMock(id=model_id)]
61+
62+
pai_provider = mocker.MagicMock(spec=OpenAIProvider)
63+
pai_provider.client.models.list = mocker.AsyncMock(return_value=response)
64+
provider._provider = pai_provider
65+
66+
discovered = await provider._discover_models()
67+
68+
assert len(discovered) == 1
69+
assert discovered[0].provider_model_id == model_id
70+
assert discovered[0].model_type == expected_type
71+
pai_provider.client.models.list.assert_awaited_once_with()
72+
73+
74+
def test_create_llm_uses_openai_responses_model(
75+
provider: OpenAIInferenceProvider,
76+
) -> None:
77+
"""Creates a Pydantic AI Responses model using the provider model ID."""
78+
pai_provider = provider._build_provider()
79+
provider._provider = pai_provider
80+
81+
model = provider._create_llm(
82+
CatalogModel(
83+
name="chat",
84+
provider_model_id="gpt-4o-mini",
85+
model_type="llm",
86+
)
87+
)
88+
89+
assert isinstance(model, OpenAIResponsesModel)
90+
assert model.model_name == "gpt-4o-mini"
91+
assert model.provider is pai_provider
92+
93+
94+
def test_create_embedding_uses_openai_embedding_model(
95+
provider: OpenAIInferenceProvider,
96+
) -> None:
97+
"""Creates a Pydantic AI embedding model using the provider model ID."""
98+
pai_provider = provider._build_provider()
99+
provider._provider = pai_provider
100+
101+
model = provider._create_embedding(
102+
CatalogModel(
103+
name="embed",
104+
provider_model_id="text-embedding-3-small",
105+
model_type="embedding",
106+
)
107+
)
108+
109+
assert isinstance(model, OpenAIEmbeddingModel)
110+
assert model.model_name == "text-embedding-3-small"
111+
assert model._provider is pai_provider
112+
113+
114+
def test_create_rerank_is_unsupported(
115+
provider: OpenAIInferenceProvider,
116+
) -> None:
117+
"""Rejects reranking because the OpenAI provider does not support it."""
118+
with pytest.raises(ModelTypeNotSupportedError) as exc_info:
119+
provider._create_rerank(
120+
CatalogModel(
121+
name="rerank",
122+
provider_model_id="rerank-model",
123+
model_type="rerank",
124+
)
125+
)
126+
127+
assert exc_info.value.model_type == "rerank"
128+
assert exc_info.value.provider_id == "openai"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Unit tests for model type inference helpers."""
2+
3+
from providers.inference.model_type_inference import (
4+
KNOWN_EMBEDDING_IDS,
5+
KNOWN_LLM_IDS,
6+
infer_model_type_from_pai_known_names,
7+
)
8+
9+
10+
def test_known_ids_include_openai_model_ids() -> None:
11+
"""PAI known-name tables are exposed as prefix-stripped model ids."""
12+
assert "gpt-4o-mini" in KNOWN_LLM_IDS
13+
assert "text-embedding-3-small" in KNOWN_EMBEDDING_IDS
14+
assert "openai:gpt-4o-mini" not in KNOWN_LLM_IDS
15+
16+
17+
def test_infer_model_type_from_pai_known_names() -> None:
18+
"""Known ids map to embedding or llm; unknown ids return None."""
19+
assert (
20+
infer_model_type_from_pai_known_names("text-embedding-3-small") == "embedding"
21+
)
22+
assert infer_model_type_from_pai_known_names("gpt-4o-mini") == "llm"
23+
assert infer_model_type_from_pai_known_names("totally-custom-chat") is None

0 commit comments

Comments
 (0)