diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py index 7e79c836..6af5a57c 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py @@ -1,28 +1,30 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from ldai import log -from ldai.providers import AgentResult, AgentRunner, ToolRegistry +from ldai.providers import RunnerResult, ToolRegistry from ldai.providers.types import LDAIMetrics from ldai_openai.openai_helper import ( get_ai_usage_from_response, + get_tool_calls_from_run_items, registry_value_to_agent_tool, ) -class OpenAIAgentRunner(AgentRunner): +class OpenAIAgentRunner: """ CAUTION: This feature is experimental and should NOT be considered ready for production use. It may change or be removed without notice and is not subject to backwards compatibility guarantees. - AgentRunner implementation for OpenAI. + Runner implementation for a single OpenAI agent. Executes a single agent using the OpenAI Agents SDK (``openai-agents``). Tool calling and the agentic loop are handled internally by ``Runner.run``. - Returned by OpenAIRunnerFactory.create_agent(config, tools). + Returned by ``OpenAIRunnerFactory.create_agent(config, tools)``. + Implements the unified :class:`~ldai.providers.runner.Runner` protocol. Requires ``openai-agents`` to be installed. """ @@ -40,15 +42,22 @@ def __init__( self._tool_definitions = tool_definitions self._tools = tools - async def run(self, input: Any) -> AgentResult: + async def run( + self, + input: Any, + output_type: Optional[Dict[str, Any]] = None, + ) -> RunnerResult: """ - Run the agent with the given input string. + Run the agent with the given input. Delegates to the OpenAI Agents SDK ``Runner.run``, which handles the tool-calling loop internally. :param input: The user prompt or input to the agent - :return: AgentResult with output, raw response, and aggregated metrics + :param output_type: Reserved for future structured output support; + currently ignored. + :return: :class:`RunnerResult` with ``content``, ``raw`` response, and + metrics including aggregated token usage and observed ``tool_calls``. """ try: from agents import Agent, Runner @@ -57,7 +66,10 @@ async def run(self, input: Any) -> AgentResult: "openai-agents is required for OpenAIAgentRunner. " "Install it with: pip install openai-agents" ) - return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None)) + return RunnerResult( + content="", + metrics=LDAIMetrics(success=False, usage=None), + ) try: agent_tools = self._build_agent_tools() @@ -73,17 +85,26 @@ async def run(self, input: Any) -> AgentResult: result = await Runner.run(agent, str(input), max_turns=25) - return AgentResult( - output=str(result.final_output), - raw=result, + tool_calls = [ + tool_name + for _agent_name, tool_name in get_tool_calls_from_run_items(result.new_items) + ] + + return RunnerResult( + content=str(result.final_output), metrics=LDAIMetrics( success=True, usage=get_ai_usage_from_response(result), + tool_calls=tool_calls if tool_calls else None, ), + raw=result, ) except Exception as error: log.warning(f"OpenAI agent run failed: {error}") - return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None)) + return RunnerResult( + content="", + metrics=LDAIMetrics(success=False, usage=None), + ) def _build_agent_tools(self) -> List[Any]: """Build tool instances from LD tool definitions and registry.""" diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_model_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_model_runner.py index 9c4a34d8..34fcde2f 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_model_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_model_runner.py @@ -1,9 +1,8 @@ import json -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from ldai import LDMessage, log -from ldai.providers.model_runner import ModelRunner -from ldai.providers.types import LDAIMetrics, ModelResponse, StructuredResponse +from ldai.providers.types import LDAIMetrics, RunnerResult from openai import AsyncOpenAI from ldai_openai.openai_helper import ( @@ -12,12 +11,15 @@ ) -class OpenAIModelRunner(ModelRunner): +class OpenAIModelRunner: """ - ModelRunner implementation for OpenAI. + Runner implementation for OpenAI chat completions. Holds a fully-configured AsyncOpenAI client, model name, and parameters. - Returned by OpenAIConnector.create_model(config). + Returned by ``OpenAIRunnerFactory.create_model(config)``. + + Implements the unified :class:`~ldai.providers.runner.Runner` protocol via + :meth:`run`. """ def __init__( @@ -30,13 +32,38 @@ def __init__( self._model_name = model_name self._parameters = parameters - async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse: + async def run( + self, + input: Any, + output_type: Optional[Dict[str, Any]] = None, + ) -> RunnerResult: """ - Invoke the OpenAI model with an array of messages. - - :param messages: Array of LDMessage objects representing the conversation - :return: ModelResponse containing the model's response and metrics + Run the OpenAI model with the given input. + + :param input: A string prompt or a list of :class:`LDMessage` objects + :param output_type: Optional JSON schema dict requesting structured output. + When provided, ``parsed`` on the returned :class:`RunnerResult` is + populated with the parsed JSON document. + :return: :class:`RunnerResult` containing ``content``, ``metrics``, + ``raw`` and (when ``output_type`` is set) ``parsed``. """ + messages = self._coerce_input(input) + + if output_type is not None: + return await self._run_structured(messages, output_type) + return await self._run_completion(messages) + + @staticmethod + def _coerce_input(input: Any) -> List[LDMessage]: + if isinstance(input, str): + return [LDMessage(role='user', content=input)] + if isinstance(input, list): + return input + raise TypeError( + f"Unsupported input type for OpenAIModelRunner.run: {type(input).__name__}" + ) + + async def _run_completion(self, messages: List[LDMessage]) -> RunnerResult: try: response = await self._client.chat.completions.create( model=self._model_name, @@ -45,40 +72,29 @@ async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse: ) metrics = get_ai_metrics_from_response(response) - - content = '' - if response.choices and len(response.choices) > 0: - message = response.choices[0].message - if message and message.content: - content = message.content + content = self._extract_content(response) if not content: log.warning('OpenAI response has no content available') - metrics = LDAIMetrics(success=False, usage=metrics.usage) + return RunnerResult( + content='', + metrics=LDAIMetrics(success=False, usage=metrics.usage), + raw=response, + ) - return ModelResponse( - message=LDMessage(role='assistant', content=content), - metrics=metrics, - ) + return RunnerResult(content=content, metrics=metrics, raw=response) except Exception as error: log.warning(f'OpenAI model invocation failed: {error}') - return ModelResponse( - message=LDMessage(role='assistant', content=''), + return RunnerResult( + content='', metrics=LDAIMetrics(success=False, usage=None), ) - async def invoke_structured_model( + async def _run_structured( self, messages: List[LDMessage], - response_structure: Dict[str, Any], - ) -> StructuredResponse: - """ - Invoke the OpenAI model with structured output support. - - :param messages: Array of LDMessage objects representing the conversation - :param response_structure: Dictionary defining the JSON schema for output structure - :return: StructuredResponse containing the structured data - """ + output_type: Dict[str, Any], + ) -> RunnerResult: try: response = await self._client.chat.completions.create( model=self._model_name, @@ -87,7 +103,7 @@ async def invoke_structured_model( 'type': 'json_schema', 'json_schema': { 'name': 'structured_output', - 'schema': response_structure, + 'schema': output_type, 'strict': True, }, }, @@ -95,35 +111,42 @@ async def invoke_structured_model( ) metrics = get_ai_metrics_from_response(response) - - content = '' - if response.choices and len(response.choices) > 0: - message = response.choices[0].message - if message and message.content: - content = message.content + content = self._extract_content(response) if not content: log.warning('OpenAI structured response has no content available') - return StructuredResponse( - data={}, - raw_response='', + return RunnerResult( + content='', metrics=LDAIMetrics(success=False, usage=metrics.usage), + raw=response, ) try: - data = json.loads(content) - return StructuredResponse(data=data, raw_response=content, metrics=metrics) + parsed = json.loads(content) + return RunnerResult( + content=content, + metrics=metrics, + raw=response, + parsed=parsed, + ) except json.JSONDecodeError as parse_error: log.warning(f'OpenAI structured response contains invalid JSON: {parse_error}') - return StructuredResponse( - data={}, - raw_response=content, + return RunnerResult( + content=content, metrics=LDAIMetrics(success=False, usage=metrics.usage), + raw=response, ) except Exception as error: log.warning(f'OpenAI structured model invocation failed: {error}') - return StructuredResponse( - data={}, - raw_response='', + return RunnerResult( + content='', metrics=LDAIMetrics(success=False, usage=None), ) + + @staticmethod + def _extract_content(response: Any) -> str: + if response.choices and len(response.choices) > 0: + message = response.choices[0].message + if message and message.content: + return message.content + return '' diff --git a/packages/ai-providers/server-ai-openai/tests/test_openai_provider.py b/packages/ai-providers/server-ai-openai/tests/test_openai_provider.py index 19d2cff7..3b69d3f6 100644 --- a/packages/ai-providers/server-ai-openai/tests/test_openai_provider.py +++ b/packages/ai-providers/server-ai-openai/tests/test_openai_provider.py @@ -120,8 +120,8 @@ def test_handles_partial_usage_data(self): assert result.usage.output == 0 -class TestInvokeModel: - """Tests for invoke_model instance method.""" +class TestRunCompletion: + """Tests for the unified run() method (chat-completion path).""" @pytest.fixture def mock_client(self): @@ -144,15 +144,14 @@ async def test_invokes_openai_chat_completions_and_returns_response(self, mock_c provider = OpenAIModelRunner(mock_client, 'gpt-3.5-turbo', {}) messages = [LDMessage(role='user', content='Hello!')] - result = await provider.invoke_model(messages) + result = await provider.run(messages) mock_client.chat.completions.create.assert_called_once_with( model='gpt-3.5-turbo', messages=[{'role': 'user', 'content': 'Hello!'}], ) - assert result.message.role == 'assistant' - assert result.message.content == 'Hello! How can I help you today?' + assert result.content == 'Hello! How can I help you today?' assert result.metrics.success is True assert result.metrics.usage is not None assert result.metrics.usage.total == 25 @@ -174,10 +173,9 @@ async def test_returns_unsuccessful_response_when_no_content(self, mock_client): provider = OpenAIModelRunner(mock_client, 'gpt-3.5-turbo', {}) messages = [LDMessage(role='user', content='Hello!')] - result = await provider.invoke_model(messages) + result = await provider.run(messages) - assert result.message.role == 'assistant' - assert result.message.content == '' + assert result.content == '' assert result.metrics.success is False @pytest.mark.asyncio @@ -193,10 +191,9 @@ async def test_returns_unsuccessful_response_when_choices_empty(self, mock_clien provider = OpenAIModelRunner(mock_client, 'gpt-3.5-turbo', {}) messages = [LDMessage(role='user', content='Hello!')] - result = await provider.invoke_model(messages) + result = await provider.run(messages) - assert result.message.role == 'assistant' - assert result.message.content == '' + assert result.content == '' assert result.metrics.success is False @pytest.mark.asyncio @@ -208,15 +205,14 @@ async def test_returns_unsuccessful_response_when_exception_thrown(self, mock_cl provider = OpenAIModelRunner(mock_client, 'gpt-3.5-turbo', {}) messages = [LDMessage(role='user', content='Hello!')] - result = await provider.invoke_model(messages) + result = await provider.run(messages) - assert result.message.role == 'assistant' - assert result.message.content == '' + assert result.content == '' assert result.metrics.success is False -class TestInvokeStructuredModel: - """Tests for invoke_structured_model instance method.""" +class TestRunStructured: + """Tests for the unified run() method (structured-output path).""" @pytest.fixture def mock_client(self): @@ -249,10 +245,10 @@ async def test_invokes_openai_with_structured_output(self, mock_client): 'required': ['name', 'age', 'city'], } - result = await provider.invoke_structured_model(messages, response_structure) + result = await provider.run(messages, output_type=response_structure) - assert result.data == {'name': 'John', 'age': 30, 'city': 'New York'} - assert result.raw_response == '{"name": "John", "age": 30, "city": "New York"}' + assert result.parsed == {'name': 'John', 'age': 30, 'city': 'New York'} + assert result.content == '{"name": "John", "age": 30, "city": "New York"}' assert result.metrics.success is True assert result.metrics.usage is not None assert result.metrics.usage.total == 30 @@ -276,10 +272,10 @@ async def test_returns_unsuccessful_when_no_content_in_structured_response(self, messages = [LDMessage(role='user', content='Tell me about a person')] response_structure = {'type': 'object'} - result = await provider.invoke_structured_model(messages, response_structure) + result = await provider.run(messages, output_type=response_structure) - assert result.data == {} - assert result.raw_response == '' + assert result.parsed is None + assert result.content == '' assert result.metrics.success is False @pytest.mark.asyncio @@ -300,10 +296,10 @@ async def test_handles_json_parsing_errors(self, mock_client): messages = [LDMessage(role='user', content='Tell me about a person')] response_structure = {'type': 'object'} - result = await provider.invoke_structured_model(messages, response_structure) + result = await provider.run(messages, output_type=response_structure) - assert result.data == {} - assert result.raw_response == 'invalid json content' + assert result.parsed is None + assert result.content == 'invalid json content' assert result.metrics.success is False assert result.metrics.usage is not None assert result.metrics.usage.total == 15 @@ -319,10 +315,10 @@ async def test_returns_unsuccessful_response_when_exception_thrown(self, mock_cl messages = [LDMessage(role='user', content='Tell me about a person')] response_structure = {'type': 'object'} - result = await provider.invoke_structured_model(messages, response_structure) + result = await provider.run(messages, output_type=response_structure) - assert result.data == {} - assert result.raw_response == '' + assert result.parsed is None + assert result.content == '' assert result.metrics.success is False @@ -465,19 +461,20 @@ def _make_run_result(self, output: str, total: int = 15, input_tokens: int = 10, @pytest.mark.asyncio async def test_runs_agent_and_returns_result_with_no_tool_calls(self): - """Should return AgentResult when Runner.run returns a final output.""" + """Should return RunnerResult when Runner.run returns a final output.""" import sys from ldai_openai import OpenAIAgentRunner mock_run_result = self._make_run_result("The answer is 42.", total=15, input_tokens=10, output_tokens=5) + mock_run_result.new_items = [] agents_mock, tc_mock = _make_agents_mock(AsyncMock(return_value=mock_run_result)) runner = OpenAIAgentRunner('gpt-4', {}, 'You are helpful.', [], {}) with patch.dict(sys.modules, {'agents': agents_mock, 'agents.tool_context': tc_mock}): result = await runner.run("What is the answer?") - assert result.output == "The answer is 42." + assert result.content == "The answer is 42." assert result.metrics.success is True assert result.metrics.usage is not None assert result.metrics.usage.total == 15 @@ -490,6 +487,7 @@ async def test_executes_tool_calls_and_returns_final_response(self): from ldai_openai import OpenAIAgentRunner mock_run_result = self._make_run_result("It is sunny in Paris.", total=43, input_tokens=30, output_tokens=13) + mock_run_result.new_items = [] agents_mock, tc_mock = _make_agents_mock(AsyncMock(return_value=mock_run_result)) weather_fn = MagicMock(return_value="Sunny, 25°C") @@ -501,13 +499,13 @@ async def test_executes_tool_calls_and_returns_final_response(self): with patch.dict(sys.modules, {'agents': agents_mock, 'agents.tool_context': tc_mock}): result = await runner.run("What is the weather in Paris?") - assert result.output == "It is sunny in Paris." + assert result.content == "It is sunny in Paris." assert result.metrics.success is True assert result.metrics.usage.total == 43 @pytest.mark.asyncio async def test_returns_failure_when_exception_thrown(self): - """Should return unsuccessful AgentResult when Runner.run raises.""" + """Should return unsuccessful RunnerResult when Runner.run raises.""" import sys from ldai_openai import OpenAIAgentRunner @@ -518,12 +516,12 @@ async def test_returns_failure_when_exception_thrown(self): with patch.dict(sys.modules, {'agents': agents_mock, 'agents.tool_context': tc_mock}): result = await runner.run("Hello") - assert result.output == "" + assert result.content == "" assert result.metrics.success is False @pytest.mark.asyncio async def test_returns_failure_when_openai_agents_not_installed(self): - """Should return unsuccessful AgentResult when openai-agents is not installed.""" + """Should return unsuccessful RunnerResult when openai-agents is not installed.""" import sys from ldai_openai import OpenAIAgentRunner @@ -532,5 +530,5 @@ async def test_returns_failure_when_openai_agents_not_installed(self): with patch.dict(sys.modules, {'agents': None}): result = await runner.run("Hello") - assert result.output == "" + assert result.content == "" assert result.metrics.success is False