Skip to content

Commit a23a075

Browse files
committed
feat: Introduce ManagedModel and ModelRunner
feat: Add ModelRunner ABC with invoke_model() and invoke_structured_model() feat: Add ManagedModel replacing Chat; expose get_model_runner() escape hatch feat!: Rename ChatResponse to ModelResponse in providers/types.py feat!: Extract OpenAIModelRunner from OpenAIRunnerFactory; factory is now model-creation-only feat!: Extract LangChainModelRunner from LangChainRunnerFactory; factory is now model-creation-only feat: Add OpenAIHelper with shared utilities for model and future agent runners feat: Add LangChainHelper with shared utilities for model and future agent runners feat!: LangChainRunnerFactory is now a no-arg factory; static helpers moved to LangChainHelper fix: LDClient.create_chat() is deprecated in favour of create_model() fix: Chat alias in ldai.chat is deprecated in favour of ManagedModel fix: Rename ai_provider param to model_runner in Judge and ManagedModel
1 parent e6e4907 commit a23a075

20 files changed

Lines changed: 758 additions & 703 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
"""LaunchDarkly AI SDK - LangChain Connector."""
2+
3+
from ldai_langchain.langchain_helper import LangChainHelper
4+
from ldai_langchain.langchain_model_runner import LangChainModelRunner
15
from ldai_langchain.langchain_runner_factory import LangChainRunnerFactory
26

37
__version__ = "0.1.0"
48

