Skip to content

Commit 826f6bb

Browse files
jsonbaileyclaude
andcommitted
feat: add root-level tools map with customParameters to AI Config types
Adds AITool dataclass and tools map (keyed by tool name) to completion and agent config types. The root-level tools map is distinct from model.parameters.tools[] which remains passable to LLM providers as-is. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 39ccda7 commit 826f6bb

4 files changed

Lines changed: 199 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AIConfig,
2121
AIJudgeConfig,
2222
AIJudgeConfigDefault,
23+
AITool,
2324
Edge,
2425
JudgeConfiguration,
2526
LDAIAgent,

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AICompletionConfigDefault,
2121
AIJudgeConfig,
2222
AIJudgeConfigDefault,
23+
AITool,
2324
Edge,
2425
JudgeConfiguration,
2526
LDMessage,
@@ -45,6 +46,22 @@
4546
_INIT_TRACK_CONTEXT = Context.builder('ld-internal-tracking').kind('ld_ai').anonymous(True).build()
4647

4748

49+
def _parse_tools(tools_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, AITool]]:
50+
"""Parse the root-level tools map from a flag variation dict."""
51+
if not tools_data or not isinstance(tools_data, dict):
52+
return None
53+
result = {}
54+
for tool_name, tool_dict in tools_data.items():
55+
if isinstance(tool_dict, dict):
56+
result[tool_name] = AITool(
57+
name=tool_dict.get('name', tool_name),
58+
type=tool_dict.get('type'),
59+
parameters=tool_dict.get('parameters'),
60+
custom_parameters=tool_dict.get('customParameters'),
61+
)
62+
return result or None
63+
64+
4865
class LDAIClient:
4966
"""The LaunchDarkly AI SDK client object."""
5067

