Skip to content

Commit 94f09ee

Browse files
jsonbaileyclaude
andcommitted
feat: Update OpenAI runners to implement Runner protocol returning RunnerResult
- OpenAIModelRunner.run() implements the unified Runner protocol; returns RunnerResult with content, metrics (LDAIMetrics), raw, and parsed fields. Structured output is supported via the output_type parameter. - OpenAIAgentRunner.run() updated to return RunnerResult; populates tool_calls in LDAIMetrics from observed openai-agents ToolCallItems. - Legacy invoke_model() and invoke_structured_model() retained as deprecated adapters that delegate to run() and wrap results into ModelResponse / StructuredResponse for backward compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 45441da commit 94f09ee

3 files changed

Lines changed: 154 additions & 70 deletions

File tree

packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
1-
from typing import Any, Dict, List
1+
from typing import Any, Dict, List, Optional
22

33
from ldai import log
4-
from ldai.providers import AgentResult, AgentRunner, ToolRegistry
4+
from ldai.providers import RunnerResult, ToolRegistry
55
from ldai.providers.types import LDAIMetrics
66

77
from ldai_openai.openai_helper import (
88
get_ai_usage_from_response,
9+
get_tool_calls_from_run_items,
910
registry_value_to_agent_tool,
1011
)
1112

1213

13-
class OpenAIAgentRunner(AgentRunner):
14+
class OpenAIAgentRunner:
1415
"""
1516
CAUTION:
1617
This feature is experimental and should NOT be considered ready for production use.
1718
It may change or be removed without notice and is not subject to backwards
1819
compatibility guarantees.
1920
20-
AgentRunner implementation for OpenAI.
21+
Runner implementation for a single OpenAI agent.
2122
2223
Executes a single agent using the OpenAI Agents SDK (``openai-agents``).
2324
Tool calling and the agentic loop are handled internally by ``Runner.run``.
24-
Returned by OpenAIRunnerFactory.create_agent(config, tools).
25+
Returned by ``OpenAIRunnerFactory.create_agent(config, tools)``.
2526
27+
Implements the unified :class:`~ldai.providers.runner.Runner` protocol.
2628
Requires ``openai-agents`` to be installed.
2729
"""
2830

@@ -40,15 +42,22 @@ def __init__(
4042
self._tool_definitions = tool_definitions
4143
self._tools = tools
4244

43-
async def run(self, input: Any) -> AgentResult:
45+
async def run(
46+
self,
47+
input: Any,
48+
output_type: Optional[Dict[str, Any]] = None,
49+
) -> RunnerResult:
4450
"""
45-
Run the agent with the given input string.
51+
Run the agent with the given input.
4652
4753
Delegates to the OpenAI Agents SDK ``Runner.run``, which handles the
4854
tool-calling loop internally.
4955
5056
:param input: The user prompt or input to the agent
51-
:return: AgentResult with output, raw response, and aggregated metrics
57+
:param output_type: Reserved for future structured output support;
58+
currently ignored.
59+
:return: :class:`RunnerResult` with ``content``, ``raw`` response, and
60+
metrics including aggregated token usage and observed ``tool_calls``.
5261
"""
5362
try:
5463
from agents import Agent, Runner
@@ -57,7 +66,10 @@ async def run(self, input: Any) -> AgentResult:
5766
"openai-agents is required for OpenAIAgentRunner. "
5867
"Install it with: pip install openai-agents"
5968
)
60-
return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None))
69+
return RunnerResult(
70+
content="",
71+
metrics=LDAIMetrics(success=False, usage=None),
72+
)
6173

6274
try:
6375
agent_tools = self._build_agent_tools()
@@ -73,17 +85,26 @@ async def run(self, input: Any) -> AgentResult:
7385

7486
result = await Runner.run(agent, str(input), max_turns=25)
7587

76-
return AgentResult(
77-
output=str(result.final_output),
78-
raw=result,
88+
tool_calls = [
89+
tool_name
90+
for _agent_name, tool_name in get_tool_calls_from_run_items(result.new_items)
91+
]
92+
93+
return RunnerResult(
94+
content=str(result.final_output),
7995
metrics=LDAIMetrics(
8096
success=True,
8197
usage=get_ai_usage_from_response(result),
98+
tool_calls=tool_calls if tool_calls else None,
8299
),
100+
raw=result,
83101
)
84102
except Exception as error:
85103
log.warning(f"OpenAI agent run failed: {error}")
86-
return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None))
104+
return RunnerResult(
105+
content="",
106+
metrics=LDAIMetrics(success=False, usage=None),
107+
)
87108

