Skip to content

Commit e6e4907

Browse files
authored
feat!: Restructure provider factory and support additional create methods (#102)
feat!: Rename OpenAIProvider → OpenAIRunnerFactory; rename openai_provider.py → openai_runner_factory.py feat!: Rename LangChainProvider → LangChainRunnerFactory; rename langchain_provider.py → langchain_runner_factory.py feat!: Remove abstract static create(ai_config) from AIProvider ABC; see new create_[model|agent|agent_graph] feat: Add create_model(), create_agent(), create_agent_graph() instance methods to AIProvider ABC (non-abstract, default warns) feat!: Rename AIProviderFactory with RunnerFactory; delete ai_provider_factory.py feat: RunnerFactory.create_model/agent/agent_graph() replace async AIProviderFactory.create() fix: LDClient now calls RunnerFactory.create_model() synchronously instead of awaiting AIProviderFactory.create()
1 parent 9d076ba commit e6e4907

11 files changed

Lines changed: 331 additions & 310 deletions

File tree

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
"""LaunchDarkly AI SDK - LangChain Provider.
2-
3-
This package provides LangChain integration for the LaunchDarkly Server-Side AI SDK,
4-
"""
5-
6-
from ldai_langchain.langchain_provider import LangChainProvider
1+
from ldai_langchain.langchain_runner_factory import LangChainRunnerFactory
72

83
__version__ = "0.1.0"
94

105
__all__ = [
116
'__version__',
12-
'LangChainProvider',
7+
'LangChainRunnerFactory',
138
]

packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_provider.py renamed to packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""LangChain implementation of AIProvider for LaunchDarkly AI SDK."""
2-
31
from typing import Any, Dict, List, Optional, Union
42

53
from langchain_core.language_models.chat_models import BaseChatModel
@@ -11,31 +9,24 @@
119
from ldai.tracker import TokenUsage
1210

1311

14-
class LangChainProvider(AIProvider):
15-
"""
16-
LangChain implementation of AIProvider.
17-
18-
This provider integrates LangChain models with LaunchDarkly's tracking capabilities.
19-
"""
12+
class LangChainRunnerFactory(AIProvider):
13+
"""LangChain provider for the LaunchDarkly AI SDK."""
2014

21-
def __init__(self, llm: BaseChatModel):
15+
def __init__(self, llm: Optional[BaseChatModel] = None):
2216
"""
23-
Initialize the LangChain provider.
24-
25-
:param llm: A LangChain BaseChatModel instance
17+
:param llm: A LangChain BaseChatModel instance (optional)
2618
"""
2719
self._llm = llm
2820

29-
@staticmethod
30-
async def create(ai_config: AIConfigKind) -> 'LangChainProvider':
21+
def create_model(self, config: AIConfigKind) -> 'LangChainRunnerFactory':
3122
"""
32-
Static factory method to create a LangChain AIProvider from an AI configuration.
23+
Create a configured LangChain model provider for the given AI config.
3324
34-
:param ai_config: The LaunchDarkly AI configuration
35-
:return: Configured LangChainProvider instance
25+
:param config: The LaunchDarkly AI configuration
26+
:return: Configured LangChainRunnerFactory ready to invoke the model
3627
"""
37-
llm = LangChainProvider.create_langchain_model(ai_config)
38-
return LangChainProvider(llm)
28+
llm = LangChainRunnerFactory.create_langchain_model(config)
29+
return LangChainRunnerFactory(llm)
3930

4031
async def invoke_model(self, messages: List[LDMessage]) -> ChatResponse:
4132
"""
@@ -45,9 +36,10 @@ async def invoke_model(self, messages: List[LDMessage]) -> ChatResponse:
4536
:return: ChatResponse containing the model's response and metrics
4637
"""
4738
try:
48-
langchain_messages = LangChainProvider.convert_messages_to_langchain(messages)
39+
assert self._llm is not None
40+
langchain_messages = LangChainRunnerFactory.convert_messages_to_langchain(messages)
4941
response: BaseMessage = await self._llm.ainvoke(langchain_messages)
50-
metrics = LangChainProvider.get_ai_metrics_from_response(response)
42+
metrics = LangChainRunnerFactory.get_ai_metrics_from_response(response)
5143

5244
content: str = ''
5345
if isinstance(response.content, str):
@@ -89,7 +81,8 @@ async def invoke_structured_model(
8981
metrics=LDAIMetrics(success=False, usage=None),
9082
)
9183
try:
92-
langchain_messages = LangChainProvider.convert_messages_to_langchain(messages)
84+
assert self._llm is not None
85+
langchain_messages = LangChainRunnerFactory.convert_messages_to_langchain(messages)
9386
structured_llm = self._llm.with_structured_output(response_structure, include_raw=True)
9487
response = await structured_llm.ainvoke(langchain_messages)
9588

@@ -104,7 +97,7 @@ async def invoke_structured_model(
10497
if raw_response is not None:
10598
if hasattr(raw_response, 'content'):
10699
structured_response.raw_response = raw_response.content
107-
structured_response.metrics.usage = LangChainProvider.get_ai_usage_from_response(raw_response)
100+
structured_response.metrics.usage = LangChainRunnerFactory.get_ai_usage_from_response(raw_response)
108101

109102
if response.get('parsing_error'):
110103
log.warning(f'LangChain structured model invocation had a parsing error')
@@ -117,11 +110,11 @@ async def invoke_structured_model(
117110
log.warning(f'LangChain structured model invocation failed: {error}')
118111
return structured_response
119112

120-
def get_chat_model(self) -> BaseChatModel:
113+
def get_chat_model(self) -> Optional[BaseChatModel]:
121114
"""
122115
Get the underlying LangChain model instance.
123116
124-
:return: The underlying BaseChatModel
117+
:return: The underlying BaseChatModel, or None if not yet configured
125118
"""
126119
return self._llm
127120

@@ -174,23 +167,19 @@ def get_ai_usage_from_response(response: BaseMessage) -> TokenUsage:
174167
@staticmethod
175168
def get_ai_metrics_from_response(response: BaseMessage) -> LDAIMetrics:
176169
"""
177-
Get AI metrics from a LangChain provider response.
178-
179-
This method extracts token usage information and success status from LangChain responses
180-
and returns a LaunchDarkly AIMetrics object.
170+
Extract LaunchDarkly AI metrics from a LangChain response.
181171
182172
:param response: The response from the LangChain model
183173
:return: LDAIMetrics with success status and token usage
184174
185-
Example:
186-
# Use with tracker.track_metrics_of for automatic tracking
175+
Example::
176+
187177
response = await tracker.track_metrics_of(
188178
lambda: llm.ainvoke(messages),
189-
LangChainProvider.get_ai_metrics_from_response
179+
LangChainRunnerFactory.get_ai_metrics_from_response
190180
)
191181
"""
192-
# Extract token usage if available
193-
usage = LangChainProvider.get_ai_usage_from_response(response)
182+
usage = LangChainRunnerFactory.get_ai_usage_from_response(response)
194183

195184
return LDAIMetrics(success=True, usage=usage)
196185

@@ -201,9 +190,6 @@ def convert_messages_to_langchain(
201190
"""
202191
Convert LaunchDarkly messages to LangChain messages.
203192
204-
This helper method enables developers to work directly with LangChain message types
205-
while maintaining compatibility with LaunchDarkly's standardized message format.
206-
207193
:param messages: List of LDMessage objects
208194
:return: List of LangChain message objects
209195
:raises ValueError: If an unsupported message role is encountered
@@ -225,10 +211,7 @@ def convert_messages_to_langchain(
225211
@staticmethod
226212
def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel:
227213
"""
228-
Create a LangChain model from an AI configuration.
229-
230-
This public helper method enables developers to initialize their own LangChain models
231-
using LaunchDarkly AI configurations.
214+
Create a LangChain model from a LaunchDarkly AI configuration.
232215
233216
:param ai_config: The LaunchDarkly AI configuration
234217
:return: A configured LangChain BaseChatModel
@@ -242,7 +225,7 @@ def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel:
242225
model_name = model_dict.get('name', '')
243226
provider = provider_dict.get('name', '')
244227
parameters = dict(model_dict.get('parameters') or {})
245-
mapped_provider = LangChainProvider.map_provider(provider)
228+
mapped_provider = LangChainRunnerFactory.map_provider(provider)
246229

247230
# Bedrock requires the foundation provider (e.g. Bedrock:Anthropic) passed in
248231
# parameters separately from model_provider, which is used for LangChain routing.

packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from ldai import LDMessage
99

10-
from ldai_langchain import LangChainProvider
10+
from ldai_langchain import LangChainRunnerFactory
1111

1212

1313
class TestConvertMessagesToLangchain:
@@ -16,7 +16,7 @@ class TestConvertMessagesToLangchain:
1616
def test_converts_system_messages_to_system_message(self):
1717
"""Should convert system messages to SystemMessage."""
1818
messages = [LDMessage(role='system', content='You are a helpful assistant.')]
19-
result = LangChainProvider.convert_messages_to_langchain(messages)
19+
result = LangChainRunnerFactory.convert_messages_to_langchain(messages)
2020

2121
assert len(result) == 1
2222
assert isinstance(result[0], SystemMessage)
@@ -25,7 +25,7 @@ def test_converts_system_messages_to_system_message(self):
2525
def test_converts_user_messages_to_human_message(self):
2626
"""Should convert user messages to HumanMessage."""
2727
messages = [LDMessage(role='user', content='Hello, how are you?')]
28-
result = LangChainProvider.convert_messages_to_langchain(messages)
28+
result = LangChainRunnerFactory.convert_messages_to_langchain(messages)
2929

3030
assert len(result) == 1
3131
assert isinstance(result[0], HumanMessage)
@@ -34,7 +34,7 @@ def test_converts_user_messages_to_human_message(self):
3434
def test_converts_assistant_messages_to_ai_message(self):
3535
"""Should convert assistant messages to AIMessage."""
3636
messages = [LDMessage(role='assistant', content='I am doing well, thank you!')]
37-
result = LangChainProvider.convert_messages_to_langchain(messages)
37+
result = LangChainRunnerFactory.convert_messages_to_langchain(messages)
3838

3939
assert len(result) == 1
4040
assert isinstance(result[0], AIMessage)
@@ -47,7 +47,7 @@ def test_converts_multiple_messages_in_order(self):
4747
LDMessage(role='user', content='What is the weather like?'),
4848
LDMessage(role='assistant', content='I cannot check the weather.'),
4949
]
50-
result = LangChainProvider.convert_messages_to_langchain(messages)
50+
result = LangChainRunnerFactory.convert_messages_to_langchain(messages)
5151

5252
assert len(result) == 3
5353
assert isinstance(result[0], SystemMessage)
@@ -62,11 +62,11 @@ class MockMessage:
6262
content = 'Test message'
6363

6464
with pytest.raises(ValueError, match='Unsupported message role: unknown'):
65-
LangChainProvider.convert_messages_to_langchain([MockMessage()]) # type: ignore
65+
LangChainRunnerFactory.convert_messages_to_langchain([MockMessage()]) # type: ignore
6666

6767
def test_handles_empty_message_array(self):
6868
"""Should handle empty message array."""
69-
result = LangChainProvider.convert_messages_to_langchain([])
69+
result = LangChainRunnerFactory.convert_messages_to_langchain([])
7070
assert len(result) == 0
7171

7272

@@ -84,7 +84,7 @@ def test_creates_metrics_with_success_true_and_token_usage(self):
8484
},
8585
}
8686

87-
result = LangChainProvider.get_ai_metrics_from_response(mock_response)
87+
result = LangChainRunnerFactory.get_ai_metrics_from_response(mock_response)
8888

8989
assert result.success is True
9090
assert result.usage is not None
@@ -103,7 +103,7 @@ def test_creates_metrics_with_snake_case_token_usage(self):
103103
},
104104
}
105105

106-
result = LangChainProvider.get_ai_metrics_from_response(mock_response)
106+
result = LangChainRunnerFactory.get_ai_metrics_from_response(mock_response)
107107

108108
assert result.success is True
109109
assert result.usage is not None
@@ -115,7 +115,7 @@ def test_creates_metrics_with_success_true_and_no_usage_when_metadata_missing(se
115115
"""Should create metrics with success=True and no usage when metadata is missing."""
116116
mock_response = AIMessage(content='Test response')
117117

118-
result = LangChainProvider.get_ai_metrics_from_response(mock_response)
118+
result = LangChainRunnerFactory.get_ai_metrics_from_response(mock_response)
119119

120120
assert result.success is True
121121
assert result.usage is None
@@ -126,23 +126,23 @@ class TestMapProvider:
126126

127127
def test_maps_gemini_to_google_genai(self):
128128
"""Should map gemini to google-genai."""
129-
assert LangChainProvider.map_provider('gemini') == 'google-genai'
130-
assert LangChainProvider.map_provider('Gemini') == 'google-genai'
131-
assert LangChainProvider.map_provider('GEMINI') == 'google-genai'
129+
assert LangChainRunnerFactory.map_provider('gemini') == 'google-genai'
130+
assert LangChainRunnerFactory.map_provider('Gemini') == 'google-genai'
131+
assert LangChainRunnerFactory.map_provider('GEMINI') == 'google-genai'
132132

133133
def test_maps_bedrock_and_model_families_to_bedrock_converse(self):
134134
"""Should map bedrock and bedrock:model_family to bedrock_converse."""
135-
assert LangChainProvider.map_provider('bedrock') == 'bedrock_converse'
136-
assert LangChainProvider.map_provider('Bedrock:Anthropic') == 'bedrock_converse'
137-
assert LangChainProvider.map_provider('bedrock:anthropic') == 'bedrock_converse'
138-
assert LangChainProvider.map_provider('bedrock:amazon') == 'bedrock_converse'
139-
assert LangChainProvider.map_provider('bedrock:cohere') == 'bedrock_converse'
135+
assert LangChainRunnerFactory.map_provider('bedrock') == 'bedrock_converse'
136+
assert LangChainRunnerFactory.map_provider('Bedrock:Anthropic') == 'bedrock_converse'
137+
assert LangChainRunnerFactory.map_provider('bedrock:anthropic') == 'bedrock_converse'
138+
assert LangChainRunnerFactory.map_provider('bedrock:amazon') == 'bedrock_converse'
139+
assert LangChainRunnerFactory.map_provider('bedrock:cohere') == 'bedrock_converse'
140140

141141
def test_returns_provider_name_unchanged_for_unmapped_providers(self):
142142
"""Should return provider name unchanged for unmapped providers."""
143-
assert LangChainProvider.map_provider('openai') == 'openai'
144-
assert LangChainProvider.map_provider('anthropic') == 'anthropic'
145-
assert LangChainProvider.map_provider('unknown') == 'unknown'
143+
assert LangChainRunnerFactory.map_provider('openai') == 'openai'
144+
assert LangChainRunnerFactory.map_provider('anthropic') == 'anthropic'
145+
assert LangChainRunnerFactory.map_provider('unknown') == 'unknown'
146146

147147

148148
class TestInvokeModel:
@@ -158,7 +158,7 @@ async def test_returns_success_true_for_string_content(self, mock_llm):
158158
"""Should return success=True for string content."""
159159
mock_response = AIMessage(content='Test response')
160160
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
161-
provider = LangChainProvider(mock_llm)
161+
provider = LangChainRunnerFactory(mock_llm)
162162

163163
messages = [LDMessage(role='user', content='Hello')]
164164
result = await provider.invoke_model(messages)
@@ -171,7 +171,7 @@ async def test_returns_success_false_for_non_string_content_and_logs_warning(sel
171171
"""Should return success=False for non-string content and log warning."""
172172
mock_response = AIMessage(content=[{'type': 'image', 'data': 'base64data'}])
173173
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
174-
provider = LangChainProvider(mock_llm)
174+
provider = LangChainRunnerFactory(mock_llm)
175175

176176
messages = [LDMessage(role='user', content='Hello')]
177177
result = await provider.invoke_model(messages)
@@ -184,7 +184,7 @@ async def test_returns_success_false_when_model_invocation_throws_error(self, mo
184184
"""Should return success=False when model invocation throws an error."""
185185
error = Exception('Model invocation failed')
186186
mock_llm.ainvoke = AsyncMock(side_effect=error)
187-
provider = LangChainProvider(mock_llm)
187+
provider = LangChainRunnerFactory(mock_llm)
188188

189189
messages = [LDMessage(role='user', content='Hello')]
190190
result = await provider.invoke_model(messages)
@@ -210,7 +210,7 @@ async def test_returns_success_true_for_successful_invocation(self, mock_llm):
210210
mock_structured_llm = MagicMock()
211211
mock_structured_llm.ainvoke = AsyncMock(return_value=mock_response)
212212
mock_llm.with_structured_output = MagicMock(return_value=mock_structured_llm)
213-
provider = LangChainProvider(mock_llm)
213+
provider = LangChainRunnerFactory(mock_llm)
214214

215215
messages = [LDMessage(role='user', content='Hello')]
216216
response_structure = {'type': 'object', 'properties': {}}
@@ -226,7 +226,7 @@ async def test_returns_success_false_when_structured_model_invocation_throws_err
226226
mock_structured_llm = MagicMock()
227227
mock_structured_llm.ainvoke = AsyncMock(side_effect=error)
228228
mock_llm.with_structured_output = MagicMock(return_value=mock_structured_llm)
229-
provider = LangChainProvider(mock_llm)
229+
provider = LangChainRunnerFactory(mock_llm)
230230

231231
messages = [LDMessage(role='user', content='Hello')]
232232
response_structure = {'type': 'object', 'properties': {}}
@@ -244,7 +244,7 @@ class TestGetChatModel:
244244
def test_returns_underlying_llm(self):
245245
"""Should return the underlying LLM."""
246246
mock_llm = MagicMock()
247-
provider = LangChainProvider(mock_llm)
247+
provider = LangChainRunnerFactory(mock_llm)
248248

249249
assert provider.get_chat_model() is mock_llm
250250

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""LaunchDarkly AI SDK OpenAI Provider."""
1+
from ldai_openai.openai_runner_factory import OpenAIRunnerFactory
22

3-
from ldai_openai.openai_provider import OpenAIProvider
4-
5-
__all__ = ['OpenAIProvider']
3+
__all__ = [
4+
'OpenAIRunnerFactory',
5+
]

0 commit comments

Comments
 (0)