@@ -68,10 +85,13 @@ def _completion_config(
6885
default: AICompletionConfigDefault,
6986
variables: Optional[Dict[str, Any]] = None,
7087
) -> AICompletionConfig:
71-
model, provider, messages, instructions, tracker, enabled, judge_configuration, _ = self.__evaluate(
88+
model, provider, messages, instructions, tracker, enabled, judge_configuration, variation = self.__evaluate(
7289
key, context, default.to_dict(), variables
7390
)
7491

92+
tools_data = variation.get('tools')
93+
tools = _parse_tools(tools_data) if tools_data is not None else default.tools
94+
7595
config = AICompletionConfig(
7696
key=key,
7797
enabled=bool(enabled),
@@ -80,6 +100,7 @@ def _completion_config(
80100
provider=provider,
81101
tracker=tracker,
82102
judge_configuration=judge_configuration,
103+
tools=tools,
83104
)
84105

85106
return config
@@ -854,13 +875,16 @@ def __evaluate_agent(
854875
:param graph_key: When set, passed to the tracker so all events include ``graphKey``.
855876
:return: Configured AIAgentConfig instance.
856877
"""
857-
model, provider, messages, instructions, tracker, enabled, judge_configuration, _ = self.__evaluate(
878+
model, provider, messages, instructions, tracker, enabled, judge_configuration, variation = self.__evaluate(
858879
key, context, default.to_dict(), variables, graph_key=graph_key
859880
)
860881

861882
# For agents, prioritize instructions over messages
862883
final_instructions = instructions if instructions is not None else default.instructions
863884

885+
tools_data = variation.get('tools')
886+
tools = _parse_tools(tools_data) if tools_data is not None else default.tools
887+
864888
return AIAgentConfig(
865889
key=key,
866890
enabled=bool(enabled) if enabled is not None else (default.enabled or False),
@@ -869,6 +893,7 @@ def __evaluate_agent(
869893
instructions=final_instructions,
870894
tracker=tracker,
871895
judge_configuration=judge_configuration or default.judge_configuration,
896+
tools=tools,
872897
)
873898

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

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,28 @@
33
from typing import Any, Dict, List, Literal, Optional, Union
44

55

6+
@dataclass(frozen=True)
7+
class AITool:
8+
"""
9+
A single tool entry from the root-level tools map in an AI Config flag variation.
10+
Distinct from model.parameters.tools[] which is the raw array passed to LLM providers.
11+
"""
12+
name: str
13+
type: Optional[str] = None
14+
parameters: Optional[Dict[str, Any]] = None
15+
custom_parameters: Optional[Dict[str, Any]] = None
16+
17+
def to_dict(self) -> dict:
18+
result: Dict[str, Any] = {'name': self.name}
19+
if self.type is not None:
20+
result['type'] = self.type
21+
if self.parameters is not None:
22+
result['parameters'] = self.parameters
23+
if self.custom_parameters is not None:
24+
result['customParameters'] = self.custom_parameters # camelCase in wire format
25+
return result
26+
27+
628
@dataclass
729
class LDMessage:
830
role: Literal['system', 'user', 'assistant']
@@ -206,6 +228,7 @@ class AICompletionConfigDefault(AIConfigDefault):
206228
"""
207229
messages: Optional[List[LDMessage]] = None
208230
judge_configuration: Optional[JudgeConfiguration] = None
231+
tools: Optional[Dict[str, 'AITool']] = None
209232

210233
def to_dict(self) -> dict:
211234
"""
@@ -215,6 +238,8 @@ def to_dict(self) -> dict:
215238
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
216239
if self.judge_configuration is not None:
217240
result['judgeConfiguration'] = self.judge_configuration.to_dict()
241+
if self.tools is not None:
242+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
218243
return result
219244

220245

@@ -225,6 +250,7 @@ class AICompletionConfig(AIConfig):
225250
"""
226251
messages: Optional[List[LDMessage]] = None
227252
judge_configuration: Optional[JudgeConfiguration] = None
253+
tools: Optional[Dict[str, 'AITool']] = None
228254

229255
def to_dict(self) -> dict:
230256
"""
@@ -234,6 +260,8 @@ def to_dict(self) -> dict:
234260
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
235261
if self.judge_configuration is not None:
236262
result['judgeConfiguration'] = self.judge_configuration.to_dict()
263+
if self.tools is not None:
264+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
237265
return result
238266

239267

@@ -248,6 +276,7 @@ class AIAgentConfigDefault(AIConfigDefault):
248276
"""
249277
instructions: Optional[str] = None
250278
judge_configuration: Optional[JudgeConfiguration] = None
279+
tools: Optional[Dict[str, 'AITool']] = None
251280

252281
def to_dict(self) -> Dict[str, Any]:
253282
"""
@@ -258,6 +287,8 @@ def to_dict(self) -> Dict[str, Any]:
258287
result['instructions'] = self.instructions
259288
if self.judge_configuration is not None:
260289
result['judgeConfiguration'] = self.judge_configuration.to_dict()
290+
if self.tools is not None:
291+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
261292
return result
262293

263294

@@ -268,6 +299,7 @@ class AIAgentConfig(AIConfig):
268299
"""
269300
instructions: Optional[str] = None
270301
judge_configuration: Optional[JudgeConfiguration] = None
302+
tools: Optional[Dict[str, 'AITool']] = None
271303

272304
def to_dict(self) -> Dict[str, Any]:
273305
"""
@@ -278,6 +310,8 @@ def to_dict(self) -> Dict[str, Any]:
278310
result['instructions'] = self.instructions
279311
if self.judge_configuration is not None:
280312
result['judgeConfiguration'] = self.judge_configuration.to_dict()
313+
if self.tools is not None:
314+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
281315
return result
282316

283317

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import pytest
2+
from ldclient import Config, Context, LDClient
3+
from ldclient.integrations.test_data import TestData
4+
5+
from ldai import AITool, 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_falls_back_to_default_tools(client, context):
96+
default_tool = AITool(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 not None
102+
assert 'default-tool' in result.tools
103+
assert result.tools['default-tool'].custom_parameters == {'priority': 'high'}
104+
105+
106+
def test_agent_config_includes_tools_from_variation(client, context):
107+
result = client.agent_config('agent-with-tools', context, AIAgentConfigDefault())
108+
109+
assert result.tools is not None
110+
assert 'search-tool' in result.tools
111+
tool = result.tools['search-tool']
112+
assert tool.name == 'search-tool'
113+
assert tool.custom_parameters == {'maxResults': 10}
114+
115+
116+
def test_aitool_to_dict_serializes_custom_parameters_as_camel_case():
117+
tool = AITool(
118+
name='my-tool',
119+
type='function',
120+
parameters={'type': 'object'},
121+
custom_parameters={'someKey': 'someValue'},
122+
)
123+
d = tool.to_dict()
124+
125+
assert d['name'] == 'my-tool'
126+
assert d['type'] == 'function'
127+
assert d['parameters'] == {'type': 'object'}
128+
assert 'customParameters' in d
129+
assert d['customParameters'] == {'someKey': 'someValue'}
130+
assert 'custom_parameters' not in d
131+
132+
133+
def test_aitool_to_dict_omits_none_fields():
134+
tool = AITool(name='bare-tool')
135+
d = tool.to_dict()
136+
137+
assert d == {'name': 'bare-tool'}

0 commit comments

Comments
 (0)