Skip to content

Commit 382e662

Browse files
authored
feat: Update OpenAI runners to implement Runner protocol returning RunnerResult (#149)
1 parent 88d4ddc commit 382e662

4 files changed

Lines changed: 281 additions & 143 deletions

File tree

packages/ai-providers/server-ai-openai/README.md

Lines changed: 111 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,64 +23,135 @@ pip install launchdarkly-server-sdk-ai-openai
2323

2424
```python
2525
import asyncio
26-
from ldai import AIClient
27-
from ldai_openai import OpenAIProvider
26+
from ldclient import LDClient, Config, Context
27+
from ldai import init
28+
from ldai.models import AICompletionConfigDefault, ModelConfig, ProviderConfig
29+
30+
# Initialize LaunchDarkly client
31+
ld_client = LDClient(Config("your-sdk-key"))
32+
ai_client = init(ld_client)
33+
34+
context = Context.builder("user-123").build()
2835

2936
async def main():
30-
# Initialize the AI client
31-
ai_client = AIClient(ld_client)
32-
33-
# Get AI config. Pass a default for improved resiliency when the flag is unavailable or
34-
# LaunchDarkly is unreachable; omit for a disabled default. Example:
35-
# from ldai.models import AICompletionConfigDefault, LDMessage, ModelConfig, ProviderConfig
36-
# default = AICompletionConfigDefault(
37-
# enabled=True,
38-
# model=ModelConfig("gpt-4"),
39-
# provider=ProviderConfig("openai"),
40-
# messages=[LDMessage(role="system", content="You are a helpful assistant.")]
41-
# )
42-
# ai_config = ai_client.config("my-ai-config-key", context, default)
43-
ai_config = ai_client.config("my-ai-config-key", context)
44-
45-
# Create an OpenAI provider from the config
46-
provider = await OpenAIProvider.create(ai_config)
47-
48-
# Invoke the model
49-
response = await provider.invoke_model(ai_config.messages)
50-
print(response.message.content)
37+
# Create a ManagedModel backed by the OpenAI provider
38+
model = await ai_client.create_model(
39+
"ai-config-key",
40+
context,
41+
AICompletionConfigDefault(
42+
enabled=True,
43+
model=ModelConfig("gpt-4"),
44+
provider=ProviderConfig("openai"),
45+
),
46+
)
47+
48+
if model:
49+
result = await model.run("Hello, how are you?")
50+
print(result.content)
5151

5252
asyncio.run(main())
5353
```
5454

55-
## Features
55+
## Usage
5656

57-
- Full integration with OpenAI's chat completions API
58-
- Automatic token usage tracking
59-
- Support for structured output (JSON schema)
60-
- Static utility methods for custom integrations
57+
### Using `create_model` (recommended)
6158

62-
## API Reference
59+
The recommended entry point is `LDAIClient.create_model`, which evaluates a
60+
LaunchDarkly AI config flag, selects the OpenAI runner automatically, and
61+
returns a `ManagedModel` that wraps the runner:
6362

64-
### OpenAIProvider
63+
```python
64+
model = await ai_client.create_model("ai-config-key", context)
65+
66+
if model:
67+
result = await model.run("What is feature flagging?")
68+
print(result.content)
69+
```
6570

66-
#### Constructor
71+
### Using the runner directly
72+
73+
If you need to construct a runner manually (e.g. for testing), you can use
74+
`OpenAIRunnerFactory` from the `ldai_openai` package:
6775

6876
```python
69-
OpenAIProvider(client: OpenAI, model_name: str, parameters: Dict[str, Any], logger: Optional[Any] = None)
77+
from ldai_openai import OpenAIRunnerFactory
78+
79+
factory = OpenAIRunnerFactory() # uses OPENAI_API_KEY from environment
80+
runner = factory.create_model(ai_config)
81+
82+
result = await runner.run("Hello!")
83+
print(result.content)
7084
```
7185

72-
#### Static Methods
86+
### Structured Output
87+
88+
Pass a JSON schema dict as `output_type` to request structured output:
7389

74-
- `create(ai_config: AIConfigKind, logger: Optional[Any] = None) -> OpenAIProvider` - Factory method to create a provider from an AI config
75-
- `get_ai_metrics_from_response(response: Any) -> LDAIMetrics` - Extract metrics from an OpenAI response
90+
```python
91+
response_structure = {
92+
"type": "object",
93+
"properties": {
94+
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
95+
"confidence": {"type": "number"},
96+
},
97+
"required": ["sentiment", "confidence"],
98+
}
99+
100+
result = await runner.run(messages, output_type=response_structure)
101+
print(result.parsed) # {"sentiment": "positive", "confidence": 0.95}
102+
```
103+
104+
### Tracking Metrics
105+
106+
`ManagedModel.run()` automatically tracks metrics via the associated
107+
`LDAIConfigTracker`. For manual tracking, use the tracker directly:
108+
109+
```python
110+
model = await ai_client.create_model("ai-config-key", context)
111+
112+
if model:
113+
result = await model.run("Explain feature flags.")
114+
# Metrics are tracked automatically; access them via result.metrics
115+
print(result.metrics.usage)
116+
```
76117

77-
#### Instance Methods
118+
### Static Utility Methods
78119

79-
- `invoke_model(messages: List[LDMessage]) -> ChatResponse` - Invoke the model with messages
80-
- `invoke_structured_model(messages: List[LDMessage], response_structure: Dict[str, Any]) -> StructuredResponse` - Invoke the model with structured output
81-
- `get_client() -> OpenAI` - Get the underlying OpenAI client
120+
The `ldai_openai` helper module provides several utility functions:
121+
122+
#### Converting Messages
123+
124+
```python
125+
from ldai.models import LDMessage
126+
from ldai_openai import convert_messages_to_openai
127+
128+
messages = [
129+
LDMessage(role="system", content="You are helpful."),
130+
LDMessage(role="user", content="Hello!"),
131+
]
132+
133+
openai_messages = convert_messages_to_openai(messages)
134+
```
135+
136+
#### Extracting Metrics
137+
138+
```python
139+
from ldai_openai import get_ai_metrics_from_response
140+
141+
# After getting a response from OpenAI
142+
metrics = get_ai_metrics_from_response(response)
143+
print(f"Success: {metrics.success}")
144+
print(f"Tokens used: {metrics.usage.total if metrics.usage else 'N/A'}")
145+
```
146+
147+
## Documentation
148+
149+
For full documentation, please refer to the [LaunchDarkly AI SDK documentation](https://docs.launchdarkly.com/sdk/ai/python).
150+
151+
## Contributing
152+
153+
See [CONTRIBUTING.md](../../../CONTRIBUTING.md) in the repository root.
82154

83155
## License
84156

85157
Apache-2.0
86-

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

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
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
5+
from ldai.providers.runner import Runner
56
from ldai.providers.types import LDAIMetrics
67

78
from ldai_openai.openai_helper import (
89
get_ai_usage_from_response,
10+
get_tool_calls_from_run_items,
11+
is_agent_tool_instance,
912
registry_value_to_agent_tool,
1013
)
1114

1215

13-
class OpenAIAgentRunner(AgentRunner):
16+
class OpenAIAgentRunner(Runner):
1417
"""
1518
CAUTION:
1619
This feature is experimental and should NOT be considered ready for production use.
1720
It may change or be removed without notice and is not subject to backwards
1821
compatibility guarantees.
1922
20-
AgentRunner implementation for OpenAI.
23+
Runner implementation for a single OpenAI agent.
2124
2225
Executes a single agent using the OpenAI Agents SDK (``openai-agents``).
2326
Tool calling and the agentic loop are handled internally by ``Runner.run``.
24-
Returned by OpenAIRunnerFactory.create_agent(config, tools).
27+
Returned by ``OpenAIRunnerFactory.create_agent(config, tools)``.
2528
29+
Implements the unified :class:`~ldai.providers.runner.Runner` protocol.
2630
Requires ``openai-agents`` to be installed.
2731
"""
2832

@@ -39,25 +43,37 @@ def __init__(
3943
self._instructions = instructions
4044
self._tool_definitions = tool_definitions
4145
self._tools = tools
46+
self._tool_name_map: Dict[str, str] = {}
4247

43-
async def run(self, input: Any) -> AgentResult:
48+
async def run(
49+
self,
50+
input: Any,
51+
output_type: Optional[Dict[str, Any]] = None,
52+
) -> RunnerResult:
4453
"""
45-
Run the agent with the given input string.
54+
Run the agent with the given input.
4655
4756
Delegates to the OpenAI Agents SDK ``Runner.run``, which handles the
4857
tool-calling loop internally.
4958
5059
:param input: The user prompt or input to the agent
51-
:return: AgentResult with output, raw response, and aggregated metrics
60+
:param output_type: Reserved for future structured output support;
61+
currently ignored.
62+
:return: :class:`RunnerResult` with ``content``, ``raw`` response, and
63+
metrics including aggregated token usage and observed ``tool_calls``.
5264
"""
5365
try:
54-
from agents import Agent, Runner
66+
from agents import Agent
67+
from agents import Runner as _Runner
5568
except ImportError:
5669
log.warning(
5770
"openai-agents is required for OpenAIAgentRunner. "
5871
"Install it with: pip install openai-agents"
5972
)
60-
return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None))
73+
return RunnerResult(
74+
content="",
75+
metrics=LDAIMetrics(success=False, usage=None),
76+
)
6177

6278
try:
6379
agent_tools = self._build_agent_tools()
@@ -71,23 +87,40 @@ async def run(self, input: Any) -> AgentResult:
7187
model_settings=model_settings,
7288
)
7389

