Skip to content

Commit 6661d5f

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 da0c9c6 commit 6661d5f

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
@@ -21,6 +21,7 @@
2121
AICompletionConfigDefault,
2222
AIJudgeConfig,
2323
AIJudgeConfigDefault,
24+
AITool,
2425
Edge,
2526
JudgeConfiguration,
2627
LDMessage,
@@ -50,6 +51,22 @@
5051
_DISABLED_JUDGE_DEFAULT = AIJudgeConfigDefault.disabled()
5152

5253

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

@@ -89,10 +106,13 @@ def _completion_config(
89106
variables: Optional[Dict[str, Any]] = None,
90107
) -> AICompletionConfig:
91108
(model, provider, messages, instructions,
92-
tracker_factory, enabled, judge_configuration, _) = self.__evaluate(
109+
tracker_factory, enabled, judge_configuration, variation) = self.__evaluate(
93110
key, context, default.to_dict(), variables
94111
)
95112

113+
tools_data = variation.get('tools')
114+
tools = _parse_tools(tools_data) if tools_data is not None else default.tools
115+
96116
config = AICompletionConfig(
97117
key=key,
98118
enabled=bool(enabled),
@@ -101,6 +121,7 @@ def _completion_config(
101121
provider=provider,
102122
create_tracker=tracker_factory,
103123
judge_configuration=judge_configuration,
124+
tools=tools,
104125
)
105126

106127
return config
@@ -891,13 +912,16 @@ def __evaluate_agent(
891912
:return: Configured AIAgentConfig instance.
892913
"""
893914
(model, provider, messages, instructions,
894-
tracker_factory, enabled, judge_configuration, _) = self.__evaluate(
915+
tracker_factory, enabled, judge_configuration, variation) = self.__evaluate(
895916
key, context, default.to_dict(), variables, graph_key=graph_key
896917
)
897918

898919
# For agents, prioritize instructions over messages
899920
final_instructions = instructions if instructions is not None else default.instructions
900921

922+
tools_data = variation.get('tools')
923+
tools = _parse_tools(tools_data) if tools_data is not None else default.tools
924+
901925
return AIAgentConfig(
902926
key=key,
903927
enabled=bool(enabled) if enabled is not None else (default.enabled or False),
@@ -906,6 +930,7 @@ def __evaluate_agent(
906930
instructions=final_instructions,
907931
create_tracker=tracker_factory,
908932
judge_configuration=judge_configuration or default.judge_configuration,
933+
tools=tools,
909934
)
910935

911936
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
@@ -5,6 +5,28 @@
55
from typing_extensions import Self
66

77

8+
@dataclass(frozen=True)
9+
class AITool:
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+
type: Optional[str] = None
16+
parameters: Optional[Dict[str, Any]] = None
17+
custom_parameters: Optional[Dict[str, Any]] = None
18+
19+
def to_dict(self) -> dict:
20+
result: Dict[str, Any] = {'name': self.name}
21+
if self.type is not None:
22+
result['type'] = self.type
23+
if self.parameters is not None:
24+
result['parameters'] = self.parameters
25+
if self.custom_parameters is not None:
26+
result['customParameters'] = self.custom_parameters # camelCase in wire format
27+
return result
28+
29+
830
@dataclass
931
class LDMessage:
1032
role: Literal['system', 'user', 'assistant']
@@ -208,6 +230,7 @@ class AICompletionConfigDefault(AIConfigDefault):
208230
"""
209231
messages: Optional[List[LDMessage]] = None
210232
judge_configuration: Optional[JudgeConfiguration] = None
233+
tools: Optional[Dict[str, 'AITool']] = None
211234

212235
def to_dict(self) -> dict:
213236
"""
@@ -217,6 +240,8 @@ def to_dict(self) -> dict:
217240
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
218241
if self.judge_configuration is not None:
219242
result['judgeConfiguration'] = self.judge_configuration.to_dict()
243+
if self.tools is not None:
244+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
220245
return result
221246

222247

@@ -227,6 +252,7 @@ class AICompletionConfig(AIConfig):
227252
"""
228253
messages: Optional[List[LDMessage]] = None
229254
judge_configuration: Optional[JudgeConfiguration] = None
255+
tools: Optional[Dict[str, 'AITool']] = None
230256

231257
def to_dict(self) -> dict:
232258
"""
@@ -236,6 +262,8 @@ def to_dict(self) -> dict:
236262
result['messages'] = [message.to_dict() for message in self.messages] if self.messages else None
237263
if self.judge_configuration is not None:
238264
result['judgeConfiguration'] = self.judge_configuration.to_dict()
265+
if self.tools is not None:
266+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
239267
return result
240268

241269

@@ -250,6 +278,7 @@ class AIAgentConfigDefault(AIConfigDefault):
250278
"""
251279
instructions: Optional[str] = None
252280
judge_configuration: Optional[JudgeConfiguration] = None
281+
tools: Optional[Dict[str, 'AITool']] = None
253282

254283
def to_dict(self) -> Dict[str, Any]:
255284
"""
@@ -260,6 +289,8 @@ def to_dict(self) -> Dict[str, Any]:
260289
result['instructions'] = self.instructions
261290
if self.judge_configuration is not None:
262291
result['judgeConfiguration'] = self.judge_configuration.to_dict()
292+
if self.tools is not None:
293+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
263294
return result
264295

265296

@@ -270,6 +301,7 @@ class AIAgentConfig(AIConfig):
270301
"""
271302
instructions: Optional[str] = None
272303
judge_configuration: Optional[JudgeConfiguration] = None
304+
tools: Optional[Dict[str, 'AITool']] = None
273305

274306
def to_dict(self) -> Dict[str, Any]:
275307
"""
@@ -280,6 +312,8 @@ def to_dict(self) -> Dict[str, Any]:
280312
result['instructions'] = self.instructions
281313
if self.judge_configuration is not None:
282314
result['judgeConfiguration'] = self.judge_configuration.to_dict()
315+
if self.tools is not None:
316+
result['tools'] = {k: v.to_dict() for k, v in self.tools.items()}
283317
return result
284318

285319

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)