From de0a29a6fed7a365e1de8ae890cde1a19b75f07d Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Tue, 24 Jun 2025 14:38:43 -0700 Subject: [PATCH 01/16] add user_specified_langchain_llm_factory --- .../langchain/core/langchain_run_context.py | 18 ++- .../langchain/llms/default_llm_factory.py | 11 ++ .../user_specified_langchain_llm_factory.py | 105 ++++++++++++++++++ .../langchain/util/api_key_error_check.py | 10 +- 4 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py diff --git a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py index e19813378..474d199db 100644 --- a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py +++ b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py @@ -23,9 +23,9 @@ from logging import Logger from logging import getLogger -from openai import APIError -from anthropic import BadRequestError -from anthropic import AuthenticationError +from ollama import ResponseError +import openai +import anthropic from pydantic_core import ValidationError @@ -495,7 +495,7 @@ async def ainvoke(self, agent_executor: AgentExecutor, inputs: Dict[str, Any], i while return_dict is None and retries > 0: try: return_dict: Dict[str, Any] = await agent_executor.ainvoke(inputs, invoke_config) - except (APIError, BadRequestError, AuthenticationError, ChatGoogleGenerativeAIError) as api_error: + except (openai.APIError, anthropic.APIError, ChatGoogleGenerativeAIError) as api_error: message: str = ApiKeyErrorCheck.check_for_api_key_exception(api_error) if message is not None: raise ValueError(message) from api_error @@ -509,6 +509,16 @@ async def ainvoke(self, agent_executor: AgentExecutor, inputs: Dict[str, Any], i retries = retries - 1 exception = key_error backtrace = traceback.format_exc() + except ResponseError as ollama_response_error: + self.logger.warning("retrying from Ollama ResponseError") + retries = retries - 1 + exception = ollama_response_error + backtrace = traceback.format_exc() + except TypeError as type_error: + self.logger.warning("retrying from TypeError") + retries = retries - 1 + exception = type_error + backtrace = traceback.format_exc() except ValueError as value_error: response = str(value_error) find_string = "An output parsing error occurred. " + \ diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 60436c751..c96d10b8a 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -32,6 +32,7 @@ from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory from neuro_san.internals.run_context.langchain.llms.llm_info_restorer import LlmInfoRestorer from neuro_san.internals.run_context.langchain.llms.standard_langchain_llm_factory import StandardLangChainLlmFactory +from neuro_san.internals.run_context.langchain.llms.user_specified_langchain_llm_factory import UserSpecifiedLangChainLlmFactory from neuro_san.internals.run_context.langchain.util.api_key_error_check import ApiKeyErrorCheck @@ -71,6 +72,7 @@ def __init__(self): self.llm_factories: List[LangChainLlmFactory] = [ StandardLangChainLlmFactory() ] + self.llm_class: str = None def load(self, agent_llm_info_file: str): """ @@ -161,6 +163,7 @@ def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler """ full_config: Dict[str, Any] = self.create_full_llm_config(config) llm: BaseLanguageModel = self.create_base_chat_model(full_config, callbacks) + print(f"\n\n{llm=}\n\n") return llm def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: @@ -168,6 +171,14 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: :param config: The llm_config from the user :return: The fully specified config with defaults filled in. """ + + self.llm_class = config.get("class") + if self.llm_class: + # If config has "class", it is user specified llm so return config as is, + # and replace "StandardLangChainLlmFactory" with "UserSpecifiedLangChainLlmFactory". + self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() + return config + default_config: Dict[str, Any] = self.llm_infos.get("default_config") use_config = self.overlayer.overlay(default_config, config) diff --git a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py new file mode 100644 index 000000000..5b483f1d5 --- /dev/null +++ b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py @@ -0,0 +1,105 @@ + +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +from typing import Any +from typing import Dict +from typing import List + +from langchain_anthropic.chat_models import ChatAnthropic +from langchain_google_genai.chat_models import ChatGoogleGenerativeAI +from langchain_ollama import ChatOllama +from langchain_nvidia_ai_endpoints import ChatNVIDIA +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.language_models.base import BaseLanguageModel +from langchain_openai.chat_models.azure import AzureChatOpenAI +from langchain_openai.chat_models.base import ChatOpenAI + +from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory + + +class UserSpecifiedLangChainLlmFactory(LangChainLlmFactory): + """ + Factory class for LLM operations + + Most methods take a config dictionary which consists of the following keys: + + "model_name" The name of the model. + Default if not specified is "gpt-3.5-turbo" + + "temperature" A float "temperature" value with which to + initialize the chat model. In general, + higher temperatures yield more random results. + Default if not specified is 0.7 + + "prompt_token_fraction" The fraction of total tokens (not necessarily words + or letters) to use for a prompt. Each model_name + has a documented number of max_tokens it can handle + which is a total count of message + response tokens + which goes into the calculation involved in + get_max_prompt_tokens(). + By default the value is 0.5. + + "max_tokens" The maximum number of tokens to use in + get_max_prompt_tokens(). By default this comes from + the model description in this class. + """ + + def create_base_chat_model(self, config: Dict[str, Any], + callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: + """ + Create a BaseLanguageModel from the fully-specified llm config. + :param config: The fully specified llm config which is a product of + _create_full_llm_config() above. + :param callbacks: A list of BaseCallbackHandlers to add to the chat model. + :return: A BaseLanguageModel (can be Chat or LLM) + Can raise a ValueError if the config's class or model_name value is + unknown to this method. + """ + # Construct the LLM + llm: BaseLanguageModel = None + chat_class: str = config.get("class") + if chat_class is not None: + chat_class = chat_class.lower() + + # Take "class" out of config and add "callback". + config.pop("class") + config["callbacks"] = callbacks + + # Unpack config in the user-specified class + if chat_class == "openai": + llm = ChatOpenAI(**config) + elif chat_class == "azure-openai": + llm = AzureChatOpenAI(**config) + elif chat_class == "anthropic": + llm = ChatAnthropic(**config) + elif chat_class == "ollama": + llm = ChatOllama(**config) + elif chat_class == "nvidia": + llm = ChatNVIDIA(**config) + elif chat_class == "gemini": + llm = ChatGoogleGenerativeAI(**config) + else: + valid_class_map = { + "openai": "ChatOpenAI", + "azure-openai": "AzureChatOpenAI", + "anthropic": "ChatAnthropic", + "ollama": "ChatOllama", + "nvidia": "ChatNVIDIA", + "gemini": "ChatGoogleGenerativeAI", + } + available = "\n".join(f" - '{key}': {val}" for key, val in valid_class_map.items()) + raise ValueError( + f"Unrecognized model class '{chat_class}'.\n" + f"Valid class values and their corresponding implementations are:\n{available}" + ) + + return llm diff --git a/neuro_san/internals/run_context/langchain/util/api_key_error_check.py b/neuro_san/internals/run_context/langchain/util/api_key_error_check.py index 62c404a4d..853a33231 100644 --- a/neuro_san/internals/run_context/langchain/util/api_key_error_check.py +++ b/neuro_san/internals/run_context/langchain/util/api_key_error_check.py @@ -22,12 +22,10 @@ # Azure OpenAI requires several parameters; all can be set via environment variables # except "deployment_name", which must be provided explicitly. - "AZURE_OPENAI_API_KEY": ["Error code: 401", "invalid subscription key", "wrong API endpoint", "Connection error"], - "AZURE_OPENAI_ENDPOINT": ["validation error", "base_url", "azure_endpoint", "AZURE_OPENAI_ENDPOINT", - "Connection error"], - "OPENAI_API_VERSION": ["validation error", "api_version", "OPENAI_API_VERSION", "Error code: 404", - "Resource not found"], - "deployment_name": ["Error code: 404", "Resource not found", "API deployment for this resource does not exist"], + "AZURE_OPENAI_API_KEY": ["invalid subscription key", "wrong API endpoint"], + "AZURE_OPENAI_ENDPOINT": ["base_url", "azure_endpoint", "AZURE_OPENAI_ENDPOINT"], + "OPENAI_API_VERSION": ["api_version", "OPENAI_API_VERSION"], + "deployment_name": ["API deployment for this resource does not exist"], } From ecdee784a8d19d0f68fc5fed9895b686400c97d2 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Tue, 24 Jun 2025 14:41:27 -0700 Subject: [PATCH 02/16] fix indent and pylint --- .../run_context/langchain/llms/default_llm_factory.py | 4 ++-- neuro_san/registries/google_serper.hocon | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index c96d10b8a..49e2496f3 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -32,7 +32,8 @@ from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory from neuro_san.internals.run_context.langchain.llms.llm_info_restorer import LlmInfoRestorer from neuro_san.internals.run_context.langchain.llms.standard_langchain_llm_factory import StandardLangChainLlmFactory -from neuro_san.internals.run_context.langchain.llms.user_specified_langchain_llm_factory import UserSpecifiedLangChainLlmFactory +from neuro_san.internals.run_context.langchain.llms.user_specified_langchain_llm_factory import \ + UserSpecifiedLangChainLlmFactory from neuro_san.internals.run_context.langchain.util.api_key_error_check import ApiKeyErrorCheck @@ -163,7 +164,6 @@ def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler """ full_config: Dict[str, Any] = self.create_full_llm_config(config) llm: BaseLanguageModel = self.create_base_chat_model(full_config, callbacks) - print(f"\n\n{llm=}\n\n") return llm def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: diff --git a/neuro_san/registries/google_serper.hocon b/neuro_san/registries/google_serper.hocon index 956607333..0d58a68f6 100644 --- a/neuro_san/registries/google_serper.hocon +++ b/neuro_san/registries/google_serper.hocon @@ -28,9 +28,9 @@ { "name": "searcher", "instructions": "Use your tool to respond to the inquiry.", - "function": { + "function": { "description": "Assist user with answer from internet." - } + } "tools": ["search_tool"] }, { From d23186e7376e12c6c0ba4e8dec5811de74fda25c Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Tue, 24 Jun 2025 14:53:34 -0700 Subject: [PATCH 03/16] Add more comments --- .../langchain/llms/default_llm_factory.py | 2 +- .../user_specified_langchain_llm_factory.py | 31 ++++--------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 49e2496f3..bf3e90b6f 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -174,7 +174,7 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: self.llm_class = config.get("class") if self.llm_class: - # If config has "class", it is user specified llm so return config as is, + # If config has "class", it is a user-specified llm so return config as is, # and replace "StandardLangChainLlmFactory" with "UserSpecifiedLangChainLlmFactory". self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() return config diff --git a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py index 5b483f1d5..772b3ed0b 100644 --- a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py @@ -28,37 +28,18 @@ class UserSpecifiedLangChainLlmFactory(LangChainLlmFactory): """ - Factory class for LLM operations + A factory for constructing LLMs based on user-specified configurations provided under the "llm_config" + section of the agent network HOCON file. - Most methods take a config dictionary which consists of the following keys: - - "model_name" The name of the model. - Default if not specified is "gpt-3.5-turbo" - - "temperature" A float "temperature" value with which to - initialize the chat model. In general, - higher temperatures yield more random results. - Default if not specified is 0.7 - - "prompt_token_fraction" The fraction of total tokens (not necessarily words - or letters) to use for a prompt. Each model_name - has a documented number of max_tokens it can handle - which is a total count of message + response tokens - which goes into the calculation involved in - get_max_prompt_tokens(). - By default the value is 0.5. - - "max_tokens" The maximum number of tokens to use in - get_max_prompt_tokens(). By default this comes from - the model description in this class. + The specific LLM class to instantiate is determined by the "class" field in "llm_config", and all + other keys in the config are passed as arguments to that class's constructor. """ def create_base_chat_model(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: """ - Create a BaseLanguageModel from the fully-specified llm config. - :param config: The fully specified llm config which is a product of - _create_full_llm_config() above. + Create a BaseLanguageModel from the user-specified llm config. + :param config: The user-specified llm config :param callbacks: A list of BaseCallbackHandlers to add to the chat model. :return: A BaseLanguageModel (can be Chat or LLM) Can raise a ValueError if the config's class or model_name value is From 58b3b8be157c3490ddbee10b2ebbc79f4c8265cc Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Thu, 26 Jun 2025 11:08:07 -0700 Subject: [PATCH 04/16] remove ollama response error --- .../run_context/langchain/core/langchain_run_context.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py index 474d199db..415622cd4 100644 --- a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py +++ b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py @@ -23,7 +23,6 @@ from logging import Logger from logging import getLogger -from ollama import ResponseError import openai import anthropic @@ -509,11 +508,6 @@ async def ainvoke(self, agent_executor: AgentExecutor, inputs: Dict[str, Any], i retries = retries - 1 exception = key_error backtrace = traceback.format_exc() - except ResponseError as ollama_response_error: - self.logger.warning("retrying from Ollama ResponseError") - retries = retries - 1 - exception = ollama_response_error - backtrace = traceback.format_exc() except TypeError as type_error: self.logger.warning("retrying from TypeError") retries = retries - 1 From 23891dbd7bcf0bf346ba60f0ef8ec649370e3985 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Mon, 30 Jun 2025 14:45:46 -0700 Subject: [PATCH 05/16] minor changes --- .../langchain/llms/default_llm_factory.py | 4 +--- .../user_specified_langchain_llm_factory.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index b0f3bbe95..649b2dac0 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -77,7 +77,6 @@ def __init__(self, config: Optional[Dict[str, Any]] = None): self.llm_factories: List[LangChainLlmFactory] = [ StandardLangChainLlmFactory() ] - self.llm_class: str = None if config: self.llm_info_file: str = config.get("agent_llm_info_file") @@ -181,8 +180,7 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: :return: The fully specified config with defaults filled in. """ - self.llm_class = config.get("class") - if self.llm_class: + if config.get("class"): # If config has "class", it is a user-specified llm so return config as is, # and replace "StandardLangChainLlmFactory" with "UserSpecifiedLangChainLlmFactory". self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() diff --git a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py index 772b3ed0b..f1ba0181c 100644 --- a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py @@ -51,23 +51,26 @@ def create_base_chat_model(self, config: Dict[str, Any], if chat_class is not None: chat_class = chat_class.lower() + # Copy the config + user_config = config.copy() + # Take "class" out of config and add "callback". - config.pop("class") - config["callbacks"] = callbacks + user_config.pop("class") + user_config["callbacks"] = callbacks # Unpack config in the user-specified class if chat_class == "openai": - llm = ChatOpenAI(**config) + llm = ChatOpenAI(**user_config) elif chat_class == "azure-openai": - llm = AzureChatOpenAI(**config) + llm = AzureChatOpenAI(**user_config) elif chat_class == "anthropic": - llm = ChatAnthropic(**config) + llm = ChatAnthropic(**user_config) elif chat_class == "ollama": - llm = ChatOllama(**config) + llm = ChatOllama(**user_config) elif chat_class == "nvidia": - llm = ChatNVIDIA(**config) + llm = ChatNVIDIA(**user_config) elif chat_class == "gemini": - llm = ChatGoogleGenerativeAI(**config) + llm = ChatGoogleGenerativeAI(**user_config) else: valid_class_map = { "openai": "ChatOpenAI", From 8e4d8815a82758c381e2ad3cd31bf05b49c0b84d Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Wed, 2 Jul 2025 01:16:52 -0700 Subject: [PATCH 06/16] - Remove user_specified_langchain_llm_factory - Resolve llm class in default_llm_factory when all factories fail - Some test hocons --- llm_extension/groq_langchain_llm_factory.py | 58 ++++++++++++ llm_extension/llm_info.hocon | 26 ++++++ .../langchain/llms/default_llm_factory.py | 84 ++++++++++++----- .../llms/standard_langchain_llm_factory.py | 2 +- .../user_specified_langchain_llm_factory.py | 89 ------------------- .../langchain/toolbox/toolbox_factory.py | 19 ++-- neuro_san/registries/manifest.hocon | 4 +- neuro_san/registries/test_new_class.hocon | 64 +++++++++++++ .../test_new_model_default_class.hocon | 64 +++++++++++++ .../test_new_model_extended_class.hocon | 65 ++++++++++++++ 10 files changed, 358 insertions(+), 117 deletions(-) create mode 100644 llm_extension/groq_langchain_llm_factory.py create mode 100644 llm_extension/llm_info.hocon delete mode 100644 neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py create mode 100644 neuro_san/registries/test_new_class.hocon create mode 100644 neuro_san/registries/test_new_model_default_class.hocon create mode 100644 neuro_san/registries/test_new_model_extended_class.hocon diff --git a/llm_extension/groq_langchain_llm_factory.py b/llm_extension/groq_langchain_llm_factory.py new file mode 100644 index 000000000..9ab2f1625 --- /dev/null +++ b/llm_extension/groq_langchain_llm_factory.py @@ -0,0 +1,58 @@ + +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +from typing import Any +from typing import Dict +from typing import List + +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.language_models.base import BaseLanguageModel +from langchain_groq import ChatGroq + +from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory + + +class GroqLangChainLlmFactory(LangChainLlmFactory): + """ + Factory class for LLM operations + """ + + def create_base_chat_model(self, config: Dict[str, Any], + callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: + """ + Create a BaseLanguageModel from the fully-specified llm config. + :param config: The fully specified llm config which is a product of + _create_full_llm_config() above. + :param callbacks: A list of BaseCallbackHandlers to add to the chat model. + :return: A BaseLanguageModel (can be Chat or LLM) + Can raise a ValueError if the config's class or model_name value is + unknown to this method. + """ + # Construct the LLM + llm: BaseLanguageModel = None + chat_class: str = config.get("class") + if chat_class is not None: + chat_class = chat_class.lower() + + model_name: str = config.get("model_name") + + if chat_class == "groq": + llm = ChatGroq( + model=model_name, + temperature=config.get("temperature") + ) + elif chat_class is None: + raise ValueError(f"Class name {chat_class} for model_name {model_name} is unspecified.") + else: + raise ValueError(f"Class {chat_class} for model_name {model_name} is unrecognized.") + + return llm diff --git a/llm_extension/llm_info.hocon b/llm_extension/llm_info.hocon new file mode 100644 index 000000000..b5e59f2e1 --- /dev/null +++ b/llm_extension/llm_info.hocon @@ -0,0 +1,26 @@ +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +# The schema specifications for this file are documented here: +# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/llm_info_hocon_reference.md + +{ + + + "classes": { + "factories": [ "llm_extension.groq_langchain_llm_factory.GroqLangChainLlmFactory" ] + "groq": { + # Add arguments like temperature that you want to pass to the llm here. + "temperature": 0.7 + } + } + +} diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 649b2dac0..8e5e319b7 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -33,8 +33,7 @@ from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory from neuro_san.internals.run_context.langchain.llms.llm_info_restorer import LlmInfoRestorer from neuro_san.internals.run_context.langchain.llms.standard_langchain_llm_factory import StandardLangChainLlmFactory -from neuro_san.internals.run_context.langchain.llms.user_specified_langchain_llm_factory import \ - UserSpecifiedLangChainLlmFactory +from neuro_san.internals.run_context.langchain.toolbox.toolbox_factory import ToolboxFactory from neuro_san.internals.run_context.langchain.util.api_key_error_check import ApiKeyErrorCheck @@ -126,24 +125,13 @@ def resolve_one_llm_factory(self, llm_factory_class_name: str, llm_info_file: st raise ValueError(f"The value for the classes.factories key in {llm_info_file} " "must be a list of strings") - class_split: List[str] = llm_factory_class_name.split(".") - if len(class_split) <= 2: - raise ValueError(f"Value in the classes.factories in {llm_info_file} must be of the form " - "..") - - # Create a list of a single package given the name in the value - packages: List[str] = [".".join(class_split[:-2])] - class_name: str = class_split[-1] - resolver = Resolver(packages) - - # Resolve the class name - llm_factory_class: Type[LangChainLlmFactory] = None - try: - llm_factory_class: Type[LangChainLlmFactory] = \ - resolver.resolve_class_in_module(class_name, module_name=class_split[-2]) - except AttributeError as exception: - raise ValueError(f"Class {llm_factory_class_name} in {llm_info_file} " - "not found in PYTHONPATH") from exception + # Resolve the factory class + llm_factory_class = self._resolve_class_from_path( + class_path=llm_factory_class_name, + expected_base=LangChainLlmFactory, + source_file=llm_info_file, + description="classes.factories" + ) # Instantiate it try: @@ -158,6 +146,38 @@ def resolve_one_llm_factory(self, llm_factory_class_name: str, llm_info_file: st "must be of type LangChainLlmFactory") return llm_factory + def _resolve_class_from_path( + self, + class_path: str, + expected_base: Type, + source_file: str, + description: str + ) -> Type: + + parts = class_path.split(".") + if len(parts) <= 2: + raise ValueError( + f"Value for '{description}' in {source_file} must be of the form " + ".." + ) + + module_name = parts[-2] + class_name = parts[-1] + packages = [".".join(parts[:-2])] + resolver = Resolver(packages) + + try: + cls = resolver.resolve_class_in_module(class_name, module_name=module_name) + except AttributeError as e: + raise ValueError(f"Class {class_path} in {source_file} not found in PYTHONPATH") from e + + if not issubclass(cls, expected_base): + raise ValueError( + f"Class {class_path} in {source_file} must be a subclass of {expected_base.__name__}" + ) + + return cls + def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: """ Creates a langchain LLM based on the 'model_name' value of @@ -172,6 +192,7 @@ def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler """ full_config: Dict[str, Any] = self.create_full_llm_config(config) llm: BaseLanguageModel = self.create_base_chat_model(full_config, callbacks) + print(f"\n\n{llm=}\n\n") return llm def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: @@ -183,7 +204,7 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: if config.get("class"): # If config has "class", it is a user-specified llm so return config as is, # and replace "StandardLangChainLlmFactory" with "UserSpecifiedLangChainLlmFactory". - self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() + # self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() return config default_config: Dict[str, Any] = self.llm_infos.get("default_config") @@ -289,6 +310,27 @@ def create_base_chat_model(self, config: Dict[str, Any], # Let the next model have a crack found_exception = exception + # Try resolving via 'class' in config if factories failed + class_path = config.get("class") + if found_exception is not None and class_path: + if not isinstance(class_path, str): + raise ValueError("'class' in llm_config must be a string") + + # Resolve the 'class' + llm_class = self._resolve_class_from_path( + class_path=class_path, + expected_base=BaseLanguageModel, + source_file="agent network hocon file", + description="llm_config" + ) + + # copy the config, take 'class' out, and unpack into llm constructor + user_config = config.copy() + user_config.pop("class") + ToolboxFactory.check_invalid_args(llm_class, user_config) + llm = llm_class(**user_config) + found_exception = None + if found_exception is not None: raise found_exception diff --git a/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py index ef101be59..b92b25193 100644 --- a/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py @@ -70,7 +70,7 @@ def create_base_chat_model(self, config: Dict[str, Any], if chat_class is not None: chat_class = chat_class.lower() - model_name: str = config.get("model_name") + model_name: str = config.get("model_name") or config.get("model") or config.get("model_id") if chat_class == "openai": llm = ChatOpenAI( diff --git a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py deleted file mode 100644 index f1ba0181c..000000000 --- a/neuro_san/internals/run_context/langchain/llms/user_specified_langchain_llm_factory.py +++ /dev/null @@ -1,89 +0,0 @@ - -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -from typing import Any -from typing import Dict -from typing import List - -from langchain_anthropic.chat_models import ChatAnthropic -from langchain_google_genai.chat_models import ChatGoogleGenerativeAI -from langchain_ollama import ChatOllama -from langchain_nvidia_ai_endpoints import ChatNVIDIA -from langchain_core.callbacks.base import BaseCallbackHandler -from langchain_core.language_models.base import BaseLanguageModel -from langchain_openai.chat_models.azure import AzureChatOpenAI -from langchain_openai.chat_models.base import ChatOpenAI - -from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory - - -class UserSpecifiedLangChainLlmFactory(LangChainLlmFactory): - """ - A factory for constructing LLMs based on user-specified configurations provided under the "llm_config" - section of the agent network HOCON file. - - The specific LLM class to instantiate is determined by the "class" field in "llm_config", and all - other keys in the config are passed as arguments to that class's constructor. - """ - - def create_base_chat_model(self, config: Dict[str, Any], - callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: - """ - Create a BaseLanguageModel from the user-specified llm config. - :param config: The user-specified llm config - :param callbacks: A list of BaseCallbackHandlers to add to the chat model. - :return: A BaseLanguageModel (can be Chat or LLM) - Can raise a ValueError if the config's class or model_name value is - unknown to this method. - """ - # Construct the LLM - llm: BaseLanguageModel = None - chat_class: str = config.get("class") - if chat_class is not None: - chat_class = chat_class.lower() - - # Copy the config - user_config = config.copy() - - # Take "class" out of config and add "callback". - user_config.pop("class") - user_config["callbacks"] = callbacks - - # Unpack config in the user-specified class - if chat_class == "openai": - llm = ChatOpenAI(**user_config) - elif chat_class == "azure-openai": - llm = AzureChatOpenAI(**user_config) - elif chat_class == "anthropic": - llm = ChatAnthropic(**user_config) - elif chat_class == "ollama": - llm = ChatOllama(**user_config) - elif chat_class == "nvidia": - llm = ChatNVIDIA(**user_config) - elif chat_class == "gemini": - llm = ChatGoogleGenerativeAI(**user_config) - else: - valid_class_map = { - "openai": "ChatOpenAI", - "azure-openai": "AzureChatOpenAI", - "anthropic": "ChatAnthropic", - "ollama": "ChatOllama", - "nvidia": "ChatNVIDIA", - "gemini": "ChatGoogleGenerativeAI", - } - available = "\n".join(f" - '{key}': {val}" for key, val in valid_class_map.items()) - raise ValueError( - f"Unrecognized model class '{chat_class}'.\n" - f"Valid class values and their corresponding implementations are:\n{available}" - ) - - return llm diff --git a/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py b/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py index 5e28c3fda..5bd8e5466 100644 --- a/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py +++ b/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py @@ -10,6 +10,7 @@ # # END COPYRIGHT +from inspect import isclass from inspect import signature from types import MethodType from typing import Any @@ -152,7 +153,7 @@ def create_tool_from_toolbox( self._get_from_api_wrapper_method(tool_class) or tool_class # Validate and instantiate - self._check_invalid_args(callable_obj, final_args) + ToolboxFactory.check_invalid_args(callable_obj, final_args) # Instance can be a BaseTool or a BaseToolkit instance: Union[BaseTool, BaseToolkit] = callable_obj(**final_args) @@ -178,7 +179,7 @@ def _resolve_args(self, args: Dict[str, Any]) -> Dict[str, Any]: # If the argument is a class definition, resolve and instantiate it nested_class: BaseModel = self._resolve_class(value.get("class")) nested_args: Dict[str, Any] = self._resolve_args(value.get("args", empty)) - self._check_invalid_args(nested_class, nested_args) + ToolboxFactory.check_invalid_args(nested_class, nested_args) resolved_args[key] = nested_class(**nested_args) else: # Otherwise, keep primitive values as they are @@ -210,16 +211,24 @@ def _resolve_class(self, class_path: str) -> Type[BaseTool]: except AttributeError as exception: raise ValueError(f"Class {class_path} not found in PYTHONPATH") from exception - def _check_invalid_args(self, method_class: Union[Type, MethodType], args: Dict[str, Any]): + @staticmethod + def check_invalid_args(method_class: Union[Type, MethodType], args: Dict[str, Any]): """ Check for invalid arguments in class or method :param method_class: Class or method to check for the invalid arguments :param args: Arguments to check """ - class_args_set: Set[str] = set(signature(method_class).parameters.keys()) + pydantic_args: Set[str] = set() + # Check for if it is a class that extends pydantic BaseModel + if isclass(method_class) and issubclass(method_class, BaseModel): + # Include field names as args + pydantic_args = set(method_class.model_fields.keys()) + # Combine the arguments + class_args_set: Set[str] = set(signature(method_class).parameters.keys()).union(pydantic_args) args_set: Set[str] = set(args.keys()) - invalid_args: Set[str] = args_set - class_args_set + # If there are args that are not from class args or alias, raise error + invalid_args: Set[str] = args_set - class_args_set if invalid_args: raise ValueError( f"Arguments {invalid_args} for '{method_class.__name__}' do not match any attributes " diff --git a/neuro_san/registries/manifest.hocon b/neuro_san/registries/manifest.hocon index e0c12757a..8f8cff035 100644 --- a/neuro_san/registries/manifest.hocon +++ b/neuro_san/registries/manifest.hocon @@ -46,7 +46,9 @@ # Agents having to do with test infrastructure "gist.hocon": true, "assess_failure.hocon": true, - + "test_new_model_default_class.hocon": true, + "test_new_class.hocon": true, + "test_new_model_extended_class.hocon": true # STOP AND READ: YOU PROBABLY DON'T WANT TO ADD YOUR .hocon FILE HERE. # # The agent network .hocon files above are examples specific to the neuro-san library. diff --git a/neuro_san/registries/test_new_class.hocon b/neuro_san/registries/test_new_class.hocon new file mode 100644 index 000000000..c1ec5b71c --- /dev/null +++ b/neuro_san/registries/test_new_class.hocon @@ -0,0 +1,64 @@ + +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +# The schema specifications for this file are documented here: +# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md + +{ + "llm_config": { + "class": "langchain_groq.chat_models.ChatGroq", + "model_name": "deepseek-r1-distill-llama-70b", + "temperature": 0.5 + }, + "tools": [ + # These tool definitions do not have to be in any particular order + # How they are linked and call each other is defined within their + # own specs. This could be a graph, potentially even with cycles. + + # This first agent definition is regarded as the "Front Man", which + # does all the talking to the outside world/client. + # It is identified as such because it is either: + # A) The only one with no parameters in his function definition, + # and therefore he needs to talk to the outside world to get things rolling. + # B) The first agent listed, regardless of function parameters. + # + # Some disqualifications from being a front man: + # 1) Cannot use a CodedTool "class" definition + # 2) Cannot use a Tool "toolbox" definition + { + "name": "MusicNerd", + + # Note that there are no parameters defined for this guy's "function" key. + # This is the primary way to identify this tool as a front-man, + # distinguishing it from the rest of the tools. + + "function": { + + # The description acts as an initial prompt. + "description": """ +I can help with music-related inquiries. +""" + }, + + "instructions": """ +You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: + • “What’s the story behind this song?” + • “Which album should I start with?” + • “Who influenced this band’s sound?” + • “Is there a deeper meaning in these lyrics?” + • “What’s a hidden gem I probably missed?” +You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. +""", + "tools": [] + } + ] +} diff --git a/neuro_san/registries/test_new_model_default_class.hocon b/neuro_san/registries/test_new_model_default_class.hocon new file mode 100644 index 000000000..4da73d3e9 --- /dev/null +++ b/neuro_san/registries/test_new_model_default_class.hocon @@ -0,0 +1,64 @@ + +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +# The schema specifications for this file are documented here: +# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md + +{ + "llm_config": { + "class": "openai", + "model_name": "gpt-4.1-mini", + "temperature": 0.5 + }, + "tools": [ + # These tool definitions do not have to be in any particular order + # How they are linked and call each other is defined within their + # own specs. This could be a graph, potentially even with cycles. + + # This first agent definition is regarded as the "Front Man", which + # does all the talking to the outside world/client. + # It is identified as such because it is either: + # A) The only one with no parameters in his function definition, + # and therefore he needs to talk to the outside world to get things rolling. + # B) The first agent listed, regardless of function parameters. + # + # Some disqualifications from being a front man: + # 1) Cannot use a CodedTool "class" definition + # 2) Cannot use a Tool "toolbox" definition + { + "name": "MusicNerd", + + # Note that there are no parameters defined for this guy's "function" key. + # This is the primary way to identify this tool as a front-man, + # distinguishing it from the rest of the tools. + + "function": { + + # The description acts as an initial prompt. + "description": """ +I can help with music-related inquiries. +""" + }, + + "instructions": """ +You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: + • “What’s the story behind this song?” + • “Which album should I start with?” + • “Who influenced this band’s sound?” + • “Is there a deeper meaning in these lyrics?” + • “What’s a hidden gem I probably missed?” +You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. +""", + "tools": [] + } + ] +} diff --git a/neuro_san/registries/test_new_model_extended_class.hocon b/neuro_san/registries/test_new_model_extended_class.hocon new file mode 100644 index 000000000..65246fc22 --- /dev/null +++ b/neuro_san/registries/test_new_model_extended_class.hocon @@ -0,0 +1,65 @@ + +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +# The schema specifications for this file are documented here: +# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md + +{ + "agent_llm_info_file": "llm_extension/llm_info.hocon" + "llm_config": { + "class": "groq", + "model_name": "deepseek-r1-distill-llama-70b", + "temperature": 0.5, + }, + "tools": [ + # These tool definitions do not have to be in any particular order + # How they are linked and call each other is defined within their + # own specs. This could be a graph, potentially even with cycles. + + # This first agent definition is regarded as the "Front Man", which + # does all the talking to the outside world/client. + # It is identified as such because it is either: + # A) The only one with no parameters in his function definition, + # and therefore he needs to talk to the outside world to get things rolling. + # B) The first agent listed, regardless of function parameters. + # + # Some disqualifications from being a front man: + # 1) Cannot use a CodedTool "class" definition + # 2) Cannot use a Tool "toolbox" definition + { + "name": "MusicNerd", + + # Note that there are no parameters defined for this guy's "function" key. + # This is the primary way to identify this tool as a front-man, + # distinguishing it from the rest of the tools. + + "function": { + + # The description acts as an initial prompt. + "description": """ +I can help with music-related inquiries. +""" + }, + + "instructions": """ +You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: + • “What’s the story behind this song?” + • “Which album should I start with?” + • “Who influenced this band’s sound?” + • “Is there a deeper meaning in these lyrics?” + • “What’s a hidden gem I probably missed?” +You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. +""", + "tools": [] + } + ] +} From 17974b5061ef42ea4448507688676e1a9c796ae8 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Wed, 2 Jul 2025 01:35:41 -0700 Subject: [PATCH 07/16] use alias for api error in langchain run context --- .../langchain/core/langchain_run_context.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py index 415622cd4..13ec6c847 100644 --- a/neuro_san/internals/run_context/langchain/core/langchain_run_context.py +++ b/neuro_san/internals/run_context/langchain/core/langchain_run_context.py @@ -23,8 +23,8 @@ from logging import Logger from logging import getLogger -import openai -import anthropic +from openai import APIError as OpenAI_APIError +from anthropic import APIError as Anthropic_APIError from pydantic_core import ValidationError @@ -494,7 +494,7 @@ async def ainvoke(self, agent_executor: AgentExecutor, inputs: Dict[str, Any], i while return_dict is None and retries > 0: try: return_dict: Dict[str, Any] = await agent_executor.ainvoke(inputs, invoke_config) - except (openai.APIError, anthropic.APIError, ChatGoogleGenerativeAIError) as api_error: + except (OpenAI_APIError, Anthropic_APIError, ChatGoogleGenerativeAIError) as api_error: message: str = ApiKeyErrorCheck.check_for_api_key_exception(api_error) if message is not None: raise ValueError(message) from api_error @@ -508,11 +508,6 @@ async def ainvoke(self, agent_executor: AgentExecutor, inputs: Dict[str, Any], i retries = retries - 1 exception = key_error backtrace = traceback.format_exc() - except TypeError as type_error: - self.logger.warning("retrying from TypeError") - retries = retries - 1 - exception = type_error - backtrace = traceback.format_exc() except ValueError as value_error: response = str(value_error) find_string = "An output parsing error occurred. " + \ From 9d90aaa06502315772d595e4f1e061bea0b7f47e Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Wed, 2 Jul 2025 01:40:37 -0700 Subject: [PATCH 08/16] change comments --- .../internals/run_context/langchain/llms/default_llm_factory.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 8e5e319b7..58fd3bb4d 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -203,8 +203,6 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: if config.get("class"): # If config has "class", it is a user-specified llm so return config as is, - # and replace "StandardLangChainLlmFactory" with "UserSpecifiedLangChainLlmFactory". - # self.llm_factories[0] = UserSpecifiedLangChainLlmFactory() return config default_config: Dict[str, Any] = self.llm_infos.get("default_config") From 7647dc92f61b594ac51a217a27d840105b9126b2 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Wed, 2 Jul 2025 01:47:28 -0700 Subject: [PATCH 09/16] remove space --- .../internals/run_context/langchain/llms/default_llm_factory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 58fd3bb4d..60f32d71b 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -76,7 +76,6 @@ def __init__(self, config: Optional[Dict[str, Any]] = None): self.llm_factories: List[LangChainLlmFactory] = [ StandardLangChainLlmFactory() ] - if config: self.llm_info_file: str = config.get("agent_llm_info_file") else: From 741e50e9a7e0a48fccda8121b10f2b54def7936b Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Wed, 2 Jul 2025 01:59:23 -0700 Subject: [PATCH 10/16] Add comments --- .../langchain/llms/standard_langchain_llm_factory.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py index b92b25193..cbab9b1d9 100644 --- a/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/standard_langchain_llm_factory.py @@ -70,6 +70,9 @@ def create_base_chat_model(self, config: Dict[str, Any], if chat_class is not None: chat_class = chat_class.lower() + # Check for key "model_name", "model", and "model_id" to use as model name + # If the config is from default_llm_info, this is always "model_name" + # but with user-specified config, it is possible to have the other keys will be specifed instead. model_name: str = config.get("model_name") or config.get("model") or config.get("model_id") if chat_class == "openai": From 69889ad0fadabfdb348bb9ed3e8b3530337ef98f Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Thu, 3 Jul 2025 11:50:11 -0700 Subject: [PATCH 11/16] - Refactor logic on creating llm based on "class" into another method - Refactor "check_invalid_arguments" into a util class - Revert the error message detection in api key error checker - Modified test_toolbox_factory - Add test for ArgumentValidator --- .../langchain/llms/default_llm_factory.py | 59 ++++++++----- .../langchain/toolbox/toolbox_factory.py | 34 +------ .../langchain/util/api_key_error_check.py | 10 ++- .../langchain/util/argument_validator.py | 81 +++++++++++++++++ .../langchain/toolbox/test_toolbox_factory.py | 15 ++-- .../langchain/util/test_argument_validator.py | 88 +++++++++++++++++++ 6 files changed, 225 insertions(+), 62 deletions(-) create mode 100644 neuro_san/internals/run_context/langchain/util/argument_validator.py create mode 100644 tests/neuro_san/internals/run_context/langchain/util/test_argument_validator.py diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 60f32d71b..d49b28efc 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -33,8 +33,8 @@ from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory from neuro_san.internals.run_context.langchain.llms.llm_info_restorer import LlmInfoRestorer from neuro_san.internals.run_context.langchain.llms.standard_langchain_llm_factory import StandardLangChainLlmFactory -from neuro_san.internals.run_context.langchain.toolbox.toolbox_factory import ToolboxFactory from neuro_san.internals.run_context.langchain.util.api_key_error_check import ApiKeyErrorCheck +from neuro_san.internals.run_context.langchain.util.argument_validator import ArgumentValidator class DefaultLlmFactory(ContextTypeLlmFactory, LangChainLlmFactory): @@ -125,7 +125,7 @@ def resolve_one_llm_factory(self, llm_factory_class_name: str, llm_info_file: st "must be a list of strings") # Resolve the factory class - llm_factory_class = self._resolve_class_from_path( + llm_factory_class: Type[LangChainLlmFactory] = self._resolve_class_from_path( class_path=llm_factory_class_name, expected_base=LangChainLlmFactory, source_file=llm_info_file, @@ -191,7 +191,6 @@ def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler """ full_config: Dict[str, Any] = self.create_full_llm_config(config) llm: BaseLanguageModel = self.create_base_chat_model(full_config, callbacks) - print(f"\n\n{llm=}\n\n") return llm def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: @@ -272,7 +271,8 @@ def get_chat_class_args(self, chat_class_name: str, use_model_name: str) -> Dict def create_base_chat_model(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: """ - Create a BaseLanguageModel from the fully-specified llm config. + Create a BaseLanguageModel from the fully-specified llm config either from standard LLM factory, + user-defined LLM factory, or user-specified langchain model class. :param config: The fully specified llm config which is a product of _create_full_llm_config() above. :param callbacks: A list of BaseCallbackHandlers to add to the chat model. @@ -308,24 +308,9 @@ def create_base_chat_model(self, config: Dict[str, Any], found_exception = exception # Try resolving via 'class' in config if factories failed - class_path = config.get("class") - if found_exception is not None and class_path: - if not isinstance(class_path, str): - raise ValueError("'class' in llm_config must be a string") - - # Resolve the 'class' - llm_class = self._resolve_class_from_path( - class_path=class_path, - expected_base=BaseLanguageModel, - source_file="agent network hocon file", - description="llm_config" - ) - - # copy the config, take 'class' out, and unpack into llm constructor - user_config = config.copy() - user_config.pop("class") - ToolboxFactory.check_invalid_args(llm_class, user_config) - llm = llm_class(**user_config) + class_path: str = config.get("class") + if llm is None and found_exception is not None and class_path: + llm = self.create_base_chat_model_from_user_class(class_path, config) found_exception = None if found_exception is not None: @@ -333,6 +318,36 @@ def create_base_chat_model(self, config: Dict[str, Any], return llm + def create_base_chat_model_from_user_class(self, class_path: str, config: Dict[str, Any]) -> BaseLanguageModel: + """ + Create a BaseLanguageModel from the user-specified langchain model class. + :param class_path: A string in the form of .. + :param config: The fully specified llm config which is a product of + _create_full_llm_config() above. + + :return: A BaseLanguageModel + """ + + if not isinstance(class_path, str): + raise ValueError("'class' in llm_config must be a string") + + # Resolve the 'class' + llm_class: Type[BaseLanguageModel] = self._resolve_class_from_path( + class_path=class_path, + expected_base=BaseLanguageModel, + source_file="agent network hocon file", + description="llm_config" + ) + + # Copy the config, take 'class' out, and unpack into llm constructor + user_config: Dict[str, Any] = config.copy() + user_config.pop("class") + + # Check for invalid args and throw error if found + ArgumentValidator.check_invalid_args(llm_class, user_config) + + return llm_class(**user_config) + def get_max_prompt_tokens(self, config: Dict[str, Any]) -> int: """ :param config: A dictionary which describes which LLM to use. diff --git a/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py b/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py index 5bd8e5466..2e75ccf3c 100644 --- a/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py +++ b/neuro_san/internals/run_context/langchain/toolbox/toolbox_factory.py @@ -10,15 +10,12 @@ # # END COPYRIGHT -from inspect import isclass -from inspect import signature -from types import MethodType + from typing import Any from typing import Callable from typing import Dict from typing import List from typing import Optional -from typing import Set from typing import Type from typing import Union @@ -33,6 +30,7 @@ from neuro_san.internals.interfaces.context_type_toolbox_factory import ContextTypeToolboxFactory from neuro_san.internals.run_context.langchain.toolbox.toolbox_info_restorer import ToolboxInfoRestorer +from neuro_san.internals.run_context.langchain.util.argument_validator import ArgumentValidator class ToolboxFactory(ContextTypeToolboxFactory): @@ -153,7 +151,7 @@ def create_tool_from_toolbox( self._get_from_api_wrapper_method(tool_class) or tool_class # Validate and instantiate - ToolboxFactory.check_invalid_args(callable_obj, final_args) + ArgumentValidator.check_invalid_args(callable_obj, final_args) # Instance can be a BaseTool or a BaseToolkit instance: Union[BaseTool, BaseToolkit] = callable_obj(**final_args) @@ -179,7 +177,7 @@ def _resolve_args(self, args: Dict[str, Any]) -> Dict[str, Any]: # If the argument is a class definition, resolve and instantiate it nested_class: BaseModel = self._resolve_class(value.get("class")) nested_args: Dict[str, Any] = self._resolve_args(value.get("args", empty)) - ToolboxFactory.check_invalid_args(nested_class, nested_args) + ArgumentValidator.check_invalid_args(nested_class, nested_args) resolved_args[key] = nested_class(**nested_args) else: # Otherwise, keep primitive values as they are @@ -211,30 +209,6 @@ def _resolve_class(self, class_path: str) -> Type[BaseTool]: except AttributeError as exception: raise ValueError(f"Class {class_path} not found in PYTHONPATH") from exception - @staticmethod - def check_invalid_args(method_class: Union[Type, MethodType], args: Dict[str, Any]): - """ - Check for invalid arguments in class or method - :param method_class: Class or method to check for the invalid arguments - :param args: Arguments to check - """ - pydantic_args: Set[str] = set() - # Check for if it is a class that extends pydantic BaseModel - if isclass(method_class) and issubclass(method_class, BaseModel): - # Include field names as args - pydantic_args = set(method_class.model_fields.keys()) - # Combine the arguments - class_args_set: Set[str] = set(signature(method_class).parameters.keys()).union(pydantic_args) - args_set: Set[str] = set(args.keys()) - - # If there are args that are not from class args or alias, raise error - invalid_args: Set[str] = args_set - class_args_set - if invalid_args: - raise ValueError( - f"Arguments {invalid_args} for '{method_class.__name__}' do not match any attributes " - "of the class or any arguments of the method." - ) - def _get_from_api_wrapper_method( self, tool_class: Union[Type[BaseTool], Type[BaseToolkit]] diff --git a/neuro_san/internals/run_context/langchain/util/api_key_error_check.py b/neuro_san/internals/run_context/langchain/util/api_key_error_check.py index 853a33231..62c404a4d 100644 --- a/neuro_san/internals/run_context/langchain/util/api_key_error_check.py +++ b/neuro_san/internals/run_context/langchain/util/api_key_error_check.py @@ -22,10 +22,12 @@ # Azure OpenAI requires several parameters; all can be set via environment variables # except "deployment_name", which must be provided explicitly. - "AZURE_OPENAI_API_KEY": ["invalid subscription key", "wrong API endpoint"], - "AZURE_OPENAI_ENDPOINT": ["base_url", "azure_endpoint", "AZURE_OPENAI_ENDPOINT"], - "OPENAI_API_VERSION": ["api_version", "OPENAI_API_VERSION"], - "deployment_name": ["API deployment for this resource does not exist"], + "AZURE_OPENAI_API_KEY": ["Error code: 401", "invalid subscription key", "wrong API endpoint", "Connection error"], + "AZURE_OPENAI_ENDPOINT": ["validation error", "base_url", "azure_endpoint", "AZURE_OPENAI_ENDPOINT", + "Connection error"], + "OPENAI_API_VERSION": ["validation error", "api_version", "OPENAI_API_VERSION", "Error code: 404", + "Resource not found"], + "deployment_name": ["Error code: 404", "Resource not found", "API deployment for this resource does not exist"], } diff --git a/neuro_san/internals/run_context/langchain/util/argument_validator.py b/neuro_san/internals/run_context/langchain/util/argument_validator.py new file mode 100644 index 000000000..c48748657 --- /dev/null +++ b/neuro_san/internals/run_context/langchain/util/argument_validator.py @@ -0,0 +1,81 @@ +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +from typing import Any +from typing import Dict +from typing import Set +from typing import Type +from typing import Union +from types import MethodType +from inspect import isclass +from inspect import signature +from pydantic import BaseModel + + +class ArgumentValidator: + """ + A utility class for inspecting method and class arguments, particularly useful for + validating input against Pydantic `BaseModel` subclasses or callable method signatures. + + This class provides static methods to: + - Validate that a dictionary of arguments matches the accepted parameters of a class or method. + - Extract all field names and aliases from a Pydantic BaseModel subclass. + """ + + @staticmethod + def check_invalid_args(method_class: Union[Type, MethodType], args: Dict[str, Any]): + """ + Check for invalid arguments in a class constructor or method call. + + :param method_class: The class or method to validate against. + :param args: Dictionary of argument to check. + :raises ValueError: If any argument name is not accepted by the method or class. + """ + + # If method_class is a Pydantic BaseModel, get its field names and aliases + if isclass(method_class) and issubclass(method_class, BaseModel): + class_args_set: Set[str] = ArgumentValidator.get_base_model_args(method_class) + else: + # Otherwise, extract argument names from the function/method signature + class_args_set = set(signature(method_class).parameters.keys()) + + # Get the argument keys provided by the user + args_set: Set[str] = set(args.keys()) + + # Identify which arguments are not accepted by the method/class + invalid_args: Set[str] = args_set - class_args_set + if invalid_args: + raise ValueError( + f"Arguments {invalid_args} for '{method_class.__name__}' do not match any attributes " + "of the class or any arguments of the method." + ) + + @staticmethod + def get_base_model_args(base_model_class: Type[BaseModel]) -> Set[str]: + """ + Extract all field names and aliases from a Pydantic BaseModel class. + + :param base_model_class: A class that inherits from `BaseModel`. + :return: A set of valid argument names, including both field names and aliases. + """ + + fields_and_aliases: Set[str] = set() + + # Check for field name and info + # field info includes attributes like "required", "default", "description", and "alias" + for field_name, field_info in base_model_class.model_fields.items(): + # Add field name to the set + fields_and_aliases.add(field_name) + if field_info.alias: + # If there is "alias" in the info add it to the set as well + fields_and_aliases.add(field_info.alias) + + return fields_and_aliases diff --git a/tests/neuro_san/internals/run_context/langchain/toolbox/test_toolbox_factory.py b/tests/neuro_san/internals/run_context/langchain/toolbox/test_toolbox_factory.py index 483a8b08f..0579d450c 100644 --- a/tests/neuro_san/internals/run_context/langchain/toolbox/test_toolbox_factory.py +++ b/tests/neuro_san/internals/run_context/langchain/toolbox/test_toolbox_factory.py @@ -18,6 +18,12 @@ from neuro_san.internals.run_context.langchain.toolbox.toolbox_factory import ToolboxFactory +RESOLVER_PATH = "leaf_common.config.resolver.Resolver.resolve_class_in_module" +VALIDATIOR_PATH = ( + "neuro_san.internals.run_context.langchain.util.argument_validator." + "ArgumentValidator.check_invalid_args" +) + class TestBaseToolFactory: """Simplified test suite for ToolboxFactory.""" @@ -43,8 +49,7 @@ def test_create_toolbox_returns_single_base_tool(self, factory): # Mock user-provided arguments user_args = {"param2": "user_value", "param3": "extra_value"} - with patch("leaf_common.config.resolver.Resolver.resolve_class_in_module") as mock_resolver, \ - patch.object(factory, "_check_invalid_args") as mock_check_invalid: + with patch(RESOLVER_PATH) as mock_resolver, patch(VALIDATIOR_PATH) as mock_check_invalid: mock_tool_class = MagicMock(spec=BaseTool) mock_resolver.return_value = mock_tool_class @@ -80,8 +85,7 @@ def test_create_toolbox_with_toolkit_constructor(self, factory): # Mock user-provided arguments user_args = {"param2": "user_value", "param3": "extra_value"} - with patch("leaf_common.config.resolver.Resolver.resolve_class_in_module") as mock_resolver, \ - patch.object(factory, "_check_invalid_args") as mock_check_invalid: + with patch(RESOLVER_PATH) as mock_resolver, patch(VALIDATIOR_PATH) as mock_check_invalid: mock_toolkit_class = MagicMock(spec=BaseToolkit) mock_resolver.return_value = mock_toolkit_class @@ -119,8 +123,7 @@ def test_create_toolbox_with_toolkit_class_method(self, factory): # Mock user-provided arguments user_args = {"param2": "user_value", "param3": "extra_value"} - with patch("leaf_common.config.resolver.Resolver.resolve_class_in_module") as mock_resolver, \ - patch.object(factory, "_check_invalid_args") as mock_check_invalid: + with patch(RESOLVER_PATH) as mock_resolver, patch(VALIDATIOR_PATH) as mock_check_invalid: # Mock the toolkit class mock_toolkit_class = MagicMock() mock_resolver.return_value = mock_toolkit_class diff --git a/tests/neuro_san/internals/run_context/langchain/util/test_argument_validator.py b/tests/neuro_san/internals/run_context/langchain/util/test_argument_validator.py new file mode 100644 index 000000000..84a66d45c --- /dev/null +++ b/tests/neuro_san/internals/run_context/langchain/util/test_argument_validator.py @@ -0,0 +1,88 @@ +# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. +# All Rights Reserved. +# Issued under the Academic Public License. +# +# You can be released from the terms, and requirements of the Academic Public +# License by purchasing a commercial license. +# Purchase of a commercial license is mandatory for any use of the +# neuro-san SDK Software in commercial settings. +# +# END COPYRIGHT + +from typing import Any +from typing import Dict +from typing import Set + +from pydantic import BaseModel +from pydantic import Field +import pytest + +from neuro_san.internals.run_context.langchain.util.argument_validator import ArgumentValidator + + +# ----------- Sample Models and Functions for Testing ----------- + +class TestModel(BaseModel): + """Used for get_base_model_args method testing""" + name: str + age: int = Field(alias="years") + + +def sample_function(name: str, city: str): + """Used for method signature testing""" + print(name, city) + + +# ------------------------- Tests ------------------------------- + +class TestArgumentValidator: + """Tests for the ArgumentValidator class and its static validation utilities.""" + + def test_get_base_model_args_returns_field_names_and_aliases(self): + """ + Test that get_base_model_args returns both the original field names + and their aliases from a Pydantic BaseModel. + """ + result: Set[str] = ArgumentValidator.get_base_model_args(TestModel) + # Expect both the field name 'name' and the alias 'years' to be included + assert "name" in result + assert "years" in result + assert "age" in result # The original field name is also always included + + def test_check_invalid_args_with_valid_model_args_passes(self): + """ + Test that check_invalid_args does not raise an error when passed + valid field names and aliases for a BaseModel. + """ + args: Dict[str, Any] = {"name": "Alice", "years": 30} + # Should not raise + ArgumentValidator.check_invalid_args(TestModel, args) + + def test_check_invalid_args_with_invalid_model_args_raises(self): + """ + Test that check_invalid_args raises a ValueError when invalid field names + are passed to a BaseModel. + """ + args: Dict[str, Any] = {"name": "Alice", "invalid_field": "oops"} + with pytest.raises(ValueError) as excinfo: + ArgumentValidator.check_invalid_args(TestModel, args) + assert "invalid_field" in str(excinfo.value) + + def test_check_invalid_args_with_valid_function_args_passes(self): + """ + Test that check_invalid_args does not raise an error when valid argument + names are passed to a regular Python function. + """ + args: Dict[str, Any] = {"name": "Alice", "city": "NYC"} + # Should not raise + ArgumentValidator.check_invalid_args(sample_function, args) + + def test_check_invalid_args_with_invalid_function_args_raises(self): + """ + Test that check_invalid_args raises a ValueError when invalid argument + names are passed to a regular Python function. + """ + args: Dict[str, Any] = {"name": "Alice", "bad_arg": "fail"} + with pytest.raises(ValueError) as excinfo: + ArgumentValidator.check_invalid_args(sample_function, args) + assert "bad_arg" in str(excinfo.value) From a796cf8d4093b1ea56e99092d89b5089b6d32e5d Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Thu, 3 Jul 2025 11:55:02 -0700 Subject: [PATCH 12/16] Add type hints --- .../run_context/langchain/llms/default_llm_factory.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index d49b28efc..9bc0fbd09 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -153,20 +153,20 @@ def _resolve_class_from_path( description: str ) -> Type: - parts = class_path.split(".") + parts: List[str] = class_path.split(".") if len(parts) <= 2: raise ValueError( f"Value for '{description}' in {source_file} must be of the form " ".." ) - module_name = parts[-2] - class_name = parts[-1] - packages = [".".join(parts[:-2])] + module_name: str = parts[-2] + class_name: str = parts[-1] + packages: str = [".".join(parts[:-2])] resolver = Resolver(packages) try: - cls = resolver.resolve_class_in_module(class_name, module_name=module_name) + cls: Type = resolver.resolve_class_in_module(class_name, module_name=module_name) except AttributeError as e: raise ValueError(f"Class {class_path} in {source_file} not found in PYTHONPATH") from e From bbc3fbc5c63a4cec3536dd365775f35a46187963 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Thu, 3 Jul 2025 15:20:04 -0700 Subject: [PATCH 13/16] Add callbacks --- .../langchain/llms/default_llm_factory.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 9bc0fbd09..8fed425dd 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -318,12 +318,18 @@ def create_base_chat_model(self, config: Dict[str, Any], return llm - def create_base_chat_model_from_user_class(self, class_path: str, config: Dict[str, Any]) -> BaseLanguageModel: + def create_base_chat_model_from_user_class( + self, + class_path: str, + config: Dict[str, Any], + callbacks: Optional[List[BaseCallbackHandler]] = None + ) -> BaseLanguageModel: """ Create a BaseLanguageModel from the user-specified langchain model class. :param class_path: A string in the form of .. :param config: The fully specified llm config which is a product of _create_full_llm_config() above. + :param callbacks: A list of BaseCallbackHandlers to add to the chat model. :return: A BaseLanguageModel """ @@ -339,9 +345,11 @@ def create_base_chat_model_from_user_class(self, class_path: str, config: Dict[s description="llm_config" ) - # Copy the config, take 'class' out, and unpack into llm constructor + # Copy the config, take 'class' out, and add callbacks + # Then unpack into llm constructor user_config: Dict[str, Any] = config.copy() user_config.pop("class") + user_config["callbacks"] = callbacks # Check for invalid args and throw error if found ArgumentValidator.check_invalid_args(llm_class, user_config) From 50bd7f0e71cb4f0362d8e6e378319410f3e5614f Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Thu, 3 Jul 2025 17:43:52 -0700 Subject: [PATCH 14/16] combine user config with the one in class in llm_info --- .../langchain/llms/default_llm_factory.py | 38 +++++++++++++++---- .../test_new_model_default_class.hocon | 4 +- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index 8fed425dd..c45a0ec64 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -177,7 +177,11 @@ def _resolve_class_from_path( return cls - def create_llm(self, config: Dict[str, Any], callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: + def create_llm( + self, + config: Dict[str, Any], + callbacks: Optional[List[BaseCallbackHandler]] = None + ) -> BaseLanguageModel: """ Creates a langchain LLM based on the 'model_name' value of the config passed in. @@ -199,9 +203,26 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: :return: The fully specified config with defaults filled in. """ - if config.get("class"): - # If config has "class", it is a user-specified llm so return config as is, - return config + class_from_llm_config: str = config.get("class") + if class_from_llm_config: + if not isinstance(class_from_llm_config, str): + raise ValueError("Value of 'class' has to be string.") + # A "class" key in the config indicates the user has specified a particular LLM implementation. + # However, the config may only contain partial arguments (e.g., {"arg_1": 0.5}) and omit others. + # + # In the standard factory, LLM classes are instantiated like: + # ChatOpenAI(arg_1=config.get("arg_1"), arg_2=config.get("arg_2")) + # If a required argument like "arg_2" is missing in the config, config.get("arg_2") returns None, + # which may raise an error during instantiation if the argument has no default. + # + # To prevent this, we first fetch the default arguments for the given class from llm_info, + # then merge them with the user-provided config. This ensures all expected arguments are present, + # and the user’s config values take precedence over the defaults. + config_from_class_in_llm_info: Dict[str, Any] = self.get_chat_class_args(class_from_llm_config) + + # Merge the defaults from llm_info with the user-defined config, + # giving priority to values in config. + return self.overlayer.overlay(config_from_class_in_llm_info, config) default_config: Dict[str, Any] = self.llm_infos.get("default_config") use_config = self.overlayer.overlay(default_config, config) @@ -242,7 +263,7 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: return full_config - def get_chat_class_args(self, chat_class_name: str, use_model_name: str) -> Dict[str, Any]: + def get_chat_class_args(self, chat_class_name: str, use_model_name: Optional[str] = None) -> Dict[str, Any]: """ :param chat_class_name: string name of the chat class to look up. :param use_model_name: the original model name that prompted the chat class lookups @@ -254,8 +275,9 @@ def get_chat_class_args(self, chat_class_name: str, use_model_name: str) -> Dict chat_classes: Dict[str, Any] = self.llm_infos.get("classes") chat_class: Dict[str, Any] = chat_classes.get(chat_class_name) if chat_class is None: - raise ValueError(f"llm info entry for {use_model_name} uses a 'class' of {chat_class_name} " - "which is not defined in the 'classes' table.") + # If chat_class_name is not in "classes" in llm_info, + # it could be a user-specified langchain model class + return {} # Get the args from the chat class args: Dict[str, Any] = chat_class.get("args") @@ -269,7 +291,7 @@ def get_chat_class_args(self, chat_class_name: str, use_model_name: str) -> Dict return args def create_base_chat_model(self, config: Dict[str, Any], - callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: + callbacks: Optional[List[BaseCallbackHandler]] = None) -> BaseLanguageModel: """ Create a BaseLanguageModel from the fully-specified llm config either from standard LLM factory, user-defined LLM factory, or user-specified langchain model class. diff --git a/neuro_san/registries/test_new_model_default_class.hocon b/neuro_san/registries/test_new_model_default_class.hocon index 4da73d3e9..645175c7a 100644 --- a/neuro_san/registries/test_new_model_default_class.hocon +++ b/neuro_san/registries/test_new_model_default_class.hocon @@ -15,8 +15,8 @@ { "llm_config": { - "class": "openai", - "model_name": "gpt-4.1-mini", + "class": "azure-openai", + "deployment_name": "gpt-4o", "temperature": 0.5 }, "tools": [ From e6dc921cc93e99cfd30707ee34333ec49904f061 Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Mon, 7 Jul 2025 15:53:14 -0700 Subject: [PATCH 15/16] remove optional --- .../langchain/llms/default_llm_factory.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index c45a0ec64..db0d5b35e 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -13,7 +13,6 @@ from typing import Any from typing import Dict from typing import List -from typing import Optional from typing import Type import os @@ -64,7 +63,7 @@ class DefaultLlmFactory(ContextTypeLlmFactory, LangChainLlmFactory): the model description in this class. """ - def __init__(self, config: Optional[Dict[str, Any]] = None): + def __init__(self, config: Dict[str, Any] = None): """ Constructor @@ -180,7 +179,7 @@ def _resolve_class_from_path( def create_llm( self, config: Dict[str, Any], - callbacks: Optional[List[BaseCallbackHandler]] = None + callbacks: List[BaseCallbackHandler] = None ) -> BaseLanguageModel: """ Creates a langchain LLM based on the 'model_name' value of @@ -263,7 +262,7 @@ def create_full_llm_config(self, config: Dict[str, Any]) -> Dict[str, Any]: return full_config - def get_chat_class_args(self, chat_class_name: str, use_model_name: Optional[str] = None) -> Dict[str, Any]: + def get_chat_class_args(self, chat_class_name: str, use_model_name: str = None) -> Dict[str, Any]: """ :param chat_class_name: string name of the chat class to look up. :param use_model_name: the original model name that prompted the chat class lookups @@ -275,7 +274,11 @@ def get_chat_class_args(self, chat_class_name: str, use_model_name: Optional[str chat_classes: Dict[str, Any] = self.llm_infos.get("classes") chat_class: Dict[str, Any] = chat_classes.get(chat_class_name) if chat_class is None: - # If chat_class_name is not in "classes" in llm_info, + if use_model_name is not None: + # If use_model_name is given, it must have a "class" in "classes" + raise ValueError(f"llm info entry for {use_model_name} uses a 'class' of {chat_class_name} " + "which is not defined in the 'classes' table.") + # If use_model_name is not provided and chat_class_name is not in "classes" in llm_info, # it could be a user-specified langchain model class return {} @@ -291,7 +294,7 @@ def get_chat_class_args(self, chat_class_name: str, use_model_name: Optional[str return args def create_base_chat_model(self, config: Dict[str, Any], - callbacks: Optional[List[BaseCallbackHandler]] = None) -> BaseLanguageModel: + callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: """ Create a BaseLanguageModel from the fully-specified llm config either from standard LLM factory, user-defined LLM factory, or user-specified langchain model class. @@ -344,7 +347,7 @@ def create_base_chat_model_from_user_class( self, class_path: str, config: Dict[str, Any], - callbacks: Optional[List[BaseCallbackHandler]] = None + callbacks: List[BaseCallbackHandler] = None ) -> BaseLanguageModel: """ Create a BaseLanguageModel from the user-specified langchain model class. From cca1d032e887a71b968fbb9b4d70366b3ec7181e Mon Sep 17 00:00:00 2001 From: Noravee Kanchanavatee Date: Tue, 8 Jul 2025 11:04:35 -0700 Subject: [PATCH 16/16] refactor default llm factory with resolver util --- llm_extension/groq_langchain_llm_factory.py | 58 ----------------- llm_extension/llm_info.hocon | 26 -------- .../langchain/llms/default_llm_factory.py | 65 +++---------------- neuro_san/internals/utils/resolver_util.py | 45 +++++++++---- neuro_san/registries/manifest.hocon | 4 +- neuro_san/registries/test_new_class.hocon | 64 ------------------ .../test_new_model_default_class.hocon | 64 ------------------ .../test_new_model_extended_class.hocon | 65 ------------------- 8 files changed, 45 insertions(+), 346 deletions(-) delete mode 100644 llm_extension/groq_langchain_llm_factory.py delete mode 100644 llm_extension/llm_info.hocon delete mode 100644 neuro_san/registries/test_new_class.hocon delete mode 100644 neuro_san/registries/test_new_model_default_class.hocon delete mode 100644 neuro_san/registries/test_new_model_extended_class.hocon diff --git a/llm_extension/groq_langchain_llm_factory.py b/llm_extension/groq_langchain_llm_factory.py deleted file mode 100644 index 9ab2f1625..000000000 --- a/llm_extension/groq_langchain_llm_factory.py +++ /dev/null @@ -1,58 +0,0 @@ - -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -from typing import Any -from typing import Dict -from typing import List - -from langchain_core.callbacks.base import BaseCallbackHandler -from langchain_core.language_models.base import BaseLanguageModel -from langchain_groq import ChatGroq - -from neuro_san.internals.run_context.langchain.llms.langchain_llm_factory import LangChainLlmFactory - - -class GroqLangChainLlmFactory(LangChainLlmFactory): - """ - Factory class for LLM operations - """ - - def create_base_chat_model(self, config: Dict[str, Any], - callbacks: List[BaseCallbackHandler] = None) -> BaseLanguageModel: - """ - Create a BaseLanguageModel from the fully-specified llm config. - :param config: The fully specified llm config which is a product of - _create_full_llm_config() above. - :param callbacks: A list of BaseCallbackHandlers to add to the chat model. - :return: A BaseLanguageModel (can be Chat or LLM) - Can raise a ValueError if the config's class or model_name value is - unknown to this method. - """ - # Construct the LLM - llm: BaseLanguageModel = None - chat_class: str = config.get("class") - if chat_class is not None: - chat_class = chat_class.lower() - - model_name: str = config.get("model_name") - - if chat_class == "groq": - llm = ChatGroq( - model=model_name, - temperature=config.get("temperature") - ) - elif chat_class is None: - raise ValueError(f"Class name {chat_class} for model_name {model_name} is unspecified.") - else: - raise ValueError(f"Class {chat_class} for model_name {model_name} is unrecognized.") - - return llm diff --git a/llm_extension/llm_info.hocon b/llm_extension/llm_info.hocon deleted file mode 100644 index b5e59f2e1..000000000 --- a/llm_extension/llm_info.hocon +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -# The schema specifications for this file are documented here: -# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/llm_info_hocon_reference.md - -{ - - - "classes": { - "factories": [ "llm_extension.groq_langchain_llm_factory.GroqLangChainLlmFactory" ] - "groq": { - # Add arguments like temperature that you want to pass to the llm here. - "temperature": 0.7 - } - } - -} diff --git a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py index db0d5b35e..fac7ca4eb 100644 --- a/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py +++ b/neuro_san/internals/run_context/langchain/llms/default_llm_factory.py @@ -25,7 +25,6 @@ from langchain_core.language_models.base import BaseLanguageModel from leaf_common.config.dictionary_overlay import DictionaryOverlay -from leaf_common.config.resolver import Resolver from leaf_common.parsers.dictionary_extractor import DictionaryExtractor from neuro_san.internals.interfaces.context_type_llm_factory import ContextTypeLlmFactory @@ -34,6 +33,7 @@ from neuro_san.internals.run_context.langchain.llms.standard_langchain_llm_factory import StandardLangChainLlmFactory from neuro_san.internals.run_context.langchain.util.api_key_error_check import ApiKeyErrorCheck from neuro_san.internals.run_context.langchain.util.argument_validator import ArgumentValidator +from neuro_san.internals.utils.resolver_util import ResolverUtil class DefaultLlmFactory(ContextTypeLlmFactory, LangChainLlmFactory): @@ -123,59 +123,15 @@ def resolve_one_llm_factory(self, llm_factory_class_name: str, llm_info_file: st raise ValueError(f"The value for the classes.factories key in {llm_info_file} " "must be a list of strings") - # Resolve the factory class - llm_factory_class: Type[LangChainLlmFactory] = self._resolve_class_from_path( - class_path=llm_factory_class_name, - expected_base=LangChainLlmFactory, - source_file=llm_info_file, - description="classes.factories" + # Resolve and instantiate the factory class + llm_factory = ResolverUtil.create_instance( + class_name=llm_factory_class_name, + class_name_source=llm_info_file, + type_of_class=LangChainLlmFactory ) - # Instantiate it - try: - llm_factory: LangChainLlmFactory = llm_factory_class() - except TypeError as exception: - raise ValueError(f"Class {llm_factory_class_name} in {llm_info_file} " - "must have a no-args constructor") from exception - - # Make sure its the correct type - if not isinstance(llm_factory, LangChainLlmFactory): - raise ValueError(f"Class {llm_factory_class_name} in {llm_info_file} " - "must be of type LangChainLlmFactory") return llm_factory - def _resolve_class_from_path( - self, - class_path: str, - expected_base: Type, - source_file: str, - description: str - ) -> Type: - - parts: List[str] = class_path.split(".") - if len(parts) <= 2: - raise ValueError( - f"Value for '{description}' in {source_file} must be of the form " - ".." - ) - - module_name: str = parts[-2] - class_name: str = parts[-1] - packages: str = [".".join(parts[:-2])] - resolver = Resolver(packages) - - try: - cls: Type = resolver.resolve_class_in_module(class_name, module_name=module_name) - except AttributeError as e: - raise ValueError(f"Class {class_path} in {source_file} not found in PYTHONPATH") from e - - if not issubclass(cls, expected_base): - raise ValueError( - f"Class {class_path} in {source_file} must be a subclass of {expected_base.__name__}" - ) - - return cls - def create_llm( self, config: Dict[str, Any], @@ -363,11 +319,10 @@ def create_base_chat_model_from_user_class( raise ValueError("'class' in llm_config must be a string") # Resolve the 'class' - llm_class: Type[BaseLanguageModel] = self._resolve_class_from_path( - class_path=class_path, - expected_base=BaseLanguageModel, - source_file="agent network hocon file", - description="llm_config" + llm_class: Type[BaseLanguageModel] = ResolverUtil.create_class( + class_name=class_path, + class_name_source="agent network hocon file", + type_of_class=BaseLanguageModel ) # Copy the config, take 'class' out, and add callbacks diff --git a/neuro_san/internals/utils/resolver_util.py b/neuro_san/internals/utils/resolver_util.py index 98c79c3de..45b482018 100644 --- a/neuro_san/internals/utils/resolver_util.py +++ b/neuro_san/internals/utils/resolver_util.py @@ -37,6 +37,35 @@ def create_instance(class_name: str, class_name_source: str, type_of_class: Type """ instance: Any = None + class_reference: Type[Any] = ResolverUtil.create_class(class_name, class_name_source, type_of_class) + + if class_reference is None: + return None + + # Instantiate the class + try: + instance = class_reference() + except TypeError as exception: + raise ValueError(f"Class '{class_name}' from {class_name_source} " + "must have a no-args constructor") from exception + + return instance + + @staticmethod + def create_class(class_name: str, class_name_source: str, type_of_class: Type) -> Type: + """ + Resolves a fully qualified class name string into an actual Python class object. + + This method expects the input string to follow the format: + '..' and uses a Resolver to dynamically + locate and return the class object. + + :param class_name: The fully qualified name of the class to resolve. + :param class_name_source: A description of the source of the class_name string, + used for clearer error messages. + :param type_of_class: Base type or interface the class must inherit from. + :return: The resolved class object. Can return None if class_name is a None or empty string. + """ if class_name is None or len(class_name) == 0: return None @@ -59,16 +88,10 @@ def create_instance(class_name: str, class_name_source: str, type_of_class: Type raise ValueError(f"Class '{class_name}' from {class_name_source} " "not found in PYTHONPATH") from exception - # Instantiate the class - try: - instance = class_reference() - except TypeError as exception: - raise ValueError(f"Class '{class_name}' from {class_name_source} " - "must have a no-args constructor") from exception - # Make sure it is the correct type - if not isinstance(instance, type_of_class): - raise ValueError(f"Class '{class_name}' from {class_name_source} " - "must be of type {type_of_class.__name__}") + if not issubclass(class_reference, type_of_class): + raise ValueError( + f"Class {class_name} in {class_name_source} must be a subclass of {type_of_class.__name__}" + ) - return instance + return class_reference diff --git a/neuro_san/registries/manifest.hocon b/neuro_san/registries/manifest.hocon index d561a0e99..65afc1000 100644 --- a/neuro_san/registries/manifest.hocon +++ b/neuro_san/registries/manifest.hocon @@ -47,9 +47,7 @@ # Agents having to do with test infrastructure "gist.hocon": true, "assess_failure.hocon": true, - "test_new_model_default_class.hocon": true, - "test_new_class.hocon": true, - "test_new_model_extended_class.hocon": true + # STOP AND READ: YOU PROBABLY DON'T WANT TO ADD YOUR .hocon FILE HERE. # # The agent network .hocon files above are examples specific to the neuro-san library. diff --git a/neuro_san/registries/test_new_class.hocon b/neuro_san/registries/test_new_class.hocon deleted file mode 100644 index c1ec5b71c..000000000 --- a/neuro_san/registries/test_new_class.hocon +++ /dev/null @@ -1,64 +0,0 @@ - -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -# The schema specifications for this file are documented here: -# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md - -{ - "llm_config": { - "class": "langchain_groq.chat_models.ChatGroq", - "model_name": "deepseek-r1-distill-llama-70b", - "temperature": 0.5 - }, - "tools": [ - # These tool definitions do not have to be in any particular order - # How they are linked and call each other is defined within their - # own specs. This could be a graph, potentially even with cycles. - - # This first agent definition is regarded as the "Front Man", which - # does all the talking to the outside world/client. - # It is identified as such because it is either: - # A) The only one with no parameters in his function definition, - # and therefore he needs to talk to the outside world to get things rolling. - # B) The first agent listed, regardless of function parameters. - # - # Some disqualifications from being a front man: - # 1) Cannot use a CodedTool "class" definition - # 2) Cannot use a Tool "toolbox" definition - { - "name": "MusicNerd", - - # Note that there are no parameters defined for this guy's "function" key. - # This is the primary way to identify this tool as a front-man, - # distinguishing it from the rest of the tools. - - "function": { - - # The description acts as an initial prompt. - "description": """ -I can help with music-related inquiries. -""" - }, - - "instructions": """ -You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: - • “What’s the story behind this song?” - • “Which album should I start with?” - • “Who influenced this band’s sound?” - • “Is there a deeper meaning in these lyrics?” - • “What’s a hidden gem I probably missed?” -You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. -""", - "tools": [] - } - ] -} diff --git a/neuro_san/registries/test_new_model_default_class.hocon b/neuro_san/registries/test_new_model_default_class.hocon deleted file mode 100644 index 645175c7a..000000000 --- a/neuro_san/registries/test_new_model_default_class.hocon +++ /dev/null @@ -1,64 +0,0 @@ - -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -# The schema specifications for this file are documented here: -# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md - -{ - "llm_config": { - "class": "azure-openai", - "deployment_name": "gpt-4o", - "temperature": 0.5 - }, - "tools": [ - # These tool definitions do not have to be in any particular order - # How they are linked and call each other is defined within their - # own specs. This could be a graph, potentially even with cycles. - - # This first agent definition is regarded as the "Front Man", which - # does all the talking to the outside world/client. - # It is identified as such because it is either: - # A) The only one with no parameters in his function definition, - # and therefore he needs to talk to the outside world to get things rolling. - # B) The first agent listed, regardless of function parameters. - # - # Some disqualifications from being a front man: - # 1) Cannot use a CodedTool "class" definition - # 2) Cannot use a Tool "toolbox" definition - { - "name": "MusicNerd", - - # Note that there are no parameters defined for this guy's "function" key. - # This is the primary way to identify this tool as a front-man, - # distinguishing it from the rest of the tools. - - "function": { - - # The description acts as an initial prompt. - "description": """ -I can help with music-related inquiries. -""" - }, - - "instructions": """ -You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: - • “What’s the story behind this song?” - • “Which album should I start with?” - • “Who influenced this band’s sound?” - • “Is there a deeper meaning in these lyrics?” - • “What’s a hidden gem I probably missed?” -You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. -""", - "tools": [] - } - ] -} diff --git a/neuro_san/registries/test_new_model_extended_class.hocon b/neuro_san/registries/test_new_model_extended_class.hocon deleted file mode 100644 index 65246fc22..000000000 --- a/neuro_san/registries/test_new_model_extended_class.hocon +++ /dev/null @@ -1,65 +0,0 @@ - -# Copyright (C) 2023-2025 Cognizant Digital Business, Evolutionary AI. -# All Rights Reserved. -# Issued under the Academic Public License. -# -# You can be released from the terms, and requirements of the Academic Public -# License by purchasing a commercial license. -# Purchase of a commercial license is mandatory for any use of the -# neuro-san SDK Software in commercial settings. -# -# END COPYRIGHT - -# The schema specifications for this file are documented here: -# https://github.com/cognizant-ai-lab/neuro-san/blob/main/docs/agent_hocon_reference.md - -{ - "agent_llm_info_file": "llm_extension/llm_info.hocon" - "llm_config": { - "class": "groq", - "model_name": "deepseek-r1-distill-llama-70b", - "temperature": 0.5, - }, - "tools": [ - # These tool definitions do not have to be in any particular order - # How they are linked and call each other is defined within their - # own specs. This could be a graph, potentially even with cycles. - - # This first agent definition is regarded as the "Front Man", which - # does all the talking to the outside world/client. - # It is identified as such because it is either: - # A) The only one with no parameters in his function definition, - # and therefore he needs to talk to the outside world to get things rolling. - # B) The first agent listed, regardless of function parameters. - # - # Some disqualifications from being a front man: - # 1) Cannot use a CodedTool "class" definition - # 2) Cannot use a Tool "toolbox" definition - { - "name": "MusicNerd", - - # Note that there are no parameters defined for this guy's "function" key. - # This is the primary way to identify this tool as a front-man, - # distinguishing it from the rest of the tools. - - "function": { - - # The description acts as an initial prompt. - "description": """ -I can help with music-related inquiries. -""" - }, - - "instructions": """ -You’re Music Nerd, the go-to brain for all things rock, pop, and everything in between from the 60s onward. You live for liner notes, B-sides, lost demos, and legendary live sets. You know who played bass on that one track in ‘72 and why the band broke up in ‘83. People come to you with questions like: - • “What’s the story behind this song?” - • “Which album should I start with?” - • “Who influenced this band’s sound?” - • “Is there a deeper meaning in these lyrics?” - • “What’s a hidden gem I probably missed?” -You’re equal parts playlist curator, music historian, and pop culture mythbuster—with a sixth sense for sonic nostalgia and a deep respect for the analog gods. -""", - "tools": [] - } - ] -}