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
8 changes: 2 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ dependencies = [
# Used by authorization resolvers
"jsonpath-ng>=1.6.1,<1.9.0",
"psycopg2-binary>=2.9.10",
# LCORE-1565
"litellm>=1.83.7",
"litellm==1.107.0rc2",
"urllib3>=2.6.3",
# Used for agent card configuration
"PyYAML>=6.0.0",
Expand Down Expand Up @@ -86,7 +85,7 @@ dependencies = [
# Used for model context windows in conversation compaction (LCORE-4765)
"genai-prices>=0.1.12",
# Used for Pydantic AI
"pydantic-ai==2.27.1",
"pydantic-ai==2.38.0",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required for vLLM provider

"pydantic-ai-skills>=0.11.0",
# Used for OpenTelemetry instrumentation
"opentelemetry-distro>=0.49b0",
Expand Down Expand Up @@ -207,9 +206,6 @@ ogxlibdev = [
"peft>=0.15.2",
# API inference: remote::vertexai
"google-cloud-aiplatform>=1.130.0",
# API inference: remote::watsonx
# LCORE-1565
"litellm>=1.88.6",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WatsonX no longer uses litellm dependency, litellm is already present in root deps.

# Other
"autoevals>=0.0.129",
"fire>=0.7.0",
Expand Down
3 changes: 2 additions & 1 deletion src/providers/inference/watsonx/pai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@
base_url or os.getenv("WATSONX_BASE_URL") or DEFAULT_WATSONX_BASE_URL
)
self._base_url = resolved_base_url.rstrip("/")
# OpenAI 3 types http_client as httpx2 only; legacy httpx still works.
self._client = AsyncOpenAI(
api_key="unused",
base_url=f"{self._base_url}/ml/v1",
default_query={"version": WATSONX_API_VERSION},
http_client=httpx.AsyncClient(auth=WatsonxIAMAuth(api_key)),
http_client=httpx.AsyncClient(auth=WatsonxIAMAuth(api_key)), # type: ignore[arg-type]
Comment thread
asimurka marked this conversation as resolved.
Dismissed
)

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ def __post_init__(self) -> None:
timeout=self.config.timeout,
)

# OpenAI 3 types http_client as httpx2 only; legacy httpx still works.
openai_client = AsyncOpenAI(
base_url=self.config.url,
api_key=(
Expand All @@ -404,7 +405,7 @@ def __post_init__(self) -> None:
else "api-key-not-set"
),
max_retries=self.config.max_retries,
http_client=http_client,
http_client=http_client, # type: ignore[arg-type]
)

provider = OpenAIProvider(openai_client=openai_client)
Expand Down
11 changes: 9 additions & 2 deletions src/pydantic_ai_lightspeed/capabilities/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Optional

from pydantic_ai.messages import TextContent, UserContent
from pydantic_ai.models import Model
from pydantic_ai.models import AbstractModel, Model


def extract_message_str_from_user_content(
Expand Down Expand Up @@ -49,21 +49,28 @@ def message_to_str(message: Optional[str | Sequence[UserContent]]) -> str:
return ""


def extract_conversation_id(model: Model) -> Optional[str]:
def extract_conversation_id(model: AbstractModel) -> Optional[str]:
"""Extract the conversation ID from the agent's model settings.

The main agent's model is built with ``conversation`` in its
``extra_body`` model settings (see ``OgxResponsesModel.from_ogx_client``).
This pulls it back out so the capability can persist the rejected turn
to the same conversation.

``RunContext.model`` is typed as ``AbstractModel`` (no settings). Request-
response models are ``Model`` subclasses that expose typed ``settings``;
realtime models and other non-``Model`` identities return ``None``.

Parameters:
model: The model bound to the current agent run (``ctx.model``).

Returns:
The conversation ID, or None if the model has no such setting.

"""
if not isinstance(model, Model):
return None

extra_body = (model.settings or {}).get("extra_body")
if not isinstance(extra_body, dict):
return None
Expand Down
3 changes: 2 additions & 1 deletion src/pydantic_ai_lightspeed/ogx/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ async def request_stream( # pylint: disable=unused-argument
responses.ResponseStreamEvent, _FilteredResponseStream
] = PeekableAsyncStream(filtered_stream)

with _map_api_errors(self.model_name):
with _map_api_errors(self.model_name, self._provider.model_id_namespace):
first_chunk = await peekable.peek()

if isinstance(first_chunk, Unset):
Expand All @@ -507,6 +507,7 @@ async def request_stream( # pylint: disable=unused-argument
_model_settings=model_settings_cast,
_response=peekable, # type: ignore[arg-type]
_provider_name=self._provider.name,
_model_id_namespace=self._provider.model_id_namespace,
_provider_url=self._provider.base_url,
_provider_timestamp=(
number_to_datetime(first_chunk.response.created_at)
Expand Down
18 changes: 10 additions & 8 deletions src/pydantic_ai_lightspeed/ogx/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ogx_client import AsyncOgxClient
from openai import AsyncOpenAI
from pydantic_ai import ModelProfile
from pydantic_ai._http import AsyncHTTPClient

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type union for httpx and httpx2 clients

from pydantic_ai.models import create_async_http_client
from pydantic_ai.profiles.openai import openai_model_profile
from pydantic_ai.providers import Provider
Expand Down Expand Up @@ -88,7 +89,7 @@ def __init__(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
library_client: Optional[AsyncOGXAsLibraryClient] = None,
http_client: Optional[httpx.AsyncClient] = None,
http_client: Optional[AsyncHTTPClient] = None,
) -> None:
"""Create a new OGX provider.

Expand All @@ -102,8 +103,8 @@ def __init__(
library_client: An initialized ``AsyncOGXAsLibraryClient`` for library mode.
When provided, requests are dispatched in-process (no server needed).
Mutually exclusive with ``base_url``, ``api_key``, and ``http_client``.
http_client: An existing ``httpx.AsyncClient`` to use for making HTTP requests.
Must be ``None`` when ``library_client`` is provided.
http_client: An existing ``httpx``/``httpx2`` async client for HTTP
requests. Must be ``None`` when ``library_client`` is provided.

"""
if library_client is not None:
Expand All @@ -123,8 +124,9 @@ def __init__(
base_url="http://ogx-library",
timeout=httpx.Timeout(None),
)
# OpenAI 3 types http_client as httpx2 only; legacy httpx still works.
self._client = AsyncOpenAI(
http_client=lib_http_client,
http_client=lib_http_client, # type: ignore[arg-type]
base_url="http://ogx-library/v1",
api_key="not-needed",
)
Expand All @@ -135,15 +137,15 @@ def __init__(
self._client = AsyncOpenAI(
base_url=base_url,
api_key=api_key,
http_client=http_client or create_async_http_client(),
http_client=http_client or create_async_http_client(), # type: ignore[arg-type]
)

def __repr__(self) -> str:
"""Return a string representation of the provider."""
return f"OgxProvider(name={self.name!r}, base_url={self.base_url!r})"

def _set_http_client(self, http_client: httpx.AsyncClient) -> None:
"""Inject an httpx.AsyncClient into the underlying OpenAI client.
def _set_http_client(self, http_client: AsyncHTTPClient) -> None:
"""Inject an async HTTP client into the underlying OpenAI client.

Replaces the internal HTTP transport by assigning directly to the
protected ``self._client._client`` attribute of the AsyncOpenAI instance.
Expand All @@ -152,4 +154,4 @@ def _set_http_client(self, http_client: httpx.AsyncClient) -> None:
http_client: The async HTTP client to use for subsequent requests.

"""
self._client._client = http_client # pyright: ignore[reportPrivateUsage] # pylint: disable=protected-access
self._client._client = http_client # type: ignore[assignment] # pylint: disable=protected-access
6 changes: 5 additions & 1 deletion tests/unit/providers/inference/azure/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ def _patch_entra_dependencies(mocker: Any) -> tuple[Any, Any, Any]:
"providers.inference.azure.provider.get_bearer_token_provider",
return_value=token_provider,
)
# PAI AzureProvider(openai_client=...) reads api_key and base_url at init.
openai_client = mocker.MagicMock(spec=AsyncAzureOpenAI)
openai_client.api_key = None
openai_client.base_url = AZURE_ENDPOINT
client = mocker.patch(
"providers.inference.azure.provider.AsyncAzureOpenAI",
return_value=mocker.MagicMock(spec=AsyncAzureOpenAI),
return_value=openai_client,
)
return credential, get_token_provider, client

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pydantic_ai import AgentRunResult, RunContext
from pydantic_ai.exceptions import ModelAPIError, UnexpectedModelBehavior
from pydantic_ai.messages import ToolCallPart, ToolReturnPart
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.usage import RequestUsage, RunUsage
from pytest_mock import MockerFixture, MockType
Expand Down Expand Up @@ -405,8 +406,7 @@ def mock_ctx_fixture(self, mocker: MockerFixture) -> RunContext:
ctx = mocker.Mock(spec=RunContext)
ctx.prompt = "How do I create a pod?"
ctx.usage = RunUsage()
ctx.model = mocker.Mock()
ctx.model.settings = {"extra_body": {"conversation": "conv_test"}}
ctx.model = TestModel(settings={"extra_body": {"conversation": "conv_test"}})
return ctx

@pytest.fixture(name="mock_handler")
Expand Down Expand Up @@ -501,7 +501,7 @@ async def test_violation_skips_persistence_when_no_conversation_id(
mock_append_turn: MockType,
) -> None:
"""Test that persistence is skipped without a conversation ID."""
mock_ctx.model = mocker.Mock(settings={})
mock_ctx.model = TestModel(settings={})
mocker.patch(
f"{_MODULE}._run_risk_check",
return_value=(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pydantic_ai import AgentRunResult, RunContext
from pydantic_ai.messages import ImageUrl, ModelResponse, TextContent, TextPart
from pydantic_ai.models.openai import OpenAIResponsesModelSettings
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RequestUsage, RunUsage
from pytest_mock import MockerFixture, MockType

Expand Down Expand Up @@ -71,40 +72,39 @@ def test_sequence_with_non_text_content(self) -> None:
class TestExtractConversationId:
"""Tests for extract_conversation_id helper."""

def test_extracts_conversation_id(self, mocker: MockerFixture) -> None:
def test_extracts_conversation_id(self) -> None:
"""Test extraction when extra_body.conversation is set."""
model = mocker.Mock()
model.settings = {"extra_body": {"conversation": "conv_123"}}
model = TestModel(settings={"extra_body": {"conversation": "conv_123"}})

assert extract_conversation_id(model) == "conv_123"

def test_returns_none_when_settings_missing(self, mocker: MockerFixture) -> None:
def test_returns_none_when_settings_missing(self) -> None:
"""Test that None settings yields None."""
model = mocker.Mock()
model.settings = None
model = TestModel(settings=None)

assert extract_conversation_id(model) is None

def test_returns_none_when_extra_body_missing(self, mocker: MockerFixture) -> None:
def test_returns_none_when_extra_body_missing(self) -> None:
"""Test that missing extra_body yields None."""
model = mocker.Mock()
model.settings = {}
model = TestModel(settings={})

assert extract_conversation_id(model) is None

def test_returns_none_when_extra_body_not_dict(self, mocker: MockerFixture) -> None:
def test_returns_none_when_extra_body_not_dict(self) -> None:
"""Test that a non-dict extra_body yields None instead of raising."""
model = mocker.Mock()
model.settings = {"extra_body": "not-a-dict"}
model = TestModel(settings={"extra_body": "not-a-dict"})

assert extract_conversation_id(model) is None

def test_returns_none_when_conversation_not_string(
self, mocker: MockerFixture
) -> None:
def test_returns_none_when_conversation_not_string(self) -> None:
"""Test that a non-string conversation value yields None."""
model = mocker.Mock()
model.settings = {"extra_body": {"conversation": 123}}
model = TestModel(settings={"extra_body": {"conversation": 123}})

assert extract_conversation_id(model) is None

def test_returns_none_for_non_model_abstract(self, mocker: MockerFixture) -> None:
"""Test that AbstractModel without Model settings yields None."""
model = mocker.Mock(spec=["model_name", "system"])

assert extract_conversation_id(model) is None

Expand Down Expand Up @@ -273,8 +273,7 @@ def mock_ctx_fixture(self, mocker: MockerFixture) -> RunContext:
ctx = mocker.Mock(spec=RunContext)
ctx.prompt = "How do I create a pod?"
ctx.usage = RunUsage()
ctx.model = mocker.Mock()
ctx.model.settings = {"extra_body": {"conversation": "conv_test"}}
ctx.model = TestModel(settings={"extra_body": {"conversation": "conv_test"}})
return ctx

@pytest.fixture(name="mock_handler")
Expand Down Expand Up @@ -375,7 +374,7 @@ async def test_rejection_skips_persistence_when_conversation_id_missing(
mock_append_turn: MockType,
) -> None:
"""Test that persistence is skipped (not crashed) without a conversation ID."""
mock_ctx.model = mocker.Mock(settings={})
mock_ctx.model = TestModel(settings={})
mock_response = ModelResponse(
parts=[TextPart(content=SUBJECT_REJECTED)],
usage=RequestUsage(),
Expand Down Expand Up @@ -605,8 +604,7 @@ async def test_wrap_run_with_none_prompt(
ctx = mocker.Mock(spec=RunContext)
ctx.prompt = None
ctx.usage = RunUsage()
ctx.model = mocker.Mock()
ctx.model.settings = {"extra_body": {"conversation": "conv_test"}}
ctx.model = TestModel(settings={"extra_body": {"conversation": "conv_test"}})

mock_response = ModelResponse(
parts=[TextPart(content=SUBJECT_REJECTED)],
Expand Down
1 change: 1 addition & 0 deletions tests/unit/pydantic_ai_lightspeed/ogx/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ def model_fixture(self, mocker: MockerFixture) -> OgxResponsesModel:
)
model._provider = mocker.Mock()
model._provider.name = "test-provider"
model._provider.model_id_namespace = "test-provider"
model._provider.base_url = "http://localhost"
mocker.patch("pydantic_ai_lightspeed.ogx._model.check_allow_model_requests")
mocker.patch.object(
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/utils/test_pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ def test_agent_has_instructions(

agent = build_agent(mock_client, mock_params, make_agent_config())

assert "You are a helpful assistant." in agent._instructions
assert any(
instruction.instruction == "You are a helpful assistant."
for instruction in agent._instructions
)

def test_agent_with_library_client(
self,
Expand Down
Loading
Loading