Skip to content

Commit f17c535

Browse files
authored
feat: Add root-level tools map with customParameters to AI Config types (#141)
1 parent da0c9c6 commit f17c535

4 files changed

Lines changed: 202 additions & 2 deletions

File tree

packages/sdk/server-ai/src/ldai/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
LDAIAgentConfig,
2727
LDAIAgentDefaults,
2828
LDMessage,
29+
LDTool,
2930
ModelConfig,
3031
ProviderConfig,
3132
)
@@ -64,6 +65,7 @@
6465
'Judge',
6566
'JudgeConfiguration',
6667
'JudgeResult',
68+
'LDTool',
6769
'LDMessage',
6870
'ModelConfig',
6971
'ProviderConfig',

packages/sdk/server-ai/src/ldai/client.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Edge,
2525
JudgeConfiguration,
2626
LDMessage,
27+
LDTool,
2728
ModelConfig,
2829
ProviderConfig,
2930
)
@@ -50,6 +51,25 @@
5051
_DISABLED_JUDGE_DEFAULT = AIJudgeConfigDefault.disabled()
5152

5253

54+
def _parse_tools(tools_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, LDTool]]:
55+
"""Parse the root-level tools map from a flag variation dict."""
56+
if not isinstance(tools_data, dict):
57+
return None
58+
result = {}
59+
for tool_name, tool_dict in tools_data.items():
60+
if not isinstance(tool_dict, dict):
61+
log.warning('Skipping tool "%s": expected a dict, got %s', tool_name, type(tool_dict).__name__)
62+
continue
63+
result[tool_name] = LDTool(
64+
name=tool_dict.get('name', tool_name),
65+
description=tool_dict.get('description'),
66+
type=tool_dict.get('type'),
67+
parameters=tool_dict.get('parameters'),
68+
custom_parameters=tool_dict.get('customParameters'),
69+
)
70+
return result or None
71+
72+
5373
class LDAIClient:
5474
"""The LaunchDarkly AI SDK client object."""
5575

@@ -89,10 +109,12 @@ def _completion_config(
89109
variables: Optional[Dict[str, Any]] = None,
90110
) -> AICompletionConfig:
91111
(model, provider, messages, instructions,
92-
tracker_factory, enabled, judge_configuration, _) = self.__evaluate(
112+
tracker_factory, enabled, judge_configuration, variation) = self.__evaluate(
93113
key, context, default.to_dict(), variables
94114
)
95115

