From 7ce3db4d3d4aea7c73571387c82f0dde4d062500 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Fri, 1 May 2026 15:36:47 -0500 Subject: [PATCH 1/2] fix: parse model.parameters.tools as list, not dict The wire format for tools at model.parameters.tools is a list of LDTool objects, while root-level tools is a dict keyed by tool name. Previously _resolve_tools treated both as dicts, causing the model params fallback path to always return None. Removes helper functions and inlines logic into _resolve_tools with no warnings, matching the JS implementation approach of trusting the data shape from the LD API. Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/server-ai/src/ldai/client.py | 55 ++++++++++--------- packages/sdk/server-ai/tests/test_tools.py | 64 ++++++++++++++++------ 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index 2c6d1fa2..b8155e69 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -53,30 +53,22 @@ _DISABLED_JUDGE_DEFAULT = AIJudgeConfigDefault.disabled() -def _parse_tools(tools_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, LDTool]]: - """Parse the root-level tools map from a flag variation dict.""" - if not isinstance(tools_data, dict): - if tools_data is not None: - log.warning('Skipping tools: expected a dict, got %s', type(tools_data).__name__) - return None - result: Dict[str, LDTool] = {} - for tool_name, tool_dict in tools_data.items(): - if not isinstance(tool_dict, dict): - log.warning('Skipping tool "%s": expected a dict, got %s', tool_name, type(tool_dict).__name__) - continue - result[tool_name] = LDTool( - name=tool_dict.get('name', tool_name), - description=tool_dict.get('description'), - type=tool_dict.get('type'), - parameters=tool_dict.get('parameters'), - custom_parameters=tool_dict.get('customParameters'), - ) - return result or None - - def _resolve_tools(variation: Dict[str, Any]) -> Optional[Dict[str, LDTool]]: if 'tools' in variation: - return _parse_tools(variation['tools']) + tools_data = variation['tools'] + if not isinstance(tools_data, dict): + return None + tools: Dict[str, LDTool] = {} + for tool_name, tool_dict in tools_data.items(): + if isinstance(tool_dict, dict): + tools[tool_name] = LDTool( + name=str(tool_dict.get('name', tool_name)), + description=tool_dict.get('description'), + type=tool_dict.get('type'), + parameters=tool_dict.get('parameters'), + custom_parameters=tool_dict.get('customParameters'), + ) + return tools or None model = variation.get('model') if not isinstance(model, dict): @@ -84,13 +76,22 @@ def _resolve_tools(variation: Dict[str, Any]) -> Optional[Dict[str, LDTool]]: parameters = model.get('parameters') if not isinstance(parameters, dict): return None - tools_data = parameters.get('tools') - if not isinstance(tools_data, dict): - if tools_data is not None: - log.warning('Skipping model.parameters.tools: expected a dict, got %s', type(tools_data).__name__) + tools_list = parameters.get('tools') + if not isinstance(tools_list, list): return None - return _parse_tools(tools_data) + tools = {} + for item in tools_list: + if isinstance(item, dict) and item.get('name'): + tool_name = str(item['name']) + tools[tool_name] = LDTool( + name=tool_name, + description=item.get('description'), + type=item.get('type'), + parameters=item.get('parameters'), + custom_parameters=item.get('customParameters'), + ) + return tools or None class LDAIClient: diff --git a/packages/sdk/server-ai/tests/test_tools.py b/packages/sdk/server-ai/tests/test_tools.py index ce7d04d8..c13eaadf 100644 --- a/packages/sdk/server-ai/tests/test_tools.py +++ b/packages/sdk/server-ai/tests/test_tools.py @@ -68,14 +68,14 @@ def td() -> TestData: 'name': 'gpt-5', 'parameters': { 'temperature': 0.5, - 'tools': { - 'param-tool': { + 'tools': [ + { 'name': 'param-tool', 'type': 'function', 'description': 'A tool from model params', 'parameters': {'type': 'object'}, } - }, + ], }, }, 'messages': [{'role': 'user', 'content': 'Hello'}], @@ -92,12 +92,12 @@ def td() -> TestData: 'model': { 'name': 'gpt-5', 'parameters': { - 'tools': { - 'model-param-tool': { + 'tools': [ + { 'name': 'model-param-tool', 'type': 'function', } - }, + ], }, }, 'messages': [{'role': 'user', 'content': 'Hello'}], @@ -114,7 +114,7 @@ def td() -> TestData: ) td.update( - td.flag('completion-model-params-tools-as-list') + td.flag('completion-model-params-tools-list-format') .variations( { 'model': { @@ -133,18 +133,17 @@ def td() -> TestData: ) td.update( - td.flag('completion-model-params-tools-missing-name') + td.flag('completion-model-params-tools-as-dict') .variations( { 'model': { 'name': 'gpt-5', 'parameters': { 'tools': { - 'valid-tool': { - 'name': 'valid-tool', + 'dict-tool': { + 'name': 'dict-tool', 'type': 'function', }, - 'bad-entry': 'not-a-dict', }, }, }, @@ -155,6 +154,29 @@ def td() -> TestData: .variation_for_all(0) ) + td.update( + td.flag('completion-model-params-tools-bad-entries') + .variations( + { + 'model': { + 'name': 'gpt-5', + 'parameters': { + 'tools': [ + { + 'name': 'valid-tool', + 'type': 'function', + }, + 'not-a-dict', + ], + }, + }, + 'messages': [{'role': 'user', 'content': 'Hello'}], + '_ldMeta': {'enabled': True, 'variationKey': 'v1', 'version': 1}, + }, + ) + .variation_for_all(0) + ) + return td @@ -249,17 +271,23 @@ def test_completion_config_root_tools_take_priority_over_model_params(client, co assert 'model-param-tool' not in result.tools -def test_completion_config_model_params_tools_as_list_returns_none(client, context): - result = client.completion_config('completion-model-params-tools-as-list', context, AICompletionConfigDefault()) +def test_completion_config_model_params_tools_list_format_is_parsed(client, context): + result = client.completion_config('completion-model-params-tools-list-format', context, AICompletionConfigDefault()) + + assert result.tools is not None + assert 'list-tool' in result.tools + assert result.tools['list-tool'].type == 'function' + + +def test_completion_config_model_params_tools_dict_format_returns_none(client, context): + result = client.completion_config('completion-model-params-tools-as-dict', context, AICompletionConfigDefault()) assert result.tools is None -def test_completion_config_model_params_tools_skips_bad_entries_silently(client, context): - result = client.completion_config( - 'completion-model-params-tools-missing-name', context, AICompletionConfigDefault() - ) +def test_completion_config_model_params_tools_skips_bad_entries(client, context): + result = client.completion_config('completion-model-params-tools-bad-entries', context, AICompletionConfigDefault()) assert result.tools is not None assert 'valid-tool' in result.tools - assert 'bad-entry' not in result.tools + assert len(result.tools) == 1 From db79f741ac8a429e8dd141aa05c6c2d8d1900701 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 5 May 2026 08:38:47 -0500 Subject: [PATCH 2/2] fix: log warnings when skipping malformed tool entries Re-adds entry-level warnings that were removed when inlining the parsing logic. Users setting incorrect defaults would otherwise get silent drops with no indication of why tools were missing. Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/server-ai/src/ldai/client.py | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index b8155e69..de60c0d2 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -68,6 +68,8 @@ def _resolve_tools(variation: Dict[str, Any]) -> Optional[Dict[str, LDTool]]: parameters=tool_dict.get('parameters'), custom_parameters=tool_dict.get('customParameters'), ) + else: + log.warning('Skipping tool "%s": expected a dict, got %s', tool_name, type(tool_dict).__name__) return tools or None model = variation.get('model') @@ -82,15 +84,20 @@ def _resolve_tools(variation: Dict[str, Any]) -> Optional[Dict[str, LDTool]]: tools = {} for item in tools_list: - if isinstance(item, dict) and item.get('name'): - tool_name = str(item['name']) - tools[tool_name] = LDTool( - name=tool_name, - description=item.get('description'), - type=item.get('type'), - parameters=item.get('parameters'), - custom_parameters=item.get('customParameters'), - ) + if not isinstance(item, dict): + log.warning('Skipping tool entry: expected a dict, got %s', type(item).__name__) + continue + if not item.get('name'): + log.warning('Skipping tool entry: missing name') + continue + tool_name = str(item['name']) + tools[tool_name] = LDTool( + name=tool_name, + description=item.get('description'), + type=item.get('type'), + parameters=item.get('parameters'), + custom_parameters=item.get('customParameters'), + ) return tools or None