Skip to content

Commit 453c71c

Browse files
authored
feat: Introduce ManagedModel and ModelRunner (PR-3) (#104)
feat: Add ModelRunner ABC with invoke_model() and invoke_structured_model() 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!: Extract shared utilities to openai_helper feat!: Extract shared utilities to langchain_helper feat: Deprecated create_chat(), use create_model() on the LDAIClient feat: Deprecated Chat object in favor of ManagedModel
1 parent ebd5166 commit 453c71c

21 files changed

Lines changed: 769 additions & 714 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1+
from ldai_langchain.langchain_helper import (
2+
convert_messages_to_langchain,
3+
create_langchain_model,
4+
get_ai_metrics_from_response,
5+
get_ai_usage_from_response,
6+
map_provider,
7+
)
8+
from ldai_langchain.langchain_model_runner import LangChainModelRunner
19
from ldai_langchain.langchain_runner_factory import LangChainRunnerFactory
210

311
__version__ = "0.1.0"
412

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

0 commit comments

Comments
 (0)