-
Notifications
You must be signed in to change notification settings - Fork 4
feat: Introduce ManagedModel and ModelRunner (PR-3) #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a23a075
feat: Introduce ManagedModel and ModelRunner
jsonbailey fc3880c
Remove module docstrings and use log.warning instead of log.warn
jsonbailey 61f825b
Fix runner factory docstrings
jsonbailey 9b72435
Fix LangChainHelper: add get_ai_usage_from_response, usage_metadata c…
jsonbailey bdc2aad
fix lint errors
jsonbailey c2f7475
fix: restore structured output pattern, rename convert_messages, remo…
jsonbailey 4cd5800
refactor!: convert LangChainHelper and OpenAIHelper to pure module-le…
jsonbailey 9285ffd
fix: add get_ai_usage_from_response to openai_helper and fix import sort
jsonbailey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
4 changes: 4 additions & 0 deletions
4
packages/ai-providers/server-ai-langchain/src/ldai_langchain/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,12 @@ | ||
| from ldai_langchain.langchain_helper import LangChainHelper | ||
| from ldai_langchain.langchain_model_runner import LangChainModelRunner | ||
| from ldai_langchain.langchain_runner_factory import LangChainRunnerFactory | ||
|
|
||
| __version__ = "0.1.0" | ||
|
|
||
| __all__ = [ | ||
| '__version__', | ||
| 'LangChainRunnerFactory', | ||
| 'LangChainHelper', | ||
| 'LangChainModelRunner', | ||
| ] |
125 changes: 125 additions & 0 deletions
125
packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| from typing import Any, Dict, List, Optional, Union | ||
|
|
||
| from langchain_core.language_models.chat_models import BaseChatModel | ||
| from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage | ||
| from ldai import LDMessage | ||
| from ldai.models import AIConfigKind | ||
| from ldai.providers.types import LDAIMetrics | ||
| from ldai.tracker import TokenUsage | ||
|
|
||
|
|
||
| class LangChainHelper: | ||
| """ | ||
| Shared utilities for LangChain-based runners (model, agent, agent graph). | ||
|
|
||
| All methods are static — this class is a namespace, not meant to be instantiated. | ||
| """ | ||
|
|
||
| @staticmethod | ||
| def map_provider(ld_provider_name: str) -> str: | ||
| """ | ||
| Map a LaunchDarkly provider name to its LangChain equivalent. | ||
|
|
||
| :param ld_provider_name: LaunchDarkly provider name | ||
| :return: LangChain-compatible provider name | ||
| """ | ||
| lowercased_name = ld_provider_name.lower() | ||
| # Bedrock is the only provider that uses "provider:model_family" (e.g. Bedrock:Anthropic). | ||
| if lowercased_name.startswith('bedrock:'): | ||
| return 'bedrock_converse' | ||
|
|
||
| mapping: Dict[str, str] = { | ||
| 'gemini': 'google-genai', | ||
| 'bedrock': 'bedrock_converse', | ||
| } | ||
| return mapping.get(lowercased_name, lowercased_name) | ||
|
|
||
| @staticmethod | ||
| def convert_messages( | ||
| messages: List[LDMessage], | ||
| ) -> List[Union[HumanMessage, SystemMessage, AIMessage]]: | ||
| """ | ||
| Convert LaunchDarkly messages to LangChain message objects. | ||
|
|
||
| :param messages: List of LDMessage objects | ||
| :return: List of LangChain message objects | ||
| :raises ValueError: If an unsupported message role is encountered | ||
| """ | ||
| result: List[Union[HumanMessage, SystemMessage, AIMessage]] = [] | ||
| for msg in messages: | ||
| if msg.role == 'system': | ||
| result.append(SystemMessage(content=msg.content)) | ||
| elif msg.role == 'user': | ||
| result.append(HumanMessage(content=msg.content)) | ||
| elif msg.role == 'assistant': | ||
| result.append(AIMessage(content=msg.content)) | ||
| else: | ||
| raise ValueError(f'Unsupported message role: {msg.role}') | ||
| return result | ||
|
|
||
| @staticmethod | ||
| def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel: | ||
| """ | ||
| Create a LangChain BaseChatModel from a LaunchDarkly AI configuration. | ||
|
|
||
| :param ai_config: The LaunchDarkly AI configuration | ||
| :return: A configured LangChain BaseChatModel | ||
| """ | ||
| from langchain.chat_models import init_chat_model | ||
|
|
||
| config_dict = ai_config.to_dict() | ||
| model_dict = config_dict.get('model') or {} | ||
| provider_dict = config_dict.get('provider') or {} | ||
|
|
||
| model_name = model_dict.get('name', '') | ||
| provider = provider_dict.get('name', '') | ||
| parameters = dict(model_dict.get('parameters') or {}) | ||
| mapped_provider = LangChainHelper.map_provider(provider) | ||
|
|
||
| # Bedrock requires the foundation provider (e.g. Bedrock:Anthropic) passed in | ||
| # parameters separately from model_provider, which is used for LangChain routing. | ||
| if mapped_provider == 'bedrock_converse' and 'provider' not in parameters: | ||
| parameters['provider'] = provider.removeprefix('bedrock:') | ||
|
|
||
| return init_chat_model( | ||
| model_name, | ||
| model_provider=mapped_provider, | ||
| **parameters, | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| @staticmethod | ||
| def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]: | ||
| """ | ||
| Extract token usage from a LangChain response. | ||
|
|
||
| :param response: The response from a LangChain model (BaseMessage or similar) | ||
| :return: TokenUsage or None if unavailable | ||
| """ | ||
| if hasattr(response, 'usage_metadata') and response.usage_metadata: | ||
| return TokenUsage( | ||
| total=response.usage_metadata.get('total_tokens', 0), | ||
| input=response.usage_metadata.get('input_tokens', 0), | ||
| output=response.usage_metadata.get('output_tokens', 0), | ||
| ) | ||
| if hasattr(response, 'response_metadata') and response.response_metadata: | ||
| token_usage = ( | ||
| response.response_metadata.get('tokenUsage') | ||
| or response.response_metadata.get('token_usage') | ||
| ) | ||
| if token_usage: | ||
| return TokenUsage( | ||
| total=token_usage.get('totalTokens', 0) or token_usage.get('total_tokens', 0), | ||
| input=token_usage.get('promptTokens', 0) or token_usage.get('prompt_tokens', 0), | ||
| output=token_usage.get('completionTokens', 0) or token_usage.get('completion_tokens', 0), | ||
| ) | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: | ||
| """ | ||
| Extract LaunchDarkly AI metrics from a LangChain response. | ||
|
|
||
| :param response: The response from a LangChain model (BaseMessage or similar) | ||
| :return: LDAIMetrics with success status and token usage | ||
| """ | ||
| return LDAIMetrics(success=True, usage=LangChainHelper.get_ai_usage_from_response(response)) | ||
112 changes: 112 additions & 0 deletions
112
packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_model_runner.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| from typing import Any, Dict, List | ||
|
|
||
| from langchain_core.language_models.chat_models import BaseChatModel | ||
| from langchain_core.messages import BaseMessage | ||
| from ldai import LDMessage, log | ||
| from ldai.providers.model_runner import ModelRunner | ||
| from ldai.providers.types import LDAIMetrics, ModelResponse, StructuredResponse | ||
| from ldai_langchain.langchain_helper import LangChainHelper | ||
|
|
||
|
|
||
| class LangChainModelRunner(ModelRunner): | ||
| """ | ||
| ModelRunner implementation for LangChain. | ||
|
|
||
| Holds a fully-configured BaseChatModel. | ||
| Returned by LangChainConnector.create_model(config). | ||
| """ | ||
|
|
||
| def __init__(self, llm: BaseChatModel): | ||
| self._llm = llm | ||
|
|
||
| def get_llm(self) -> BaseChatModel: | ||
| """ | ||
| Return the underlying LangChain BaseChatModel. | ||
|
|
||
| :return: The BaseChatModel instance | ||
| """ | ||
| return self._llm | ||
|
|
||
| async def invoke_model(self, messages: List[LDMessage]) -> ModelResponse: | ||
| """ | ||
| Invoke the LangChain model with an array of messages. | ||
|
|
||
| :param messages: Array of LDMessage objects representing the conversation | ||
| :return: ModelResponse containing the model's response and metrics | ||
| """ | ||
| try: | ||
| langchain_messages = LangChainHelper.convert_messages(messages) | ||
| response: BaseMessage = await self._llm.ainvoke(langchain_messages) | ||
| metrics = LangChainHelper.get_ai_metrics_from_response(response) | ||
|
|
||
| content: str = '' | ||
| if isinstance(response.content, str): | ||
| content = response.content | ||
| else: | ||
| log.warning( | ||
| f'Multimodal response not supported, expecting a string. ' | ||
| f'Content type: {type(response.content)}, Content: {response.content}' | ||
| ) | ||
| metrics = LDAIMetrics(success=False, usage=metrics.usage) | ||
|
|
||
| return ModelResponse( | ||
| message=LDMessage(role='assistant', content=content), | ||
| metrics=metrics, | ||
| ) | ||
| except Exception as error: | ||
| log.warning(f'LangChain model invocation failed: {error}') | ||
| return ModelResponse( | ||
| message=LDMessage(role='assistant', content=''), | ||
| metrics=LDAIMetrics(success=False, usage=None), | ||
| ) | ||
|
|
||
| async def invoke_structured_model( | ||
| self, | ||
| messages: List[LDMessage], | ||
| response_structure: Dict[str, Any], | ||
| ) -> StructuredResponse: | ||
| """ | ||
| Invoke the LangChain model with structured output support. | ||
|
|
||
| :param messages: Array of LDMessage objects representing the conversation | ||
| :param response_structure: Dictionary defining the output structure | ||
| :return: StructuredResponse containing the structured data | ||
| """ | ||
| try: | ||
| langchain_messages = LangChainHelper.convert_messages(messages) | ||
| structured_llm = self._llm.with_structured_output(response_structure, include_raw=True) | ||
| response = await structured_llm.ainvoke(langchain_messages) | ||
|
|
||
| if not isinstance(response, dict): | ||
| log.warning(f'Structured output did not return a dict. Got: {type(response)}') | ||
| return StructuredResponse( | ||
| data={}, | ||
| raw_response='', | ||
| metrics=LDAIMetrics(success=False, usage=None), | ||
| ) | ||
|
|
||
| raw_response = response.get('raw') | ||
| usage = LangChainHelper.get_ai_usage_from_response(raw_response) if raw_response is not None else None | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| raw_content = raw_response.content if hasattr(raw_response, 'content') else '' | ||
|
|
||
| if response.get('parsing_error'): | ||
| log.warning('LangChain structured model invocation had a parsing error') | ||
| return StructuredResponse( | ||
| data={}, | ||
| raw_response=raw_content, | ||
| metrics=LDAIMetrics(success=False, usage=usage), | ||
| ) | ||
|
|
||
| return StructuredResponse( | ||
| data=response.get('parsed') or {}, | ||
| raw_response=raw_content, | ||
| metrics=LDAIMetrics(success=True, usage=usage), | ||
| ) | ||
| except Exception as error: | ||
| log.warning(f'LangChain structured model invocation failed: {error}') | ||
| return StructuredResponse( | ||
| data={}, | ||
| raw_response='', | ||
| metrics=LDAIMetrics(success=False, usage=None), | ||
| ) | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.