116+
tools = _parse_tools(variation.get('tools'))
117+
96118
config = AICompletionConfig(
97119
key=key,
98120
enabled=bool(enabled),
@@ -101,6 +123,7 @@ def _completion_config(
101123
provider=provider,
102124
create_tracker=tracker_factory,
103125
judge_configuration=judge_configuration,
126+
tools=tools,
104127
)
105128

106129
return config
@@ -891,13 +914,15 @@ def __evaluate_agent(
891914
:return: Configured AIAgentConfig instance.
892915
"""
893916
(model, provider, messages, instructions,
894-
tracker_factory, enabled, judge_configuration, _) = self.__evaluate(
917+
tracker_factory, enabled, judge_configuration, variation) = self.__evaluate(
895918
key, context, default.to_dict(), variables, graph_key=graph_key
896919
)
897920

898921
# For agents, prioritize instructions over messages
899922
final_instructions = instructions if instructions is not None else default.instructions
900923

924+
tools = _parse_tools(variation.get('tools'))
925+
901926
return AIAgentConfig(
902927
key=key,
903928
enabled=bool(enabled) if enabled is not None else (default.enabled or False),
@@ -906,6 +931,7 @@ def __evaluate_agent(
906931
instructions=final_instructions,
907932
create_tracker=tracker_factory,
908933
judge_configuration=judge_configuration or default.judge_configuration,
934+
tools=tools,
909935
)
910936

911937
def __interpolate_template(self, template: str, variables: Dict[str, Any]) -> str:

packages/sdk/server-ai/src/ldai/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
from typing_extensions import Self
66

77

8+
@dataclass(frozen=True)
9+
class LDTool:
10+
"""
11+
A single tool entry from the root-level tools map in an AI Config flag variation.
12+
Distinct from model.parameters.tools[] which is the raw array passed to LLM providers.
13+
"""
14+
name: str
15+
description: Optional[str] = None
16+
type: Optional[str] = None
17+
parameters: Optional[Dict[str, Any]] = None
18+
custom_parameters: Optional[Dict[str, Any]] = None
19+
20+
def to_dict(self) -> dict:
21+
result: Dict[str, Any] = {'name': self.name}
22+
if self.description is not None:
23+
result['description'] = self.description
24+
if self.type is not None:
25+
result['type'] = self.type
26+
if self.parameters is not None:
27+
result['parameters'] = self.parameters
28+
if self.custom_parameters is not None:
29+
result['customParameters'] = self.custom_parameters # camelCase in wire format
30+
return result
31+
32+
833
@dataclass
934
class LDMessage:
1035
role: Literal['system', 'user', 'assistant']
@@ -208,6 +233,7 @@ class AICompletionConfigDefault(AIConfigDefault):
208233
"""
209234
messages: Optional[List[LDMessage]] = None
210235
judge_configuration: Optional[JudgeConfiguration] = None
236+
tools: Optional[Dict[str, 'LDTool']] = None
211237

212238
def to_dict(self) -> dict:
213239
"""
@@ -217,6 +243,8 @@ def to_dict(self) -> dict:
217243
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
218244
if self.judge_configuration is not None:
219245
result['judgeConfiguration'] = self.judge_configuration.to_dict()
246+
if self.tools is not None:
247+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
220248
return result
221249

222250

@@ -227,6 +255,7 @@ class AICompletionConfig(AIConfig):
227255
"""
228256
messages: Optional[List[LDMessage]] = None
229257
judge_configuration: Optional[JudgeConfiguration] = None
258+
tools: Optional[Dict[str, 'LDTool']] = None
230259

231260
def to_dict(self) -> dict:
232261
"""
@@ -236,6 +265,8 @@ def to_dict(self) -> dict:
236265
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
237266
if self.judge_configuration is not None:
238267
result['judgeConfiguration'] = self.judge_configuration.to_dict()
268+
if self.tools is not None:
269+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
239270
return result
240271

241272

@@ -250,6 +281,7 @@ class AIAgentConfigDefault(AIConfigDefault):
250281
"""
251282
instructions: Optional[str] = None
252283
judge_configuration: Optional[JudgeConfiguration] = None
284+
tools: Optional[Dict[str, 'LDTool']] = None
253285

254286
def to_dict(self) -> Dict[str, Any]:
255287
"""
@@ -260,6 +292,8 @@ def to_dict(self) -> Dict[str, Any]:
260292
result['instructions'] = self.instructions
261293
if self.judge_configuration is not None:
262294
result['judgeConfiguration'] = self.judge_configuration.to_dict()
295+
if self.tools is not None:
296+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
263297
return result
264298

265299

@@ -270,6 +304,7 @@ class AIAgentConfig(AIConfig):
270304
"""
271305
instructions: Optional[str] = None
272306
judge_configuration: Optional[JudgeConfiguration] = None
307+
tools: Optional[Dict[str, 'LDTool']] = None
273308

274309
def to_dict(self) -> Dict[str, Any]:
275310
"""
@@ -280,6 +315,8 @@ def to_dict(self) -> Dict[str, Any]:
280315
result['instructions'] = self.instructions
281316
if self.judge_configuration is not None:
282317
result['judgeConfiguration'] = self.judge_configuration.to_dict()
318+
if self.tools is not None:
319+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
283320
return result
284321

285322

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import pytest
2+
from ldclient import Config, Context, LDClient
3+
from ldclient.integrations.test_data import TestData
4+
5+
from ldai import LDTool, LDAIClient
6+
from ldai.models import AIAgentConfigDefault, AICompletionConfigDefault
7+
8+
9+
@pytest.fixture
10+
def td() -> TestData:
11+
td = TestData.data_source()
12+
td.update(
13+
td.flag('completion-with-tools')
14+
.variations(
15+
{
16+
'model': {'name': 'gpt-5', 'parameters': {'temperature': 0.7}},
17+
'messages': [{'role': 'user', 'content': 'Hello'}],
18+
'tools': {
19+
'web-search-tool': {
20+
'name': 'web-search-tool',
21+
'type': 'function',
22+
'parameters': {'type': 'object', 'properties': {}, 'required': []},
23+
'customParameters': {'some-custom-parameter': 'some-custom-value'},
24+
}
25+
},
26+
'_ldMeta': {'enabled': True, 'variationKey': 'v1', 'version': 1},
27+
},
28+
)
29+
.variation_for_all(0)
30+
)
31+
32+
td.update(
33+
td.flag('completion-no-tools')
34+
.variations(
35+
{
36+
'model': {'name': 'gpt-5'},
37+
'messages': [{'role': 'user', 'content': 'Hello'}],
38+
'_ldMeta': {'enabled': True, 'variationKey': 'v1', 'version': 1},
39+
},
40+
)
41+
.variation_for_all(0)
42+
)
43+
44+
td.update(
45+
td.flag('agent-with-tools')
46+
.variations(
47+
{
48+
'model': {'name': 'gpt-5'},
49+
'instructions': 'You are a helpful agent.',
50+
'tools': {
51+
'search-tool': {
52+
'name': 'search-tool',
53+
'type': 'function',
54+
'customParameters': {'maxResults': 10},
55+
}
56+
},
57+
'_ldMeta': {'enabled': True, 'variationKey': 'v1', 'version': 1, 'mode': 'agent'},
58+
},
59+
)
60+
.variation_for_all(0)
61+
)
62+
63+
return td
64+
65+
66+
@pytest.fixture
67+
def client(td) -> LDAIClient:
68+
config = Config('fake-sdk-key', update_processor_class=td, send_events=False)
69+
ld_client = LDClient(config=config)
70+
return LDAIClient(ld_client)
71+
72+
73+
@pytest.fixture
74+
def context() -> Context:
75+
return Context.builder('test-user').name('Test User').build()
76+
77+
78+
def test_completion_config_includes_tools_from_variation(client, context):
79+
result = client.completion_config('completion-with-tools', context, AICompletionConfigDefault())
80+
81+
assert result.tools is not None
82+
assert 'web-search-tool' in result.tools
83+
tool = result.tools['web-search-tool']
84+
assert tool.name == 'web-search-tool'
85+
assert tool.type == 'function'
86+
assert tool.custom_parameters == {'some-custom-parameter': 'some-custom-value'}
87+
88+
89+
def test_completion_config_tools_none_when_not_in_variation(client, context):
90+
result = client.completion_config('completion-no-tools', context, AICompletionConfigDefault())
91+
92+
assert result.tools is None
93+
94+
95+
def test_completion_config_tools_none_when_variation_has_no_tools(client, context):
96+
default_tool = LDTool(name='default-tool', type='function', custom_parameters={'priority': 'high'})
97+
default = AICompletionConfigDefault(tools={'default-tool': default_tool})
98+
99+
result = client.completion_config('completion-no-tools', context, default)
100+
101+
assert result.tools is None
102+
103+
104+
def test_agent_config_includes_tools_from_variation(client, context):
105+
result = client.agent_config('agent-with-tools', context, AIAgentConfigDefault())
106+
107+
assert result.tools is not None
108+
assert 'search-tool' in result.tools
109+
tool = result.tools['search-tool']
110+
assert tool.name == 'search-tool'
111+
assert tool.custom_parameters == {'maxResults': 10}
112+
113+
114+
def test_aitool_to_dict_serializes_custom_parameters_as_camel_case():
115+
tool = LDTool(
116+
name='my-tool',
117+
type='function',
118+
parameters={'type': 'object'},
119+
custom_parameters={'someKey': 'someValue'},
120+
)
121+
d = tool.to_dict()
122+
123+
assert d['name'] == 'my-tool'
124+
assert d['type'] == 'function'
125+
assert d['parameters'] == {'type': 'object'}
126+
assert 'customParameters' in d
127+
assert d['customParameters'] == {'someKey': 'someValue'}
128+
assert 'custom_parameters' not in d
129+
130+
131+
def test_aitool_to_dict_omits_none_fields():
132+
tool = LDTool(name='bare-tool')
133+
d = tool.to_dict()
134+
135+
assert d == {'name': 'bare-tool'}

0 commit comments

Comments
 (0)