59
__all__ = [
610
'__version__',
711
'LangChainRunnerFactory',
12+
'LangChainHelper',
13+
'LangChainModelRunner',
814
]
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Shared LangChain utilities for the LaunchDarkly AI SDK."""
2+
3+
from typing import Any, Dict, List, Optional, Union
4+
5+
from langchain_core.language_models.chat_models import BaseChatModel
6+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
7+
from ldai import LDMessage
8+
from ldai.models import AIConfigKind
9+
from ldai.providers.types import LDAIMetrics
10+
from ldai.tracker import TokenUsage
11+
12+
13+
class LangChainHelper:
14+
"""
15+
Shared utilities for LangChain-based runners (model, agent, agent graph).
16+
17+
All methods are static — this class is a namespace, not meant to be instantiated.
18+
"""
19+
20+
@staticmethod
21+
def map_provider(ld_provider_name: str) -> str:
22+
"""
23+
Map a LaunchDarkly provider name to its LangChain equivalent.
24+
25+
:param ld_provider_name: LaunchDarkly provider name
26+
:return: LangChain-compatible provider name
27+
"""
28+
lowercased_name = ld_provider_name.lower()
29+
# Bedrock is the only provider that uses "provider:model_family" (e.g. Bedrock:Anthropic).
30+
if lowercased_name.startswith('bedrock:'):
31+
return 'bedrock_converse'
32+
33+
mapping: Dict[str, str] = {
34+
'gemini': 'google-genai',
35+
'bedrock': 'bedrock_converse',
36+
}
37+
return mapping.get(lowercased_name, lowercased_name)
38+
39+
@staticmethod
40+
def convert_messages(
41+
messages: List[LDMessage],
42+
) -> List[Union[HumanMessage, SystemMessage, AIMessage]]:
43+
"""
44+
Convert LaunchDarkly messages to LangChain message objects.
45+
46+
:param messages: List of LDMessage objects
47+
:return: List of LangChain message objects
48+
:raises ValueError: If an unsupported message role is encountered
49+
"""
50+
result: List[Union[HumanMessage, SystemMessage, AIMessage]] = []
51+
for msg in messages:
52+
if msg.role == 'system':
53+
result.append(SystemMessage(content=msg.content))
54+
elif msg.role == 'user':
55+
result.append(HumanMessage(content=msg.content))
56+
elif msg.role == 'assistant':
57+
result.append(AIMessage(content=msg.content))
58+
else:
59+
raise ValueError(f'Unsupported message role: {msg.role}')
60+
return result
61+
62+
@staticmethod
63+
def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel:
64+
"""
65+
Create a LangChain BaseChatModel from a LaunchDarkly AI configuration.
66+
67+
:param ai_config: The LaunchDarkly AI configuration
68+
:return: A configured LangChain BaseChatModel
69+
"""
70+
from langchain.chat_models import init_chat_model
71+
72+
config_dict = ai_config.to_dict()
73+
model_dict = config_dict.get('model') or {}
74+
provider_dict = config_dict.get('provider') or {}
75+
76+
model_name = model_dict.get('name', '')
77+
provider = provider_dict.get('name', '')
78+
parameters = model_dict.get('parameters') or {}
79+
80+
return init_chat_model(
81+
model_name,
82+
model_provider=LangChainHelper.map_provider(provider),
83+
**parameters,
84+
)
85+
86+
@staticmethod
87+
def get_ai_metrics_from_response(response: Any) -> LDAIMetrics:
88+
"""
89+
Extract LaunchDarkly AI metrics from a LangChain response.
90+
91+
:param response: The response from a LangChain model (BaseMessage or similar)
92+
:return: LDAIMetrics with success status and token usage
93+
"""
94+
usage: Optional[TokenUsage] = None
95+
if hasattr(response, 'response_metadata') and response.response_metadata:
96+
token_usage = (
97+
response.response_metadata.get('tokenUsage')
98+
or response.response_metadata.get('token_usage')
99+
)
100+
if token_usage:
101+
usage = TokenUsage(
102+
total=token_usage.get('totalTokens', 0) or token_usage.get('total_tokens', 0),
103+
input=token_usage.get('promptTokens', 0) or token_usage.get('prompt_tokens', 0),
104+
output=token_usage.get('completionTokens', 0) or token_usage.get('completion_tokens', 0),
105+
)
106+
return LDAIMetrics(success=True, usage=usage)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""LangChain model runner for LaunchDarkly AI SDK."""
2+
3+
from typing import Any, Dict, List
4+
5+
from langchain_core.language_models.chat_models import BaseChatModel
6+
from langchain_core.messages import BaseMessage
7+
from ldai import LDMessage, log
8+
from ldai.providers.model_runner import ModelRunner
9+
from ldai.providers.types import LDAIMetrics, ModelResponse, StructuredResponse
10+
from ldai_langchain.langchain_helper import LangChainHelper
11+
12+
13+
class LangChainModelRunner(ModelRunner):
14+
"""
15+
ModelRunner implementation for LangChain.
16+
17+
Holds a fully-configured BaseChatModel.
18+
Returned by LangChainConnector.create_model(config).
19+
"""
20+
21+
def __init__(self, llm: BaseChatModel):
22+
self._llm = llm
23+
24+
def get_llm(self) -> BaseChatModel:
25+
"""
26+
Return the underlying LangChain BaseChatModel.
27+
28+
:return: The BaseChatModel instance
29+
"""
30+
return self._llm
31+
32+
async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse:
33+
"""
34+
Invoke the LangChain model with an array of messages.
35+
36+
:param messages: Array of LDMessage objects representing the conversation
37+
:return: ModelResponse containing the model's response and metrics
38+
"""
39+
try:
40+
langchain_messages = LangChainHelper.convert_messages(messages)
41+
response: BaseMessage = await self._llm.ainvoke(langchain_messages)
42+
metrics = LangChainHelper.get_ai_metrics_from_response(response)
43+
44+
content: str = ''
45+
if isinstance(response.content, str):
46+
content = response.content
47+
else:
48+
log.warning(
49+
f'Multimodal response not supported, expecting a string. '
50+
f'Content type: {type(response.content)}, Content: {response.content}'
51+
)
52+
metrics = LDAIMetrics(success=False, usage=metrics.usage)
53+
54+
return ModelResponse(
55+
message=LDMessage(role='assistant', content=content),
56+
metrics=metrics,
57+
)
58+
except Exception as error:
59+
log.warning(f'LangChain model invocation failed: {error}')
60+
return ModelResponse(
61+
message=LDMessage(role='assistant', content=''),
62+
metrics=LDAIMetrics(success=False, usage=None),
63+
)
64+
65+
async def invoke_structured_model(
66+
self,
67+
messages: List[LDMessage],
68+
response_structure: Dict[str, Any],
69+
) -> StructuredResponse:
70+
"""
71+
Invoke the LangChain model with structured output support.
72+
73+
:param messages: Array of LDMessage objects representing the conversation
74+
:param response_structure: Dictionary defining the output structure
75+
:return: StructuredResponse containing the structured data
76+
"""
77+
try:
78+
langchain_messages = LangChainHelper.convert_messages(messages)
79+
structured_llm = self._llm.with_structured_output(response_structure, include_raw=True)
80+
response = await structured_llm.ainvoke(langchain_messages)
81+
82+
if not isinstance(response, dict):
83+
log.warning(f'Structured output did not return a dict. Got: {type(response)}')
84+
return StructuredResponse(
85+
data={},
86+
raw_response='',
87+
metrics=LDAIMetrics(success=False, usage=None),
88+
)
89+
90+
raw_response = response.get('raw')
91+
usage = LangChainHelper.get_ai_usage_from_response(raw_response) if raw_response is not None else None
92+
raw_content = raw_response.content if hasattr(raw_response, 'content') else ''
93+
94+
if response.get('parsing_error'):
95+
log.warning('LangChain structured model invocation had a parsing error')
96+
return StructuredResponse(
97+
data={},
98+
raw_response=raw_content,
99+
metrics=LDAIMetrics(success=False, usage=usage),
100+
)
101+
102+
return StructuredResponse(
103+
data=response.get('parsed') or {},
104+
raw_response=raw_content,
105+
metrics=LDAIMetrics(success=True, usage=usage),
106+
)
107+
except Exception as error:
108+
log.warning(f'LangChain structured model invocation failed: {error}')
109+
return StructuredResponse(
110+
data={},
111+
raw_response='',
112+
metrics=LDAIMetrics(success=False, usage=None),
113+
)
114+

0 commit comments

Comments
 (0)