From aabad9390b7e499f8a73060c856c6f144d4e6820 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:52:54 +0000 Subject: [PATCH 1/4] feat: add custom parameters support for tool definitions Co-Authored-By: traci@launchdarkly.com --- packages/sdk/server-ai/src/ldai/__init__.py | 2 + packages/sdk/server-ai/src/ldai/client.py | 32 ++++- packages/sdk/server-ai/src/ldai/models.py | 62 +++++++++ packages/sdk/server-ai/tests/test_agents.py | 81 +++++++++++- .../sdk/server-ai/tests/test_model_config.py | 123 +++++++++++++++++- 5 files changed, 293 insertions(+), 7 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/__init__.py b/packages/sdk/server-ai/src/ldai/__init__.py index c70d941c..b0d7b5a7 100644 --- a/packages/sdk/server-ai/src/ldai/__init__.py +++ b/packages/sdk/server-ai/src/ldai/__init__.py @@ -28,6 +28,7 @@ LDMessage, ModelConfig, ProviderConfig, + ToolDefinition, ) from ldai.providers import ( AgentGraphResult, @@ -68,6 +69,7 @@ 'LDMessage', 'ModelConfig', 'ProviderConfig', + 'ToolDefinition', 'log', # Deprecated exports 'AIConfig', diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index 9c87ee8a..cb363f5b 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -25,6 +25,7 @@ LDMessage, ModelConfig, ProviderConfig, + ToolDefinition, ) from ldai.providers import ToolRegistry from ldai.providers.runner_factory import RunnerFactory @@ -68,7 +69,7 @@ def _completion_config( default: AICompletionConfigDefault, variables: Optional[Dict[str, Any]] = None, ) -> AICompletionConfig: - model, provider, messages, instructions, tracker, enabled, judge_configuration, _ = self.__evaluate( + model, provider, messages, instructions, tracker, enabled, judge_configuration, _, tools = self.__evaluate( key, context, default.to_dict(), variables ) @@ -80,6 +81,7 @@ def _completion_config( provider=provider, tracker=tracker, judge_configuration=judge_configuration, + tools=tools, ) return config @@ -134,7 +136,9 @@ def _judge_config( default: AIJudgeConfigDefault, variables: Optional[Dict[str, Any]] = None, ) -> AIJudgeConfig: - model, provider, messages, instructions, tracker, enabled, judge_configuration, variation = self.__evaluate( + (model, provider, messages, instructions, + tracker, enabled, judge_configuration, + variation, tools) = self.__evaluate( key, context, default.to_dict(), variables ) @@ -750,7 +754,8 @@ def __evaluate( variables: Optional[Dict[str, Any]] = None, ) -> Tuple[ Optional[ModelConfig], Optional[ProviderConfig], Optional[List[LDMessage]], - Optional[str], LDAIConfigTracker, bool, Optional[Any], Dict[str, Any] + Optional[str], LDAIConfigTracker, bool, Optional[Any], Dict[str, Any], + Optional[List[ToolDefinition]] ]: """ Internal method to evaluate a configuration and extract components. @@ -828,7 +833,23 @@ def __evaluate( if judges: judge_configuration = JudgeConfiguration(judges=judges) - return model, provider_config, messages, instructions, tracker, enabled, judge_configuration, variation + tools = None + model_raw = variation.get('model') + params_raw = model_raw.get('parameters') if isinstance(model_raw, dict) else None + tool_defs_raw = params_raw.get('tools') if isinstance(params_raw, dict) else None + if isinstance(tool_defs_raw, list): + tools = [ + ToolDefinition( + name=t.get('name', ''), + parameters=t.get('parameters', None) + ) + for t in tool_defs_raw + if isinstance(t, dict) and t.get('name') + ] + if not tools: + tools = None + + return model, provider_config, messages, instructions, tracker, enabled, judge_configuration, variation, tools def __evaluate_agent( self, @@ -846,7 +867,7 @@ def __evaluate_agent( :param variables: Variables for interpolation. :return: Configured AIAgentConfig instance. """ - model, provider, messages, instructions, tracker, enabled, judge_configuration, _ = self.__evaluate( + model, provider, messages, instructions, tracker, enabled, judge_configuration, _, tools = self.__evaluate( key, context, default.to_dict(), variables ) @@ -861,6 +882,7 @@ def __evaluate_agent( instructions=final_instructions, tracker=tracker, judge_configuration=judge_configuration or default.judge_configuration, + tools=tools or default.tools, ) def __interpolate_template(self, template: str, variables: Dict[str, Any]) -> str: diff --git a/packages/sdk/server-ai/src/ldai/models.py b/packages/sdk/server-ai/src/ldai/models.py index 07b02c23..37e86d58 100644 --- a/packages/sdk/server-ai/src/ldai/models.py +++ b/packages/sdk/server-ai/src/ldai/models.py @@ -77,6 +77,52 @@ def to_dict(self) -> dict: } +class ToolDefinition: + """ + Definition of a tool available to an AI configuration. + + Each tool has a name used to match against the tool registry, and + optional custom parameters that can be configured via the LaunchDarkly + dashboard. + """ + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None): + """ + :param name: The name of the tool. + :param parameters: Optional custom parameters for the tool. + """ + self._name = name + self._parameters = parameters + + @property + def name(self) -> str: + """ + The name of the tool. + """ + return self._name + + def get_parameter(self, key: str) -> Any: + """ + Retrieve a custom parameter by key. + + :param key: The parameter key to look up. + :return: The parameter value, or None if not found. + """ + if self._parameters is None: + return None + + return self._parameters.get(key) + + def to_dict(self) -> dict: + """ + Render the tool definition as a dictionary object. + """ + result: Dict[str, Any] = {'name': self._name} + if self._parameters is not None: + result['parameters'] = self._parameters + return result + + class ProviderConfig: """ Configuration related to the provider. @@ -208,6 +254,7 @@ class AICompletionConfigDefault(AIConfigDefault): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None + tools: Optional[List[ToolDefinition]] = None def to_dict(self) -> dict: """ @@ -217,6 +264,12 @@ def to_dict(self) -> dict: result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() + if self.tools is not None: + model = result.get('model') or {} + params = model.get('parameters') or {} + params['tools'] = [tool.to_dict() for tool in self.tools] + model['parameters'] = params + result['model'] = model return result @@ -227,6 +280,7 @@ class AICompletionConfig(AIConfig): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None + tools: Optional[List[ToolDefinition]] = None def to_dict(self) -> dict: """ @@ -250,6 +304,7 @@ class AIAgentConfigDefault(AIConfigDefault): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None + tools: Optional[List[ToolDefinition]] = None def to_dict(self) -> Dict[str, Any]: """ @@ -260,6 +315,12 @@ def to_dict(self) -> Dict[str, Any]: result['instructions'] = self.instructions if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() + if self.tools is not None: + model = result.get('model') or {} + params = model.get('parameters') or {} + params['tools'] = [tool.to_dict() for tool in self.tools] + model['parameters'] = params + result['model'] = model return result @@ -270,6 +331,7 @@ class AIAgentConfig(AIConfig): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None + tools: Optional[List[ToolDefinition]] = None def to_dict(self) -> Dict[str, Any]: """ diff --git a/packages/sdk/server-ai/tests/test_agents.py b/packages/sdk/server-ai/tests/test_agents.py index 4d6aa3f9..4c78865e 100644 --- a/packages/sdk/server-ai/tests/test_agents.py +++ b/packages/sdk/server-ai/tests/test_agents.py @@ -3,7 +3,7 @@ from ldclient.integrations.test_data import TestData from ldai import (LDAIAgentConfig, LDAIAgentDefaults, LDAIClient, ModelConfig, - ProviderConfig) + ProviderConfig, ToolDefinition) @pytest.fixture @@ -103,6 +103,30 @@ def td() -> TestData: .variation_for_all(0) ) + # Agent with tools and custom parameters + td.update( + td.flag('agent-with-tools') + .variations( + { + 'model': { + 'name': 'gpt-4', + 'parameters': { + 'temperature': 0.3, + 'tools': [ + {'name': 'get-order', 'parameters': {'includeHistory': True, 'maxItems': 5}}, + {'name': 'search-products', 'parameters': {'category': 'electronics'}}, + {'name': 'send-email'}, + ], + }, + }, + 'provider': {'name': 'openai'}, + 'instructions': 'You are a support agent with tools.', + '_ldMeta': {'enabled': True, 'variationKey': 'tools-v1', 'version': 1, 'mode': 'agent'}, + } + ) + .variation_for_all(0) + ) + return td @@ -363,3 +387,58 @@ def test_agents_request_without_default_uses_disabled(ldai_client: LDAIClient): assert 'missing-agent' in agents assert agents['missing-agent'].enabled is False + + +def test_agent_config_has_tools(ldai_client: LDAIClient): + """Test that agent configs parse tools with custom parameters from flag variations.""" + context = Context.create('user-key') + + agent = ldai_client.agent_config('agent-with-tools', context) + + assert agent.enabled is True + assert agent.tools is not None + assert len(agent.tools) == 3 + + get_order = agent.tools[0] + assert get_order.name == 'get-order' + assert get_order.get_parameter('includeHistory') is True + assert get_order.get_parameter('maxItems') == 5 + + search = agent.tools[1] + assert search.name == 'search-products' + assert search.get_parameter('category') == 'electronics' + + send_email = agent.tools[2] + assert send_email.name == 'send-email' + assert send_email.get_parameter('anything') is None + + +def test_agent_config_tools_fallback_to_default(ldai_client: LDAIClient): + """Test that agent config falls back to default tools when flag has no tools.""" + context = Context.create('user-key') + default_tools = [ToolDefinition('default-tool', parameters={'timeout': 30})] + default = LDAIAgentDefaults( + enabled=False, + model=ModelConfig('fallback-model'), + instructions='Default instructions', + tools=default_tools, + ) + + agent = ldai_client.agent_config('customer-support-agent', context, default) + + assert agent.enabled is True + # customer-support-agent has no tools in the flag, so falls back to default + assert agent.tools is not None + assert len(agent.tools) == 1 + assert agent.tools[0].name == 'default-tool' + assert agent.tools[0].get_parameter('timeout') == 30 + + +def test_agent_config_no_tools(ldai_client: LDAIClient): + """Test that agent tools is None when neither flag nor default has tools.""" + context = Context.create('user-key') + + agent = ldai_client.agent_config('customer-support-agent', context) + + assert agent.enabled is True + assert agent.tools is None diff --git a/packages/sdk/server-ai/tests/test_model_config.py b/packages/sdk/server-ai/tests/test_model_config.py index 636d14a4..78d445c3 100644 --- a/packages/sdk/server-ai/tests/test_model_config.py +++ b/packages/sdk/server-ai/tests/test_model_config.py @@ -2,7 +2,7 @@ from ldclient import Config, Context, LDClient from ldclient.integrations.test_data import TestData -from ldai import LDAIClient, LDMessage, ModelConfig +from ldai import LDAIClient, LDMessage, ModelConfig, ToolDefinition from ldai.models import (AIAgentConfigDefault, AICompletionConfigDefault, AIConfigDefault, AIJudgeConfigDefault) @@ -102,6 +102,42 @@ def td() -> TestData: .variation_for_all(1) ) + td.update( + td.flag('config-with-tools') + .variations( + { + 'model': { + 'name': 'gpt-4', + 'parameters': { + 'temperature': 0.7, + 'tools': [ + {'name': 'web_search', 'parameters': {'maxResults': 10, 'region': 'us'}}, + {'name': 'get_weather', 'parameters': {'units': 'celsius'}}, + {'name': 'calculator'}, + ], + }, + }, + 'provider': {'name': 'openai'}, + 'messages': [{'role': 'system', 'content': 'You are a helpful assistant.'}], + '_ldMeta': {'enabled': True, 'variationKey': 'tools-v1', 'version': 1}, + } + ) + .variation_for_all(0) + ) + + td.update( + td.flag('config-no-tools') + .variations( + { + 'model': {'name': 'gpt-4', 'parameters': {'temperature': 0.5}}, + 'provider': {'name': 'openai'}, + 'messages': [{'role': 'system', 'content': 'Hello'}], + '_ldMeta': {'enabled': True, 'variationKey': 'no-tools-v1', 'version': 1}, + } + ) + .variation_for_all(0) + ) + return td @@ -404,3 +440,88 @@ def test_completion_config_without_default_uses_disabled(ldai_client: LDAIClient config = ldai_client.completion_config('missing-flag', context) assert config.enabled is False + + +# ============================================================================ +# ToolDefinition tests +# ============================================================================ + +def test_tool_definition_basic(): + tool = ToolDefinition('web_search') + assert tool.name == 'web_search' + assert tool.get_parameter('anything') is None + + +def test_tool_definition_with_parameters(): + tool = ToolDefinition('web_search', parameters={'maxResults': 10, 'region': 'us'}) + assert tool.name == 'web_search' + assert tool.get_parameter('maxResults') == 10 + assert tool.get_parameter('region') == 'us' + assert tool.get_parameter('nonexistent') is None + + +def test_tool_definition_to_dict(): + tool = ToolDefinition('web_search', parameters={'maxResults': 10}) + d = tool.to_dict() + assert d == {'name': 'web_search', 'parameters': {'maxResults': 10}} + + +def test_tool_definition_to_dict_no_parameters(): + tool = ToolDefinition('calculator') + d = tool.to_dict() + assert d == {'name': 'calculator'} + + +def test_completion_config_has_tools(ldai_client: LDAIClient): + """Test that tools with custom parameters are parsed from flag variations.""" + context = Context.create('user-key') + default = AICompletionConfigDefault(enabled=False, model=ModelConfig('fallback'), messages=[]) + + config = ldai_client.completion_config('config-with-tools', context, default) + + assert config.tools is not None + assert len(config.tools) == 3 + + web_search = config.tools[0] + assert web_search.name == 'web_search' + assert web_search.get_parameter('maxResults') == 10 + assert web_search.get_parameter('region') == 'us' + + get_weather = config.tools[1] + assert get_weather.name == 'get_weather' + assert get_weather.get_parameter('units') == 'celsius' + + calculator = config.tools[2] + assert calculator.name == 'calculator' + assert calculator.get_parameter('anything') is None + + +def test_completion_config_no_tools(ldai_client: LDAIClient): + """Test that tools is None when no tools are defined.""" + context = Context.create('user-key') + default = AICompletionConfigDefault(enabled=False, model=ModelConfig('fallback'), messages=[]) + + config = ldai_client.completion_config('config-no-tools', context, default) + + assert config.tools is None + + +def test_completion_config_tools_missing_flag(ldai_client: LDAIClient): + """Test that tools from default are not used for completion configs.""" + context = Context.create('user-key') + default = AICompletionConfigDefault( + enabled=True, + model=ModelConfig('fallback'), + messages=[], + tools=[ToolDefinition('default_tool', parameters={'key': 'value'})], + ) + + config = ldai_client.completion_config('missing-flag', context, default) + + # The default is serialized into the variation dict, so the SDK evaluates + # against it; completion_config does not fall back to default.tools + # separately — the variation itself carries the tool definitions. + assert config.tools is not None + assert len(config.tools) == 1 + assert config.tools[0].name == 'default_tool' + assert config.tools[0].get_parameter('key') == 'value' From 1d3d52f3d48634a074edfa01e2380a8566b813e8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:27:48 +0000 Subject: [PATCH 2/4] refactor: rename to customParameters matching API/dashboard naming Co-Authored-By: traci@launchdarkly.com --- packages/sdk/server-ai/src/ldai/client.py | 2 +- packages/sdk/server-ai/src/ldai/models.py | 29 +++++++++------- packages/sdk/server-ai/tests/test_agents.py | 16 ++++----- .../sdk/server-ai/tests/test_model_config.py | 34 +++++++++---------- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index cb363f5b..9c9e562d 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -841,7 +841,7 @@ def __evaluate( tools = [ ToolDefinition( name=t.get('name', ''), - parameters=t.get('parameters', None) + custom_parameters=t.get('customParameters', None) ) for t in tool_defs_raw if isinstance(t, dict) and t.get('name') diff --git a/packages/sdk/server-ai/src/ldai/models.py b/packages/sdk/server-ai/src/ldai/models.py index 37e86d58..714c82cb 100644 --- a/packages/sdk/server-ai/src/ldai/models.py +++ b/packages/sdk/server-ai/src/ldai/models.py @@ -81,18 +81,23 @@ class ToolDefinition: """ Definition of a tool available to an AI configuration. - Each tool has a name used to match against the tool registry, and - optional custom parameters that can be configured via the LaunchDarkly - dashboard. + Each tool has a name used to match against the tool registry, + and optional custom parameters that can be configured via the + LaunchDarkly dashboard. """ - def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None): + def __init__( + self, + name: str, + custom_parameters: Optional[Dict[str, Any]] = None, + ): """ :param name: The name of the tool. - :param parameters: Optional custom parameters for the tool. + :param custom_parameters: Optional custom parameters for + the tool, configured via the LaunchDarkly dashboard. """ self._name = name - self._parameters = parameters + self._custom_parameters = custom_parameters @property def name(self) -> str: @@ -101,25 +106,25 @@ def name(self) -> str: """ return self._name - def get_parameter(self, key: str) -> Any: + def get_custom_parameter(self, key: str) -> Any: """ Retrieve a custom parameter by key. - :param key: The parameter key to look up. + :param key: The custom parameter key to look up. :return: The parameter value, or None if not found. """ - if self._parameters is None: + if self._custom_parameters is None: return None - return self._parameters.get(key) + return self._custom_parameters.get(key) def to_dict(self) -> dict: """ Render the tool definition as a dictionary object. """ result: Dict[str, Any] = {'name': self._name} - if self._parameters is not None: - result['parameters'] = self._parameters + if self._custom_parameters is not None: + result['customParameters'] = self._custom_parameters return result diff --git a/packages/sdk/server-ai/tests/test_agents.py b/packages/sdk/server-ai/tests/test_agents.py index 4c78865e..455e20b3 100644 --- a/packages/sdk/server-ai/tests/test_agents.py +++ b/packages/sdk/server-ai/tests/test_agents.py @@ -113,8 +113,8 @@ def td() -> TestData: 'parameters': { 'temperature': 0.3, 'tools': [ - {'name': 'get-order', 'parameters': {'includeHistory': True, 'maxItems': 5}}, - {'name': 'search-products', 'parameters': {'category': 'electronics'}}, + {'name': 'get-order', 'customParameters': {'includeHistory': True, 'maxItems': 5}}, + {'name': 'search-products', 'customParameters': {'category': 'electronics'}}, {'name': 'send-email'}, ], }, @@ -401,22 +401,22 @@ def test_agent_config_has_tools(ldai_client: LDAIClient): get_order = agent.tools[0] assert get_order.name == 'get-order' - assert get_order.get_parameter('includeHistory') is True - assert get_order.get_parameter('maxItems') == 5 + assert get_order.get_custom_parameter('includeHistory') is True + assert get_order.get_custom_parameter('maxItems') == 5 search = agent.tools[1] assert search.name == 'search-products' - assert search.get_parameter('category') == 'electronics' + assert search.get_custom_parameter('category') == 'electronics' send_email = agent.tools[2] assert send_email.name == 'send-email' - assert send_email.get_parameter('anything') is None + assert send_email.get_custom_parameter('anything') is None def test_agent_config_tools_fallback_to_default(ldai_client: LDAIClient): """Test that agent config falls back to default tools when flag has no tools.""" context = Context.create('user-key') - default_tools = [ToolDefinition('default-tool', parameters={'timeout': 30})] + default_tools = [ToolDefinition('default-tool', custom_parameters={'timeout': 30})] default = LDAIAgentDefaults( enabled=False, model=ModelConfig('fallback-model'), @@ -431,7 +431,7 @@ def test_agent_config_tools_fallback_to_default(ldai_client: LDAIClient): assert agent.tools is not None assert len(agent.tools) == 1 assert agent.tools[0].name == 'default-tool' - assert agent.tools[0].get_parameter('timeout') == 30 + assert agent.tools[0].get_custom_parameter('timeout') == 30 def test_agent_config_no_tools(ldai_client: LDAIClient): diff --git a/packages/sdk/server-ai/tests/test_model_config.py b/packages/sdk/server-ai/tests/test_model_config.py index 78d445c3..4c4807fc 100644 --- a/packages/sdk/server-ai/tests/test_model_config.py +++ b/packages/sdk/server-ai/tests/test_model_config.py @@ -111,8 +111,8 @@ def td() -> TestData: 'parameters': { 'temperature': 0.7, 'tools': [ - {'name': 'web_search', 'parameters': {'maxResults': 10, 'region': 'us'}}, - {'name': 'get_weather', 'parameters': {'units': 'celsius'}}, + {'name': 'web_search', 'customParameters': {'maxResults': 10, 'region': 'us'}}, + {'name': 'get_weather', 'customParameters': {'units': 'celsius'}}, {'name': 'calculator'}, ], }, @@ -449,24 +449,24 @@ def test_completion_config_without_default_uses_disabled(ldai_client: LDAIClient def test_tool_definition_basic(): tool = ToolDefinition('web_search') assert tool.name == 'web_search' - assert tool.get_parameter('anything') is None + assert tool.get_custom_parameter('anything') is None -def test_tool_definition_with_parameters(): - tool = ToolDefinition('web_search', parameters={'maxResults': 10, 'region': 'us'}) +def test_tool_definition_with_custom_parameters(): + tool = ToolDefinition('web_search', custom_parameters={'maxResults': 10, 'region': 'us'}) assert tool.name == 'web_search' - assert tool.get_parameter('maxResults') == 10 - assert tool.get_parameter('region') == 'us' - assert tool.get_parameter('nonexistent') is None + assert tool.get_custom_parameter('maxResults') == 10 + assert tool.get_custom_parameter('region') == 'us' + assert tool.get_custom_parameter('nonexistent') is None def test_tool_definition_to_dict(): - tool = ToolDefinition('web_search', parameters={'maxResults': 10}) + tool = ToolDefinition('web_search', custom_parameters={'maxResults': 10}) d = tool.to_dict() - assert d == {'name': 'web_search', 'parameters': {'maxResults': 10}} + assert d == {'name': 'web_search', 'customParameters': {'maxResults': 10}} -def test_tool_definition_to_dict_no_parameters(): +def test_tool_definition_to_dict_no_custom_parameters(): tool = ToolDefinition('calculator') d = tool.to_dict() assert d == {'name': 'calculator'} @@ -484,16 +484,16 @@ def test_completion_config_has_tools(ldai_client: LDAIClient): web_search = config.tools[0] assert web_search.name == 'web_search' - assert web_search.get_parameter('maxResults') == 10 - assert web_search.get_parameter('region') == 'us' + assert web_search.get_custom_parameter('maxResults') == 10 + assert web_search.get_custom_parameter('region') == 'us' get_weather = config.tools[1] assert get_weather.name == 'get_weather' - assert get_weather.get_parameter('units') == 'celsius' + assert get_weather.get_custom_parameter('units') == 'celsius' calculator = config.tools[2] assert calculator.name == 'calculator' - assert calculator.get_parameter('anything') is None + assert calculator.get_custom_parameter('anything') is None def test_completion_config_no_tools(ldai_client: LDAIClient): @@ -513,7 +513,7 @@ def test_completion_config_tools_missing_flag(ldai_client: LDAIClient): enabled=True, model=ModelConfig('fallback'), messages=[], - tools=[ToolDefinition('default_tool', parameters={'key': 'value'})], + tools=[ToolDefinition('default_tool', custom_parameters={'key': 'value'})], ) config = ldai_client.completion_config('missing-flag', context, default) @@ -524,4 +524,4 @@ def test_completion_config_tools_missing_flag(ldai_client: LDAIClient): assert config.tools is not None assert len(config.tools) == 1 assert config.tools[0].name == 'default_tool' - assert config.tools[0].get_parameter('key') == 'value' + assert config.tools[0].get_custom_parameter('key') == 'value' From 869616b3996b92b87405de3842f924df2a8b3cf0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:12:53 +0000 Subject: [PATCH 3/4] refactor: remove ToolDefinition, merge custom parameters into config types Co-Authored-By: traci@launchdarkly.com --- packages/sdk/server-ai/src/ldai/__init__.py | 2 - packages/sdk/server-ai/src/ldai/client.py | 39 ++--- packages/sdk/server-ai/src/ldai/models.py | 139 ++++++++++-------- packages/sdk/server-ai/tests/test_agents.py | 37 ++--- .../sdk/server-ai/tests/test_model_config.py | 68 +++------ 5 files changed, 131 insertions(+), 154 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/__init__.py b/packages/sdk/server-ai/src/ldai/__init__.py index b0d7b5a7..c70d941c 100644 --- a/packages/sdk/server-ai/src/ldai/__init__.py +++ b/packages/sdk/server-ai/src/ldai/__init__.py @@ -28,7 +28,6 @@ LDMessage, ModelConfig, ProviderConfig, - ToolDefinition, ) from ldai.providers import ( AgentGraphResult, @@ -69,7 +68,6 @@ 'LDMessage', 'ModelConfig', 'ProviderConfig', - 'ToolDefinition', 'log', # Deprecated exports 'AIConfig', diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index 9c9e562d..06a01739 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -25,7 +25,6 @@ LDMessage, ModelConfig, ProviderConfig, - ToolDefinition, ) from ldai.providers import ToolRegistry from ldai.providers.runner_factory import RunnerFactory @@ -69,7 +68,9 @@ def _completion_config( default: AICompletionConfigDefault, variables: Optional[Dict[str, Any]] = None, ) -> AICompletionConfig: - model, provider, messages, instructions, tracker, enabled, judge_configuration, _, tools = self.__evaluate( + (model, provider, messages, instructions, + tracker, enabled, judge_configuration, + _, tool_custom_parameters) = self.__evaluate( key, context, default.to_dict(), variables ) @@ -81,7 +82,7 @@ def _completion_config( provider=provider, tracker=tracker, judge_configuration=judge_configuration, - tools=tools, + tool_custom_parameters=tool_custom_parameters, ) return config @@ -138,7 +139,7 @@ def _judge_config( ) -> AIJudgeConfig: (model, provider, messages, instructions, tracker, enabled, judge_configuration, - variation, tools) = self.__evaluate( + variation, _) = self.__evaluate( key, context, default.to_dict(), variables ) @@ -755,7 +756,7 @@ def __evaluate( ) -> Tuple[ Optional[ModelConfig], Optional[ProviderConfig], Optional[List[LDMessage]], Optional[str], LDAIConfigTracker, bool, Optional[Any], Dict[str, Any], - Optional[List[ToolDefinition]] + Optional[Dict[str, Dict[str, Any]]] ]: """ Internal method to evaluate a configuration and extract components. @@ -833,23 +834,21 @@ def __evaluate( if judges: judge_configuration = JudgeConfiguration(judges=judges) - tools = None + tool_custom_parameters = None model_raw = variation.get('model') params_raw = model_raw.get('parameters') if isinstance(model_raw, dict) else None tool_defs_raw = params_raw.get('tools') if isinstance(params_raw, dict) else None if isinstance(tool_defs_raw, list): - tools = [ - ToolDefinition( - name=t.get('name', ''), - custom_parameters=t.get('customParameters', None) - ) - for t in tool_defs_raw - if isinstance(t, dict) and t.get('name') - ] - if not tools: - tools = None + parsed: Dict[str, Dict[str, Any]] = {} + for t in tool_defs_raw: + if isinstance(t, dict) and t.get('name'): + parsed[t['name']] = t.get('customParameters') or {} + if parsed: + tool_custom_parameters = parsed - return model, provider_config, messages, instructions, tracker, enabled, judge_configuration, variation, tools + return (model, provider_config, messages, instructions, + tracker, enabled, judge_configuration, + variation, tool_custom_parameters) def __evaluate_agent( self, @@ -867,7 +866,9 @@ def __evaluate_agent( :param variables: Variables for interpolation. :return: Configured AIAgentConfig instance. """ - model, provider, messages, instructions, tracker, enabled, judge_configuration, _, tools = self.__evaluate( + (model, provider, messages, instructions, + tracker, enabled, judge_configuration, + _, tool_custom_parameters) = self.__evaluate( key, context, default.to_dict(), variables ) @@ -882,7 +883,7 @@ def __evaluate_agent( instructions=final_instructions, tracker=tracker, judge_configuration=judge_configuration or default.judge_configuration, - tools=tools or default.tools, + tool_custom_parameters=tool_custom_parameters or default.tool_custom_parameters, ) def __interpolate_template(self, template: str, variables: Dict[str, Any]) -> str: diff --git a/packages/sdk/server-ai/src/ldai/models.py b/packages/sdk/server-ai/src/ldai/models.py index 714c82cb..3d90be10 100644 --- a/packages/sdk/server-ai/src/ldai/models.py +++ b/packages/sdk/server-ai/src/ldai/models.py @@ -77,57 +77,6 @@ def to_dict(self) -> dict: } -class ToolDefinition: - """ - Definition of a tool available to an AI configuration. - - Each tool has a name used to match against the tool registry, - and optional custom parameters that can be configured via the - LaunchDarkly dashboard. - """ - - def __init__( - self, - name: str, - custom_parameters: Optional[Dict[str, Any]] = None, - ): - """ - :param name: The name of the tool. - :param custom_parameters: Optional custom parameters for - the tool, configured via the LaunchDarkly dashboard. - """ - self._name = name - self._custom_parameters = custom_parameters - - @property - def name(self) -> str: - """ - The name of the tool. - """ - return self._name - - def get_custom_parameter(self, key: str) -> Any: - """ - Retrieve a custom parameter by key. - - :param key: The custom parameter key to look up. - :return: The parameter value, or None if not found. - """ - if self._custom_parameters is None: - return None - - return self._custom_parameters.get(key) - - def to_dict(self) -> dict: - """ - Render the tool definition as a dictionary object. - """ - result: Dict[str, Any] = {'name': self._name} - if self._custom_parameters is not None: - result['customParameters'] = self._custom_parameters - return result - - class ProviderConfig: """ Configuration related to the provider. @@ -259,7 +208,22 @@ class AICompletionConfigDefault(AIConfigDefault): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None - tools: Optional[List[ToolDefinition]] = None + tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + + def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: + """ + Retrieve a custom parameter for a specific tool. + + :param tool_name: The name of the tool. + :param key: The custom parameter key to look up. + :return: The parameter value, or None if not found. + """ + if self.tool_custom_parameters is None: + return None + tool_params = self.tool_custom_parameters.get(tool_name) + if tool_params is None: + return None + return tool_params.get(key) def to_dict(self) -> dict: """ @@ -269,10 +233,16 @@ def to_dict(self) -> dict: result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() - if self.tools is not None: + if self.tool_custom_parameters is not None: model = result.get('model') or {} params = model.get('parameters') or {} - params['tools'] = [tool.to_dict() for tool in self.tools] + tools_list = [] + for name, custom_params in self.tool_custom_parameters.items(): + tool_entry: Dict[str, Any] = {'name': name} + if custom_params: + tool_entry['customParameters'] = custom_params + tools_list.append(tool_entry) + params['tools'] = tools_list model['parameters'] = params result['model'] = model return result @@ -285,7 +255,22 @@ class AICompletionConfig(AIConfig): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None - tools: Optional[List[ToolDefinition]] = None + tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + + def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: + """ + Retrieve a custom parameter for a specific tool. + + :param tool_name: The name of the tool. + :param key: The custom parameter key to look up. + :return: The parameter value, or None if not found. + """ + if self.tool_custom_parameters is None: + return None + tool_params = self.tool_custom_parameters.get(tool_name) + if tool_params is None: + return None + return tool_params.get(key) def to_dict(self) -> dict: """ @@ -309,7 +294,22 @@ class AIAgentConfigDefault(AIConfigDefault): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None - tools: Optional[List[ToolDefinition]] = None + tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + + def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: + """ + Retrieve a custom parameter for a specific tool. + + :param tool_name: The name of the tool. + :param key: The custom parameter key to look up. + :return: The parameter value, or None if not found. + """ + if self.tool_custom_parameters is None: + return None + tool_params = self.tool_custom_parameters.get(tool_name) + if tool_params is None: + return None + return tool_params.get(key) def to_dict(self) -> Dict[str, Any]: """ @@ -320,10 +320,16 @@ def to_dict(self) -> Dict[str, Any]: result['instructions'] = self.instructions if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() - if self.tools is not None: + if self.tool_custom_parameters is not None: model = result.get('model') or {} params = model.get('parameters') or {} - params['tools'] = [tool.to_dict() for tool in self.tools] + tools_list = [] + for name, custom_params in self.tool_custom_parameters.items(): + tool_entry: Dict[str, Any] = {'name': name} + if custom_params: + tool_entry['customParameters'] = custom_params + tools_list.append(tool_entry) + params['tools'] = tools_list model['parameters'] = params result['model'] = model return result @@ -336,7 +342,22 @@ class AIAgentConfig(AIConfig): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None - tools: Optional[List[ToolDefinition]] = None + tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + + def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: + """ + Retrieve a custom parameter for a specific tool. + + :param tool_name: The name of the tool. + :param key: The custom parameter key to look up. + :return: The parameter value, or None if not found. + """ + if self.tool_custom_parameters is None: + return None + tool_params = self.tool_custom_parameters.get(tool_name) + if tool_params is None: + return None + return tool_params.get(key) def to_dict(self) -> Dict[str, Any]: """ diff --git a/packages/sdk/server-ai/tests/test_agents.py b/packages/sdk/server-ai/tests/test_agents.py index 455e20b3..fb024ed4 100644 --- a/packages/sdk/server-ai/tests/test_agents.py +++ b/packages/sdk/server-ai/tests/test_agents.py @@ -3,7 +3,7 @@ from ldclient.integrations.test_data import TestData from ldai import (LDAIAgentConfig, LDAIAgentDefaults, LDAIClient, ModelConfig, - ProviderConfig, ToolDefinition) + ProviderConfig) @pytest.fixture @@ -396,49 +396,40 @@ def test_agent_config_has_tools(ldai_client: LDAIClient): agent = ldai_client.agent_config('agent-with-tools', context) assert agent.enabled is True - assert agent.tools is not None - assert len(agent.tools) == 3 + assert agent.tool_custom_parameters is not None + assert len(agent.tool_custom_parameters) == 3 - get_order = agent.tools[0] - assert get_order.name == 'get-order' - assert get_order.get_custom_parameter('includeHistory') is True - assert get_order.get_custom_parameter('maxItems') == 5 - - search = agent.tools[1] - assert search.name == 'search-products' - assert search.get_custom_parameter('category') == 'electronics' - - send_email = agent.tools[2] - assert send_email.name == 'send-email' - assert send_email.get_custom_parameter('anything') is None + assert agent.get_tool_custom_parameter('get-order', 'includeHistory') is True + assert agent.get_tool_custom_parameter('get-order', 'maxItems') == 5 + assert agent.get_tool_custom_parameter('search-products', 'category') == 'electronics' + assert agent.get_tool_custom_parameter('send-email', 'anything') is None + assert 'send-email' in agent.tool_custom_parameters def test_agent_config_tools_fallback_to_default(ldai_client: LDAIClient): """Test that agent config falls back to default tools when flag has no tools.""" context = Context.create('user-key') - default_tools = [ToolDefinition('default-tool', custom_parameters={'timeout': 30})] default = LDAIAgentDefaults( enabled=False, model=ModelConfig('fallback-model'), instructions='Default instructions', - tools=default_tools, + tool_custom_parameters={'default-tool': {'timeout': 30}}, ) agent = ldai_client.agent_config('customer-support-agent', context, default) assert agent.enabled is True # customer-support-agent has no tools in the flag, so falls back to default - assert agent.tools is not None - assert len(agent.tools) == 1 - assert agent.tools[0].name == 'default-tool' - assert agent.tools[0].get_custom_parameter('timeout') == 30 + assert agent.tool_custom_parameters is not None + assert len(agent.tool_custom_parameters) == 1 + assert agent.get_tool_custom_parameter('default-tool', 'timeout') == 30 def test_agent_config_no_tools(ldai_client: LDAIClient): - """Test that agent tools is None when neither flag nor default has tools.""" + """Test that tool_custom_parameters is None when neither flag nor default has tools.""" context = Context.create('user-key') agent = ldai_client.agent_config('customer-support-agent', context) assert agent.enabled is True - assert agent.tools is None + assert agent.tool_custom_parameters is None diff --git a/packages/sdk/server-ai/tests/test_model_config.py b/packages/sdk/server-ai/tests/test_model_config.py index 4c4807fc..c96ec2aa 100644 --- a/packages/sdk/server-ai/tests/test_model_config.py +++ b/packages/sdk/server-ai/tests/test_model_config.py @@ -2,7 +2,7 @@ from ldclient import Config, Context, LDClient from ldclient.integrations.test_data import TestData -from ldai import LDAIClient, LDMessage, ModelConfig, ToolDefinition +from ldai import LDAIClient, LDMessage, ModelConfig from ldai.models import (AIAgentConfigDefault, AICompletionConfigDefault, AIConfigDefault, AIJudgeConfigDefault) @@ -443,35 +443,9 @@ def test_completion_config_without_default_uses_disabled(ldai_client: LDAIClient # ============================================================================ -# ToolDefinition tests +# Tool custom parameters tests # ============================================================================ -def test_tool_definition_basic(): - tool = ToolDefinition('web_search') - assert tool.name == 'web_search' - assert tool.get_custom_parameter('anything') is None - - -def test_tool_definition_with_custom_parameters(): - tool = ToolDefinition('web_search', custom_parameters={'maxResults': 10, 'region': 'us'}) - assert tool.name == 'web_search' - assert tool.get_custom_parameter('maxResults') == 10 - assert tool.get_custom_parameter('region') == 'us' - assert tool.get_custom_parameter('nonexistent') is None - - -def test_tool_definition_to_dict(): - tool = ToolDefinition('web_search', custom_parameters={'maxResults': 10}) - d = tool.to_dict() - assert d == {'name': 'web_search', 'customParameters': {'maxResults': 10}} - - -def test_tool_definition_to_dict_no_custom_parameters(): - tool = ToolDefinition('calculator') - d = tool.to_dict() - assert d == {'name': 'calculator'} - - def test_completion_config_has_tools(ldai_client: LDAIClient): """Test that tools with custom parameters are parsed from flag variations.""" context = Context.create('user-key') @@ -479,49 +453,41 @@ def test_completion_config_has_tools(ldai_client: LDAIClient): config = ldai_client.completion_config('config-with-tools', context, default) - assert config.tools is not None - assert len(config.tools) == 3 - - web_search = config.tools[0] - assert web_search.name == 'web_search' - assert web_search.get_custom_parameter('maxResults') == 10 - assert web_search.get_custom_parameter('region') == 'us' - - get_weather = config.tools[1] - assert get_weather.name == 'get_weather' - assert get_weather.get_custom_parameter('units') == 'celsius' + assert config.tool_custom_parameters is not None + assert len(config.tool_custom_parameters) == 3 - calculator = config.tools[2] - assert calculator.name == 'calculator' - assert calculator.get_custom_parameter('anything') is None + assert config.get_tool_custom_parameter('web_search', 'maxResults') == 10 + assert config.get_tool_custom_parameter('web_search', 'region') == 'us' + assert config.get_tool_custom_parameter('get_weather', 'units') == 'celsius' + assert config.get_tool_custom_parameter('calculator', 'anything') is None + assert 'calculator' in config.tool_custom_parameters def test_completion_config_no_tools(ldai_client: LDAIClient): - """Test that tools is None when no tools are defined.""" + """Test that tool_custom_parameters is None when no tools are defined.""" context = Context.create('user-key') default = AICompletionConfigDefault(enabled=False, model=ModelConfig('fallback'), messages=[]) config = ldai_client.completion_config('config-no-tools', context, default) - assert config.tools is None + assert config.tool_custom_parameters is None def test_completion_config_tools_missing_flag(ldai_client: LDAIClient): - """Test that tools from default are not used for completion configs.""" + """Test that tools from default are used for completion configs via serialization.""" context = Context.create('user-key') default = AICompletionConfigDefault( enabled=True, model=ModelConfig('fallback'), messages=[], - tools=[ToolDefinition('default_tool', custom_parameters={'key': 'value'})], + tool_custom_parameters={'default_tool': {'key': 'value'}}, ) config = ldai_client.completion_config('missing-flag', context, default) # The default is serialized into the variation dict, so the SDK evaluates - # against it; completion_config does not fall back to default.tools + # against it; completion_config does not fall back to default.tool_custom_parameters # separately — the variation itself carries the tool definitions. - assert config.tools is not None - assert len(config.tools) == 1 - assert config.tools[0].name == 'default_tool' - assert config.tools[0].get_custom_parameter('key') == 'value' + assert config.tool_custom_parameters is not None + assert len(config.tool_custom_parameters) == 1 + assert config.get_tool_custom_parameter('default_tool', 'key') == 'value' From 10909947854d640fb24ce36ae27dc3b7d0133afb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:19:01 +0000 Subject: [PATCH 4/4] refactor: address review feedback on tool custom parameters - Extract _get_tool_custom_parameter() helper to deduplicate across 4 classes - Extract _serialize_tool_custom_parameters() helper for to_dict serialization - Add ToolCustomParametersMap type alias for legibility - Only store tools that actually have customParameters (skip bare tool entries) - Add docstring on to_dict explaining completion-config serialization-path fallback - Add comment about dict insertion order in serialization helper Co-Authored-By: traci@launchdarkly.com --- packages/sdk/server-ai/src/ldai/__init__.py | 2 + packages/sdk/server-ai/src/ldai/client.py | 22 ++- packages/sdk/server-ai/src/ldai/models.py | 186 ++++++++++-------- packages/sdk/server-ai/tests/test_agents.py | 6 +- .../sdk/server-ai/tests/test_model_config.py | 6 +- 5 files changed, 125 insertions(+), 97 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/__init__.py b/packages/sdk/server-ai/src/ldai/__init__.py index c70d941c..64ee57c8 100644 --- a/packages/sdk/server-ai/src/ldai/__init__.py +++ b/packages/sdk/server-ai/src/ldai/__init__.py @@ -28,6 +28,7 @@ LDMessage, ModelConfig, ProviderConfig, + ToolCustomParametersMap, ) from ldai.providers import ( AgentGraphResult, @@ -68,6 +69,7 @@ 'LDMessage', 'ModelConfig', 'ProviderConfig', + 'ToolCustomParametersMap', 'log', # Deprecated exports 'AIConfig', diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index 06a01739..04f66060 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -25,6 +25,7 @@ LDMessage, ModelConfig, ProviderConfig, + ToolCustomParametersMap, ) from ldai.providers import ToolRegistry from ldai.providers.runner_factory import RunnerFactory @@ -756,7 +757,7 @@ def __evaluate( ) -> Tuple[ Optional[ModelConfig], Optional[ProviderConfig], Optional[List[LDMessage]], Optional[str], LDAIConfigTracker, bool, Optional[Any], Dict[str, Any], - Optional[Dict[str, Dict[str, Any]]] + Optional[ToolCustomParametersMap] ]: """ Internal method to evaluate a configuration and extract components. @@ -836,13 +837,22 @@ def __evaluate( tool_custom_parameters = None model_raw = variation.get('model') - params_raw = model_raw.get('parameters') if isinstance(model_raw, dict) else None - tool_defs_raw = params_raw.get('tools') if isinstance(params_raw, dict) else None + params_raw = ( + model_raw.get('parameters') + if isinstance(model_raw, dict) else None + ) + tool_defs_raw = ( + params_raw.get('tools') + if isinstance(params_raw, dict) else None + ) if isinstance(tool_defs_raw, list): - parsed: Dict[str, Dict[str, Any]] = {} + parsed: ToolCustomParametersMap = {} for t in tool_defs_raw: - if isinstance(t, dict) and t.get('name'): - parsed[t['name']] = t.get('customParameters') or {} + if not isinstance(t, dict) or not t.get('name'): + continue + cp = t.get('customParameters') + if isinstance(cp, dict) and cp: + parsed[t['name']] = cp if parsed: tool_custom_parameters = parsed diff --git a/packages/sdk/server-ai/src/ldai/models.py b/packages/sdk/server-ai/src/ldai/models.py index 3d90be10..28f173bd 100644 --- a/packages/sdk/server-ai/src/ldai/models.py +++ b/packages/sdk/server-ai/src/ldai/models.py @@ -4,6 +4,58 @@ from ldai.tracker import LDAIConfigTracker +# Type alias for tool custom parameters: maps tool name -> custom params dict +ToolCustomParametersMap = Dict[str, Dict[str, Any]] + + +def _get_tool_custom_parameter( + tool_custom_parameters: Optional['ToolCustomParametersMap'], + tool_name: str, + key: str, +) -> Any: + """Retrieve a custom parameter for a specific tool. + + :param tool_custom_parameters: The tool custom parameters map. + :param tool_name: The name of the tool. + :param key: The custom parameter key to look up. + :return: The parameter value, or None if not found. + """ + if tool_custom_parameters is None: + return None + tool_params = tool_custom_parameters.get(tool_name) + if tool_params is None: + return None + return tool_params.get(key) + + +def _serialize_tool_custom_parameters( + result: Dict[str, Any], + tool_custom_parameters: Optional['ToolCustomParametersMap'], +) -> None: + """Serialize tool_custom_parameters into the result dict. + + Injects tools into ``result['model']['parameters']['tools']``. + Iteration order follows dict insertion order (Python 3.7+); if the + original flag payload order matters, the caller must ensure it is + preserved at parse time. + + :param result: The mutable dict being built by to_dict(). + :param tool_custom_parameters: The tool custom parameters map. + """ + if tool_custom_parameters is None: + return + model = result.get('model') or {} + params = model.get('parameters') or {} + tools_list = [] + for name, custom_params in tool_custom_parameters.items(): + tool_entry: Dict[str, Any] = {'name': name} + if custom_params: + tool_entry['customParameters'] = custom_params + tools_list.append(tool_entry) + params['tools'] = tools_list + model['parameters'] = params + result['model'] = model + @dataclass class LDMessage: @@ -208,43 +260,33 @@ class AICompletionConfigDefault(AIConfigDefault): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None - tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + tool_custom_parameters: Optional[ToolCustomParametersMap] = None - def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: - """ - Retrieve a custom parameter for a specific tool. - - :param tool_name: The name of the tool. - :param key: The custom parameter key to look up. - :return: The parameter value, or None if not found. - """ - if self.tool_custom_parameters is None: - return None - tool_params = self.tool_custom_parameters.get(tool_name) - if tool_params is None: - return None - return tool_params.get(key) + def get_tool_custom_parameter( + self, tool_name: str, key: str, + ) -> Any: + """Retrieve a custom parameter for a specific tool.""" + return _get_tool_custom_parameter( + self.tool_custom_parameters, tool_name, key, + ) def to_dict(self) -> dict: - """ - Render the given default values as an AICompletionConfigDefault-compatible dictionary object. + """Render the given default values as an AICompletionConfigDefault-compatible dictionary object. + + Note: tool_custom_parameters are serialized into + ``model.parameters.tools`` so that they are carried inside the + variation dict used as the evaluation fallback. This is how + completion-config defaults reach ``_completion_config`` — there + is no separate ``or default.tool_custom_parameters`` fallback + like agent configs have. """ result = self._base_to_dict() result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() - if self.tool_custom_parameters is not None: - model = result.get('model') or {} - params = model.get('parameters') or {} - tools_list = [] - for name, custom_params in self.tool_custom_parameters.items(): - tool_entry: Dict[str, Any] = {'name': name} - if custom_params: - tool_entry['customParameters'] = custom_params - tools_list.append(tool_entry) - params['tools'] = tools_list - model['parameters'] = params - result['model'] = model + _serialize_tool_custom_parameters( + result, self.tool_custom_parameters, + ) return result @@ -255,22 +297,15 @@ class AICompletionConfig(AIConfig): """ messages: Optional[List[LDMessage]] = None judge_configuration: Optional[JudgeConfiguration] = None - tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + tool_custom_parameters: Optional[ToolCustomParametersMap] = None - def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: - """ - Retrieve a custom parameter for a specific tool. - - :param tool_name: The name of the tool. - :param key: The custom parameter key to look up. - :return: The parameter value, or None if not found. - """ - if self.tool_custom_parameters is None: - return None - tool_params = self.tool_custom_parameters.get(tool_name) - if tool_params is None: - return None - return tool_params.get(key) + def get_tool_custom_parameter( + self, tool_name: str, key: str, + ) -> Any: + """Retrieve a custom parameter for a specific tool.""" + return _get_tool_custom_parameter( + self.tool_custom_parameters, tool_name, key, + ) def to_dict(self) -> dict: """ @@ -294,22 +329,15 @@ class AIAgentConfigDefault(AIConfigDefault): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None - tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None + tool_custom_parameters: Optional[ToolCustomParametersMap] = None - def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: - """ - Retrieve a custom parameter for a specific tool. - - :param tool_name: The name of the tool. - :param key: The custom parameter key to look up. - :return: The parameter value, or None if not found. - """ - if self.tool_custom_parameters is None: - return None - tool_params = self.tool_custom_parameters.get(tool_name) - if tool_params is None: - return None - return tool_params.get(key) + def get_tool_custom_parameter( + self, tool_name: str, key: str, + ) -> Any: + """Retrieve a custom parameter for a specific tool.""" + return _get_tool_custom_parameter( + self.tool_custom_parameters, tool_name, key, + ) def to_dict(self) -> Dict[str, Any]: """ @@ -320,18 +348,9 @@ def to_dict(self) -> Dict[str, Any]: result['instructions'] = self.instructions if self.judge_configuration is not None: result['judgeConfiguration'] = self.judge_configuration.to_dict() - if self.tool_custom_parameters is not None: - model = result.get('model') or {} - params = model.get('parameters') or {} - tools_list = [] - for name, custom_params in self.tool_custom_parameters.items(): - tool_entry: Dict[str, Any] = {'name': name} - if custom_params: - tool_entry['customParameters'] = custom_params - tools_list.append(tool_entry) - params['tools'] = tools_list - model['parameters'] = params - result['model'] = model + _serialize_tool_custom_parameters( + result, self.tool_custom_parameters, + ) return result @@ -342,22 +361,15 @@ class AIAgentConfig(AIConfig): """ instructions: Optional[str] = None judge_configuration: Optional[JudgeConfiguration] = None - tool_custom_parameters: Optional[Dict[str, Dict[str, Any]]] = None - - def get_tool_custom_parameter(self, tool_name: str, key: str) -> Any: - """ - Retrieve a custom parameter for a specific tool. - - :param tool_name: The name of the tool. - :param key: The custom parameter key to look up. - :return: The parameter value, or None if not found. - """ - if self.tool_custom_parameters is None: - return None - tool_params = self.tool_custom_parameters.get(tool_name) - if tool_params is None: - return None - return tool_params.get(key) + tool_custom_parameters: Optional[ToolCustomParametersMap] = None + + def get_tool_custom_parameter( + self, tool_name: str, key: str, + ) -> Any: + """Retrieve a custom parameter for a specific tool.""" + return _get_tool_custom_parameter( + self.tool_custom_parameters, tool_name, key, + ) def to_dict(self) -> Dict[str, Any]: """ diff --git a/packages/sdk/server-ai/tests/test_agents.py b/packages/sdk/server-ai/tests/test_agents.py index fb024ed4..7d89b6af 100644 --- a/packages/sdk/server-ai/tests/test_agents.py +++ b/packages/sdk/server-ai/tests/test_agents.py @@ -397,13 +397,15 @@ def test_agent_config_has_tools(ldai_client: LDAIClient): assert agent.enabled is True assert agent.tool_custom_parameters is not None - assert len(agent.tool_custom_parameters) == 3 + # Only tools with non-empty customParameters are included + assert len(agent.tool_custom_parameters) == 2 assert agent.get_tool_custom_parameter('get-order', 'includeHistory') is True assert agent.get_tool_custom_parameter('get-order', 'maxItems') == 5 assert agent.get_tool_custom_parameter('search-products', 'category') == 'electronics' + # 'send-email' has no customParameters, so it is not in the map + assert 'send-email' not in agent.tool_custom_parameters assert agent.get_tool_custom_parameter('send-email', 'anything') is None - assert 'send-email' in agent.tool_custom_parameters def test_agent_config_tools_fallback_to_default(ldai_client: LDAIClient): diff --git a/packages/sdk/server-ai/tests/test_model_config.py b/packages/sdk/server-ai/tests/test_model_config.py index c96ec2aa..8372e12c 100644 --- a/packages/sdk/server-ai/tests/test_model_config.py +++ b/packages/sdk/server-ai/tests/test_model_config.py @@ -454,13 +454,15 @@ def test_completion_config_has_tools(ldai_client: LDAIClient): config = ldai_client.completion_config('config-with-tools', context, default) assert config.tool_custom_parameters is not None - assert len(config.tool_custom_parameters) == 3 + # Only tools with non-empty customParameters are included + assert len(config.tool_custom_parameters) == 2 assert config.get_tool_custom_parameter('web_search', 'maxResults') == 10 assert config.get_tool_custom_parameter('web_search', 'region') == 'us' assert config.get_tool_custom_parameter('get_weather', 'units') == 'celsius' + # 'calculator' has no customParameters, so it is not in the map + assert 'calculator' not in config.tool_custom_parameters assert config.get_tool_custom_parameter('calculator', 'anything') is None - assert 'calculator' in config.tool_custom_parameters def test_completion_config_no_tools(ldai_client: LDAIClient):