74-
result = await Runner.run(agent, str(input), max_turns=25)
90+
result = await _Runner.run(agent, str(input), max_turns=25)
7591

76-
return AgentResult(
77-
output=str(result.final_output),
78-
raw=result,
92+
tool_calls = [
93+
ld_name
94+
for _agent_name, tool_fn_name in get_tool_calls_from_run_items(result.new_items)
95+
for ld_name in [self._tool_name_map.get(tool_fn_name)]
96+
if ld_name is not None
97+
]
98+
99+
return RunnerResult(
100+
content=str(result.final_output),
79101
metrics=LDAIMetrics(
80102
success=True,
81103
usage=get_ai_usage_from_response(result),
104+
tool_calls=tool_calls if tool_calls else None,
82105
),
106+
raw=result,
83107
)
84108
except Exception as error:
85109
log.warning(f"OpenAI agent run failed: {error}")
86-
return AgentResult(output="", raw=None, metrics=LDAIMetrics(success=False, usage=None))
110+
return RunnerResult(
111+
content="",
112+
metrics=LDAIMetrics(success=False, usage=None),
113+
)
87114

88115
def _build_agent_tools(self) -> List[Any]:
89-
"""Build tool instances from LD tool definitions and registry."""
116+
"""Build tool instances from LD tool definitions and registry.
117+
118+
Also populates ``self._tool_name_map`` so observed tool-call names
119+
from the runtime can be translated back to their LD config keys for
120+
metric reporting.
121+
"""
90122
tools = []
123+
self._tool_name_map = {}
91124
for td in self._tool_definitions:
92125
if not isinstance(td, dict):
93126
continue
@@ -97,6 +130,14 @@ def _build_agent_tools(self) -> List[Any]:
97130

98131
tool_fn = self._tools.get(name)
99132
if tool_fn:
133+
# Map runtime tool name → LD config key for metrics (function __name__
134+
# for callables; identity for native tool instances — see get_tool_calls_from_run_items).
135+
if is_agent_tool_instance(tool_fn):
136+
self._tool_name_map[tool_fn.name] = name
137+
else:
138+
fn_name = getattr(tool_fn, '__name__', None)
139+
if fn_name:
140+
self._tool_name_map[fn_name] = name
100141
tools.append(registry_value_to_agent_tool(tool_fn))
101142
continue
102143

0 commit comments

Comments
 (0)