88109
def _build_agent_tools(self) -> List[Any]:
89110
"""Build tool instances from LD tool definitions and registry."""
Lines changed: 111 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import json
2-
from typing import Any, Dict, List
2+
from typing import Any, Dict, List, Optional
33

44
from ldai import LDMessage, log
55
from ldai.providers.model_runner import ModelRunner
6-
from ldai.providers.types import LDAIMetrics, ModelResponse, StructuredResponse
6+
from ldai.providers.types import LDAIMetrics, ModelResponse, RunnerResult, StructuredResponse
77
from openai import AsyncOpenAI
88

99
from ldai_openai.openai_helper import (
@@ -14,10 +14,15 @@
1414

1515
class OpenAIModelRunner(ModelRunner):
1616
"""
17-
ModelRunner implementation for OpenAI.
17+
Runner implementation for OpenAI chat completions.
1818
1919
Holds a fully-configured AsyncOpenAI client, model name, and parameters.
20-
Returned by OpenAIConnector.create_model(config).
20+
Returned by ``OpenAIRunnerFactory.create_model(config)``.
21+
22+
Implements the unified :class:`~ldai.providers.runner.Runner` protocol via
23+
:meth:`run`. The legacy :meth:`invoke_model` and :meth:`invoke_structured_model`
24+
methods are preserved for backward compatibility with the managed layer until
25+
its migration to the unified protocol is complete.
2126
"""
2227

2328
def __init__(
@@ -30,13 +35,38 @@ def __init__(
3035
self._model_name = model_name
3136
self._parameters = parameters
3237

33-
async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse:
38+
async def run(
39+
self,
40+
input: Any,
41+
output_type: Optional[Dict[str, Any]] = None,
42+
) -> RunnerResult:
3443
"""
35-
Invoke the OpenAI model with an array of messages.
36-
37-
:param messages: Array of LDMessage objects representing the conversation
38-
:return: ModelResponse containing the model's response and metrics
44+
Run the OpenAI model with the given input.
45+
46+
:param input: A string prompt or a list of :class:`LDMessage` objects
47+
:param output_type: Optional JSON schema dict requesting structured output.
48+
When provided, ``parsed`` on the returned :class:`RunnerResult` is
49+
populated with the parsed JSON document.
50+
:return: :class:`RunnerResult` containing ``content``, ``metrics``,
51+
``raw`` and (when ``output_type`` is set) ``parsed``.
3952
"""
53+
messages = self._coerce_input(input)
54+
55+
if output_type is not None:
56+
return await self._run_structured(messages, output_type)
57+
return await self._run_completion(messages)
58+
59+
@staticmethod
60+
def _coerce_input(input: Any) -> List[LDMessage]:
61+
if isinstance(input, str):
62+
return [LDMessage(role='user', content=input)]
63+
if isinstance(input, list):
64+
return input
65+
raise TypeError(
66+
f"Unsupported input type for OpenAIModelRunner.run: {type(input).__name__}"
67+
)
68+
69+
async def _run_completion(self, messages: List[LDMessage]) -> RunnerResult:
4070
try:
4171
response = await self._client.chat.completions.create(
4272
model=self._model_name,
@@ -45,40 +75,29 @@ async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse:
4575
)
4676

4777
metrics = get_ai_metrics_from_response(response)
48-
49-
content = ''
50-
if response.choices and len(response.choices) > 0:
51-
message = response.choices[0].message
52-
if message and message.content:
53-
content = message.content
78+
content = self._extract_content(response)
5479

5580
if not content:
5681
log.warning('OpenAI response has no content available')
57-
metrics = LDAIMetrics(success=False, usage=metrics.usage)
82+
return RunnerResult(
83+
content='',
84+
metrics=LDAIMetrics(success=False, usage=metrics.usage),
85+
raw=response,
86+
)
5887

59-
return ModelResponse(
60-
message=LDMessage(role='assistant', content=content),
61-
metrics=metrics,
62-
)
88+
return RunnerResult(content=content, metrics=metrics, raw=response)
6389
except Exception as error:
6490
log.warning(f'OpenAI model invocation failed: {error}')
65-
return ModelResponse(
66-
message=LDMessage(role='assistant', content=''),
91+
return RunnerResult(
92+
content='',
6793
metrics=LDAIMetrics(success=False, usage=None),
6894
)
6995

70-
async def invoke_structured_model(
96+
async def _run_structured(
7197
self,
7298
messages: List[LDMessage],
73-
response_structure: Dict[str, Any],
74-
) -> StructuredResponse:
75-
"""
76-
Invoke the OpenAI model with structured output support.
77-
78-
:param messages: Array of LDMessage objects representing the conversation
79-
:param response_structure: Dictionary defining the JSON schema for output structure
80-
:return: StructuredResponse containing the structured data
81-
"""
99+
output_type: Dict[str, Any],
100+
) -> RunnerResult:
82101
try:
83102
response = await self._client.chat.completions.create(
84103
model=self._model_name,
@@ -87,43 +106,85 @@ async def invoke_structured_model(
87106
'type': 'json_schema',
88107
'json_schema': {
89108
'name': 'structured_output',
90-
'schema': response_structure,
109+
'schema': output_type,
91110
'strict': True,
92111
},
93112
},
94113
**self._parameters,
95114
)
96115

97116
metrics = get_ai_metrics_from_response(response)
98-
99-
content = ''
100-
if response.choices and len(response.choices) > 0:
101-
message = response.choices[0].message
102-
if message and message.content:
103-
content = message.content
117+
content = self._extract_content(response)
104118

105119
if not content:
106120
log.warning('OpenAI structured response has no content available')
107-
return StructuredResponse(
108-
data={},
109-
raw_response='',
121+
return RunnerResult(
122+
content='',
110123
metrics=LDAIMetrics(success=False, usage=metrics.usage),
124+
raw=response,
111125
)
112126

113127
try:
114-
data = json.loads(content)
115-
return StructuredResponse(data=data, raw_response=content, metrics=metrics)
128+
parsed = json.loads(content)
129+
return RunnerResult(
130+
content=content,
131+
metrics=metrics,
132+
raw=response,
133+
parsed=parsed,
134+
)
116135
except json.JSONDecodeError as parse_error:
117136
log.warning(f'OpenAI structured response contains invalid JSON: {parse_error}')
118-
return StructuredResponse(
119-
data={},
120-
raw_response=content,
137+
return RunnerResult(
138+
content=content,
121139
metrics=LDAIMetrics(success=False, usage=metrics.usage),
140+
raw=response,
122141
)
123142
except Exception as error:
124143
log.warning(f'OpenAI structured model invocation failed: {error}')
125-
return StructuredResponse(
126-
data={},
127-
raw_response='',
144+
return RunnerResult(
145+
content='',
128146
metrics=LDAIMetrics(success=False, usage=None),
129147
)
148+
149+
@staticmethod
150+
def _extract_content(response: Any) -> str:
151+
if response.choices and len(response.choices) > 0:
152+
message = response.choices[0].message
153+
if message and message.content:
154+
return message.content
155+
return ''
156+
157+
async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse:
158+
"""
159+
Invoke the OpenAI model with an array of messages.
160+
161+
.. deprecated::
162+
Use :meth:`run` instead. This method delegates to :meth:`run` and
163+
adapts the result to the legacy :class:`ModelResponse` shape so
164+
existing callers in the managed layer continue to function.
165+
"""
166+
result = await self._run_completion(messages)
167+
return ModelResponse(
168+
message=LDMessage(role='assistant', content=result.content),
169+
metrics=result.metrics,
170+
)
171+
172+
async def invoke_structured_model(
173+
self,
174+
messages: List[LDMessage],
175+
response_structure: Dict[str, Any],
176+
) -> StructuredResponse:
177+
"""
178+
Invoke the OpenAI model with structured output support.
179+
180+
.. deprecated::
181+
Use :meth:`run` with the ``output_type`` argument instead. This
182+
method delegates to :meth:`run` and adapts the result to the
183+
legacy :class:`StructuredResponse` shape.
184+
"""
185+
result = await self._run_structured(messages, response_structure)
186+
return StructuredResponse(
187+
data=result.parsed or {},
188+
raw_response=result.content,
189+
metrics=result.metrics,
190+
)

0 commit comments

Comments
 (0)