kiln-ai 0.18.0__py3-none-any.whl → 0.20.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kiln-ai might be problematic. Click here for more details.
- kiln_ai/adapters/__init__.py +2 -2
- kiln_ai/adapters/adapter_registry.py +46 -0
- kiln_ai/adapters/chat/chat_formatter.py +8 -12
- kiln_ai/adapters/chat/test_chat_formatter.py +6 -2
- kiln_ai/adapters/data_gen/data_gen_task.py +2 -2
- kiln_ai/adapters/data_gen/test_data_gen_task.py +7 -3
- kiln_ai/adapters/docker_model_runner_tools.py +119 -0
- kiln_ai/adapters/eval/base_eval.py +2 -2
- kiln_ai/adapters/eval/eval_runner.py +3 -1
- kiln_ai/adapters/eval/g_eval.py +2 -2
- kiln_ai/adapters/eval/test_base_eval.py +1 -1
- kiln_ai/adapters/eval/test_eval_runner.py +6 -12
- kiln_ai/adapters/eval/test_g_eval.py +3 -4
- kiln_ai/adapters/eval/test_g_eval_data.py +1 -1
- kiln_ai/adapters/fine_tune/__init__.py +1 -1
- kiln_ai/adapters/fine_tune/base_finetune.py +1 -0
- kiln_ai/adapters/fine_tune/fireworks_finetune.py +32 -20
- kiln_ai/adapters/fine_tune/openai_finetune.py +14 -4
- kiln_ai/adapters/fine_tune/test_fireworks_tinetune.py +30 -21
- kiln_ai/adapters/fine_tune/test_openai_finetune.py +108 -111
- kiln_ai/adapters/ml_model_list.py +1009 -111
- kiln_ai/adapters/model_adapters/base_adapter.py +62 -28
- kiln_ai/adapters/model_adapters/litellm_adapter.py +397 -80
- kiln_ai/adapters/model_adapters/test_base_adapter.py +194 -18
- kiln_ai/adapters/model_adapters/test_litellm_adapter.py +428 -4
- kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py +1103 -0
- kiln_ai/adapters/model_adapters/test_saving_adapter_results.py +5 -5
- kiln_ai/adapters/model_adapters/test_structured_output.py +120 -14
- kiln_ai/adapters/parsers/__init__.py +1 -1
- kiln_ai/adapters/parsers/test_r1_parser.py +1 -1
- kiln_ai/adapters/provider_tools.py +35 -20
- kiln_ai/adapters/remote_config.py +57 -10
- kiln_ai/adapters/repair/repair_task.py +1 -1
- kiln_ai/adapters/repair/test_repair_task.py +12 -9
- kiln_ai/adapters/run_output.py +3 -0
- kiln_ai/adapters/test_adapter_registry.py +109 -2
- kiln_ai/adapters/test_docker_model_runner_tools.py +305 -0
- kiln_ai/adapters/test_ml_model_list.py +51 -1
- kiln_ai/adapters/test_prompt_adaptors.py +13 -6
- kiln_ai/adapters/test_provider_tools.py +73 -12
- kiln_ai/adapters/test_remote_config.py +470 -16
- kiln_ai/datamodel/__init__.py +23 -21
- kiln_ai/datamodel/basemodel.py +54 -28
- kiln_ai/datamodel/datamodel_enums.py +3 -0
- kiln_ai/datamodel/dataset_split.py +5 -3
- kiln_ai/datamodel/eval.py +4 -4
- kiln_ai/datamodel/external_tool_server.py +298 -0
- kiln_ai/datamodel/finetune.py +2 -2
- kiln_ai/datamodel/json_schema.py +25 -10
- kiln_ai/datamodel/project.py +11 -4
- kiln_ai/datamodel/prompt.py +2 -2
- kiln_ai/datamodel/prompt_id.py +4 -4
- kiln_ai/datamodel/registry.py +0 -15
- kiln_ai/datamodel/run_config.py +62 -0
- kiln_ai/datamodel/task.py +8 -83
- kiln_ai/datamodel/task_output.py +7 -2
- kiln_ai/datamodel/task_run.py +41 -0
- kiln_ai/datamodel/test_basemodel.py +213 -21
- kiln_ai/datamodel/test_eval_model.py +6 -6
- kiln_ai/datamodel/test_example_models.py +175 -0
- kiln_ai/datamodel/test_external_tool_server.py +691 -0
- kiln_ai/datamodel/test_model_perf.py +1 -1
- kiln_ai/datamodel/test_prompt_id.py +5 -1
- kiln_ai/datamodel/test_registry.py +8 -3
- kiln_ai/datamodel/test_task.py +20 -47
- kiln_ai/datamodel/test_tool_id.py +239 -0
- kiln_ai/datamodel/tool_id.py +83 -0
- kiln_ai/tools/__init__.py +8 -0
- kiln_ai/tools/base_tool.py +82 -0
- kiln_ai/tools/built_in_tools/__init__.py +13 -0
- kiln_ai/tools/built_in_tools/math_tools.py +124 -0
- kiln_ai/tools/built_in_tools/test_math_tools.py +204 -0
- kiln_ai/tools/mcp_server_tool.py +95 -0
- kiln_ai/tools/mcp_session_manager.py +243 -0
- kiln_ai/tools/test_base_tools.py +199 -0
- kiln_ai/tools/test_mcp_server_tool.py +457 -0
- kiln_ai/tools/test_mcp_session_manager.py +1585 -0
- kiln_ai/tools/test_tool_registry.py +473 -0
- kiln_ai/tools/tool_registry.py +64 -0
- kiln_ai/utils/config.py +32 -0
- kiln_ai/utils/open_ai_types.py +94 -0
- kiln_ai/utils/project_utils.py +17 -0
- kiln_ai/utils/test_config.py +138 -1
- kiln_ai/utils/test_open_ai_types.py +131 -0
- {kiln_ai-0.18.0.dist-info → kiln_ai-0.20.1.dist-info}/METADATA +37 -6
- kiln_ai-0.20.1.dist-info/RECORD +138 -0
- kiln_ai-0.18.0.dist-info/RECORD +0 -115
- {kiln_ai-0.18.0.dist-info → kiln_ai-0.20.1.dist-info}/WHEEL +0 -0
- {kiln_ai-0.18.0.dist-info → kiln_ai-0.20.1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from kiln_ai.datamodel.external_tool_server import ExternalToolServer, ToolServerType
|
|
7
|
+
from kiln_ai.utils.config import MCP_SECRETS_KEY, Config
|
|
8
|
+
from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestExternalToolServer:
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_config(self):
|
|
14
|
+
"""Mock Config.shared() to avoid file system dependencies."""
|
|
15
|
+
with patch.object(Config, "shared") as mock_shared:
|
|
16
|
+
config_instance = Mock()
|
|
17
|
+
config_instance.get_value.return_value = {}
|
|
18
|
+
config_instance.update_settings = Mock()
|
|
19
|
+
mock_shared.return_value = config_instance
|
|
20
|
+
yield config_instance
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def remote_mcp_base_props(self) -> Dict[str, Any]:
|
|
24
|
+
"""Base properties for remote MCP server."""
|
|
25
|
+
return {
|
|
26
|
+
"server_url": "https://api.example.com/mcp",
|
|
27
|
+
"headers": {"Content-Type": "application/json"},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def local_mcp_base_props(self) -> Dict[str, Any]:
|
|
32
|
+
"""Base properties for local MCP server."""
|
|
33
|
+
return {
|
|
34
|
+
"command": "python",
|
|
35
|
+
"args": ["-m", "mcp_server"],
|
|
36
|
+
"env_vars": {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
"server_type, properties",
|
|
41
|
+
[
|
|
42
|
+
(
|
|
43
|
+
ToolServerType.remote_mcp,
|
|
44
|
+
{
|
|
45
|
+
"server_url": "https://api.example.com/mcp",
|
|
46
|
+
"headers": {"Authorization": "Bearer token123"},
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
ToolServerType.local_mcp,
|
|
51
|
+
{
|
|
52
|
+
"command": "python",
|
|
53
|
+
"args": ["-m", "server"],
|
|
54
|
+
"env_vars": {"API_KEY": "secret123"},
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_valid_server_creation(self, mock_config, server_type, properties):
|
|
60
|
+
"""Test creating valid servers of both types."""
|
|
61
|
+
server = ExternalToolServer(
|
|
62
|
+
name="test-server",
|
|
63
|
+
type=server_type,
|
|
64
|
+
description="Test server",
|
|
65
|
+
properties=properties,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert server.name == "test-server"
|
|
69
|
+
assert server.type == server_type
|
|
70
|
+
assert server.description == "Test server"
|
|
71
|
+
assert server.properties == properties
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"server_type, invalid_props, expected_error",
|
|
75
|
+
[
|
|
76
|
+
# Remote MCP validation errors
|
|
77
|
+
(ToolServerType.remote_mcp, {}, "server_url must be a string"),
|
|
78
|
+
(ToolServerType.remote_mcp, {"server_url": ""}, "server_url is required"),
|
|
79
|
+
(
|
|
80
|
+
ToolServerType.remote_mcp,
|
|
81
|
+
{"server_url": 123},
|
|
82
|
+
"server_url must be a string",
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
ToolServerType.remote_mcp,
|
|
86
|
+
{"server_url": "http://test.com"},
|
|
87
|
+
"headers must be set",
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
ToolServerType.remote_mcp,
|
|
91
|
+
{"server_url": "http://test.com", "headers": "not-a-dict"},
|
|
92
|
+
"headers must be a dictionary",
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
ToolServerType.remote_mcp,
|
|
96
|
+
{
|
|
97
|
+
"server_url": "http://test.com",
|
|
98
|
+
"headers": {},
|
|
99
|
+
"secret_header_keys": "not-a-list",
|
|
100
|
+
},
|
|
101
|
+
"secret_header_keys must be a list",
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
ToolServerType.remote_mcp,
|
|
105
|
+
{
|
|
106
|
+
"server_url": "http://test.com",
|
|
107
|
+
"headers": {},
|
|
108
|
+
"secret_header_keys": [123],
|
|
109
|
+
},
|
|
110
|
+
"secret_header_keys must contain only strings",
|
|
111
|
+
),
|
|
112
|
+
# Local MCP validation errors
|
|
113
|
+
(ToolServerType.local_mcp, {}, "command must be a string"),
|
|
114
|
+
(ToolServerType.local_mcp, {"command": ""}, "command is required"),
|
|
115
|
+
(ToolServerType.local_mcp, {"command": 123}, "command must be a string"),
|
|
116
|
+
(
|
|
117
|
+
ToolServerType.local_mcp,
|
|
118
|
+
{"command": "python"},
|
|
119
|
+
"arguments must be a list",
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
ToolServerType.local_mcp,
|
|
123
|
+
{"command": "python", "args": "not-a-list"},
|
|
124
|
+
"arguments must be a list",
|
|
125
|
+
),
|
|
126
|
+
(
|
|
127
|
+
ToolServerType.local_mcp,
|
|
128
|
+
{"command": "python", "args": [], "env_vars": "not-a-dict"},
|
|
129
|
+
"environment variables must be a dictionary",
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
ToolServerType.local_mcp,
|
|
133
|
+
{
|
|
134
|
+
"command": "python",
|
|
135
|
+
"args": [],
|
|
136
|
+
"env_vars": {},
|
|
137
|
+
"secret_env_var_keys": "not-a-list",
|
|
138
|
+
},
|
|
139
|
+
"secret_env_var_keys must be a list",
|
|
140
|
+
),
|
|
141
|
+
(
|
|
142
|
+
ToolServerType.local_mcp,
|
|
143
|
+
{
|
|
144
|
+
"command": "python",
|
|
145
|
+
"args": [],
|
|
146
|
+
"env_vars": {},
|
|
147
|
+
"secret_env_var_keys": [123],
|
|
148
|
+
},
|
|
149
|
+
"secret_env_var_keys must contain only strings",
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
def test_validation_errors(
|
|
154
|
+
self, mock_config, server_type, invalid_props, expected_error
|
|
155
|
+
):
|
|
156
|
+
"""Test validation errors for invalid configurations."""
|
|
157
|
+
with pytest.raises((ValueError, Exception)) as exc_info:
|
|
158
|
+
ExternalToolServer(
|
|
159
|
+
name="test-server", type=server_type, properties=invalid_props
|
|
160
|
+
)
|
|
161
|
+
# Check that the expected error message is in the exception string
|
|
162
|
+
assert expected_error in str(exc_info.value)
|
|
163
|
+
|
|
164
|
+
def test_get_secret_keys_remote_mcp(self, mock_config, remote_mcp_base_props):
|
|
165
|
+
"""Test get_secret_keys for remote MCP servers."""
|
|
166
|
+
# No secret keys defined
|
|
167
|
+
server = ExternalToolServer(
|
|
168
|
+
name="test-server",
|
|
169
|
+
type=ToolServerType.remote_mcp,
|
|
170
|
+
properties=remote_mcp_base_props,
|
|
171
|
+
)
|
|
172
|
+
assert server.get_secret_keys() == []
|
|
173
|
+
|
|
174
|
+
# With secret header keys
|
|
175
|
+
props_with_secrets = {
|
|
176
|
+
**remote_mcp_base_props,
|
|
177
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
178
|
+
}
|
|
179
|
+
server = ExternalToolServer(
|
|
180
|
+
name="test-server",
|
|
181
|
+
type=ToolServerType.remote_mcp,
|
|
182
|
+
properties=props_with_secrets,
|
|
183
|
+
)
|
|
184
|
+
assert server.get_secret_keys() == ["Authorization", "X-API-Key"]
|
|
185
|
+
|
|
186
|
+
def test_get_secret_keys_local_mcp(self, mock_config, local_mcp_base_props):
|
|
187
|
+
"""Test get_secret_keys for local MCP servers."""
|
|
188
|
+
# No secret keys defined
|
|
189
|
+
server = ExternalToolServer(
|
|
190
|
+
name="test-server",
|
|
191
|
+
type=ToolServerType.local_mcp,
|
|
192
|
+
properties=local_mcp_base_props,
|
|
193
|
+
)
|
|
194
|
+
assert server.get_secret_keys() == []
|
|
195
|
+
|
|
196
|
+
# With secret env var keys
|
|
197
|
+
props_with_secrets = {
|
|
198
|
+
**local_mcp_base_props,
|
|
199
|
+
"secret_env_var_keys": ["API_KEY", "SECRET_TOKEN"],
|
|
200
|
+
}
|
|
201
|
+
server = ExternalToolServer(
|
|
202
|
+
name="test-server",
|
|
203
|
+
type=ToolServerType.local_mcp,
|
|
204
|
+
properties=props_with_secrets,
|
|
205
|
+
)
|
|
206
|
+
assert server.get_secret_keys() == ["API_KEY", "SECRET_TOKEN"]
|
|
207
|
+
|
|
208
|
+
def test_secret_processing_remote_mcp_initialization(self, mock_config):
|
|
209
|
+
"""Test secret processing during remote MCP server initialization."""
|
|
210
|
+
properties = {
|
|
211
|
+
"server_url": "https://api.example.com/mcp",
|
|
212
|
+
"headers": {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"Authorization": "Bearer secret123",
|
|
215
|
+
"X-API-Key": "api-key-456",
|
|
216
|
+
},
|
|
217
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
server = ExternalToolServer(
|
|
221
|
+
name="test-server", type=ToolServerType.remote_mcp, properties=properties
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Secrets should be extracted to _unsaved_secrets
|
|
225
|
+
assert server._unsaved_secrets == {
|
|
226
|
+
"Authorization": "Bearer secret123",
|
|
227
|
+
"X-API-Key": "api-key-456",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Secrets should be removed from headers
|
|
231
|
+
assert server.properties["headers"] == {"Content-Type": "application/json"}
|
|
232
|
+
|
|
233
|
+
def test_secret_processing_local_mcp_initialization(self, mock_config):
|
|
234
|
+
"""Test secret processing during local MCP server initialization."""
|
|
235
|
+
properties = {
|
|
236
|
+
"command": "python",
|
|
237
|
+
"args": ["-m", "server"],
|
|
238
|
+
"env_vars": {
|
|
239
|
+
"PATH": "/usr/bin",
|
|
240
|
+
"API_KEY": "secret123",
|
|
241
|
+
"DB_PASSWORD": "db-secret-456",
|
|
242
|
+
},
|
|
243
|
+
"secret_env_var_keys": ["API_KEY", "DB_PASSWORD"],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
server = ExternalToolServer(
|
|
247
|
+
name="test-server", type=ToolServerType.local_mcp, properties=properties
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Secrets should be extracted to _unsaved_secrets
|
|
251
|
+
assert server._unsaved_secrets == {
|
|
252
|
+
"API_KEY": "secret123",
|
|
253
|
+
"DB_PASSWORD": "db-secret-456",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Secrets should be removed from env_vars
|
|
257
|
+
assert server.properties["env_vars"] == {"PATH": "/usr/bin"}
|
|
258
|
+
|
|
259
|
+
def test_secret_processing_property_update_remote_mcp(
|
|
260
|
+
self, mock_config, remote_mcp_base_props
|
|
261
|
+
):
|
|
262
|
+
"""Test secret processing when properties are updated via __setattr__ for remote MCP."""
|
|
263
|
+
server = ExternalToolServer(
|
|
264
|
+
name="test-server",
|
|
265
|
+
type=ToolServerType.remote_mcp,
|
|
266
|
+
properties={
|
|
267
|
+
**remote_mcp_base_props,
|
|
268
|
+
"secret_header_keys": ["Authorization"],
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Clear any existing unsaved secrets
|
|
273
|
+
server._unsaved_secrets.clear()
|
|
274
|
+
|
|
275
|
+
# Update properties with secrets
|
|
276
|
+
new_properties = {
|
|
277
|
+
**remote_mcp_base_props,
|
|
278
|
+
"headers": {
|
|
279
|
+
**remote_mcp_base_props["headers"],
|
|
280
|
+
"Authorization": "Bearer new-token",
|
|
281
|
+
},
|
|
282
|
+
"secret_header_keys": ["Authorization"],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
server.properties = new_properties
|
|
286
|
+
|
|
287
|
+
# Secret should be processed (extracted and removed from headers)
|
|
288
|
+
assert server._unsaved_secrets == {"Authorization": "Bearer new-token"}
|
|
289
|
+
assert "Authorization" not in server.properties["headers"]
|
|
290
|
+
|
|
291
|
+
def test_secret_processing_clears_existing_secrets(
|
|
292
|
+
self, mock_config, remote_mcp_base_props
|
|
293
|
+
):
|
|
294
|
+
"""Test that secret processing clears existing _unsaved_secrets."""
|
|
295
|
+
server = ExternalToolServer(
|
|
296
|
+
name="test-server",
|
|
297
|
+
type=ToolServerType.remote_mcp,
|
|
298
|
+
properties={
|
|
299
|
+
**remote_mcp_base_props,
|
|
300
|
+
"secret_header_keys": ["Authorization"],
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Manually add some unsaved secrets
|
|
305
|
+
server._unsaved_secrets = {"OldSecret": "old-value"}
|
|
306
|
+
|
|
307
|
+
# Update properties - should clear old secrets
|
|
308
|
+
new_properties = {
|
|
309
|
+
**remote_mcp_base_props,
|
|
310
|
+
"headers": {
|
|
311
|
+
**remote_mcp_base_props["headers"],
|
|
312
|
+
"Authorization": "Bearer new-token",
|
|
313
|
+
},
|
|
314
|
+
"secret_header_keys": ["Authorization"],
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
server.properties = new_properties
|
|
318
|
+
|
|
319
|
+
# Only new secret should remain
|
|
320
|
+
assert server._unsaved_secrets == {"Authorization": "Bearer new-token"}
|
|
321
|
+
assert "OldSecret" not in server._unsaved_secrets
|
|
322
|
+
|
|
323
|
+
def test_retrieve_secrets_from_config(self, mock_config, remote_mcp_base_props):
|
|
324
|
+
"""Test retrieving secrets from config storage."""
|
|
325
|
+
server = ExternalToolServer(
|
|
326
|
+
name="test-server",
|
|
327
|
+
type=ToolServerType.remote_mcp,
|
|
328
|
+
properties={
|
|
329
|
+
**remote_mcp_base_props,
|
|
330
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
server.id = "server-123"
|
|
334
|
+
|
|
335
|
+
# Mock config to return saved secrets
|
|
336
|
+
mock_config.get_value.return_value = {
|
|
337
|
+
"server-123::Authorization": "Bearer config-token",
|
|
338
|
+
"server-123::X-API-Key": "config-api-key",
|
|
339
|
+
"other-server::Authorization": "other-token",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
secrets, missing = server.retrieve_secrets()
|
|
343
|
+
|
|
344
|
+
assert secrets == {
|
|
345
|
+
"Authorization": "Bearer config-token",
|
|
346
|
+
"X-API-Key": "config-api-key",
|
|
347
|
+
}
|
|
348
|
+
assert missing == []
|
|
349
|
+
|
|
350
|
+
def test_retrieve_secrets_from_unsaved(self, mock_config, remote_mcp_base_props):
|
|
351
|
+
"""Test retrieving secrets from unsaved storage when not in config."""
|
|
352
|
+
server = ExternalToolServer(
|
|
353
|
+
name="test-server",
|
|
354
|
+
type=ToolServerType.remote_mcp,
|
|
355
|
+
properties={
|
|
356
|
+
**remote_mcp_base_props,
|
|
357
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
server.id = "server-123"
|
|
361
|
+
server._unsaved_secrets = {
|
|
362
|
+
"Authorization": "Bearer unsaved-token",
|
|
363
|
+
"X-API-Key": "unsaved-api-key",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
# Mock config to return empty
|
|
367
|
+
mock_config.get_value.return_value = {}
|
|
368
|
+
|
|
369
|
+
secrets, missing = server.retrieve_secrets()
|
|
370
|
+
|
|
371
|
+
assert secrets == {
|
|
372
|
+
"Authorization": "Bearer unsaved-token",
|
|
373
|
+
"X-API-Key": "unsaved-api-key",
|
|
374
|
+
}
|
|
375
|
+
assert missing == []
|
|
376
|
+
|
|
377
|
+
def test_retrieve_secrets_config_takes_precedence(
|
|
378
|
+
self, mock_config, remote_mcp_base_props
|
|
379
|
+
):
|
|
380
|
+
"""Test that config secrets take precedence over unsaved secrets."""
|
|
381
|
+
server = ExternalToolServer(
|
|
382
|
+
name="test-server",
|
|
383
|
+
type=ToolServerType.remote_mcp,
|
|
384
|
+
properties={
|
|
385
|
+
**remote_mcp_base_props,
|
|
386
|
+
"secret_header_keys": ["Authorization"],
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
server.id = "server-123"
|
|
390
|
+
server._unsaved_secrets = {"Authorization": "Bearer unsaved-token"}
|
|
391
|
+
|
|
392
|
+
# Mock config to return saved secret
|
|
393
|
+
mock_config.get_value.return_value = {
|
|
394
|
+
"server-123::Authorization": "Bearer config-token"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
secrets, missing = server.retrieve_secrets()
|
|
398
|
+
|
|
399
|
+
assert secrets == {"Authorization": "Bearer config-token"}
|
|
400
|
+
assert missing == []
|
|
401
|
+
|
|
402
|
+
def test_retrieve_secrets_with_missing_values(
|
|
403
|
+
self, mock_config, remote_mcp_base_props
|
|
404
|
+
):
|
|
405
|
+
"""Test retrieving secrets when some are missing."""
|
|
406
|
+
server = ExternalToolServer(
|
|
407
|
+
name="test-server",
|
|
408
|
+
type=ToolServerType.remote_mcp,
|
|
409
|
+
properties={
|
|
410
|
+
**remote_mcp_base_props,
|
|
411
|
+
"secret_header_keys": ["Authorization", "X-API-Key", "Missing-Key"],
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
server.id = "server-123"
|
|
415
|
+
|
|
416
|
+
# Mock config with only partial secrets
|
|
417
|
+
mock_config.get_value.return_value = {
|
|
418
|
+
"server-123::Authorization": "Bearer config-token"
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
secrets, missing = server.retrieve_secrets()
|
|
422
|
+
|
|
423
|
+
assert secrets == {"Authorization": "Bearer config-token"}
|
|
424
|
+
assert set(missing) == {"X-API-Key", "Missing-Key"}
|
|
425
|
+
|
|
426
|
+
def test_retrieve_secrets_no_secret_keys(self, mock_config, remote_mcp_base_props):
|
|
427
|
+
"""Test retrieving secrets when no secret keys are defined."""
|
|
428
|
+
server = ExternalToolServer(
|
|
429
|
+
name="test-server",
|
|
430
|
+
type=ToolServerType.remote_mcp,
|
|
431
|
+
properties=remote_mcp_base_props, # No secret_header_keys
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
secrets, missing = server.retrieve_secrets()
|
|
435
|
+
|
|
436
|
+
assert secrets == {}
|
|
437
|
+
assert missing == []
|
|
438
|
+
|
|
439
|
+
def test_save_secrets(self, mock_config, remote_mcp_base_props):
|
|
440
|
+
"""Test saving unsaved secrets to config."""
|
|
441
|
+
server = ExternalToolServer(
|
|
442
|
+
name="test-server",
|
|
443
|
+
type=ToolServerType.remote_mcp,
|
|
444
|
+
properties={
|
|
445
|
+
**remote_mcp_base_props,
|
|
446
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
server.id = "server-123"
|
|
450
|
+
server._unsaved_secrets = {
|
|
451
|
+
"Authorization": "Bearer token",
|
|
452
|
+
"X-API-Key": "api-key",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# Mock existing config secrets
|
|
456
|
+
existing_secrets = {"other-server::key": "other-value"}
|
|
457
|
+
mock_config.get_value.return_value = existing_secrets
|
|
458
|
+
|
|
459
|
+
server._save_secrets()
|
|
460
|
+
|
|
461
|
+
# Should update config with new secrets
|
|
462
|
+
expected_secrets = {
|
|
463
|
+
"other-server::key": "other-value",
|
|
464
|
+
"server-123::Authorization": "Bearer token",
|
|
465
|
+
"server-123::X-API-Key": "api-key",
|
|
466
|
+
}
|
|
467
|
+
mock_config.update_settings.assert_called_once_with(
|
|
468
|
+
{MCP_SECRETS_KEY: expected_secrets}
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Should clear unsaved secrets
|
|
472
|
+
assert server._unsaved_secrets == {}
|
|
473
|
+
|
|
474
|
+
def test_save_secrets_no_id_error(self, mock_config, remote_mcp_base_props):
|
|
475
|
+
"""Test that saving secrets without ID raises error."""
|
|
476
|
+
server = ExternalToolServer(
|
|
477
|
+
name="test-server",
|
|
478
|
+
type=ToolServerType.remote_mcp,
|
|
479
|
+
properties={
|
|
480
|
+
**remote_mcp_base_props,
|
|
481
|
+
"secret_header_keys": ["Authorization"],
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
# Manually set unsaved secrets to bypass the empty check
|
|
485
|
+
server._unsaved_secrets = {"Authorization": "Bearer token"}
|
|
486
|
+
# Explicitly set ID to None to test the error condition
|
|
487
|
+
server.id = None
|
|
488
|
+
|
|
489
|
+
with pytest.raises(
|
|
490
|
+
ValueError, match="Server ID cannot be None when saving secrets"
|
|
491
|
+
):
|
|
492
|
+
server._save_secrets()
|
|
493
|
+
|
|
494
|
+
def test_save_secrets_with_no_unsaved_secrets(
|
|
495
|
+
self, mock_config, remote_mcp_base_props
|
|
496
|
+
):
|
|
497
|
+
"""Test that saving secrets with no unsaved secrets does nothing."""
|
|
498
|
+
server = ExternalToolServer(
|
|
499
|
+
name="test-server",
|
|
500
|
+
type=ToolServerType.remote_mcp,
|
|
501
|
+
properties={
|
|
502
|
+
**remote_mcp_base_props,
|
|
503
|
+
"secret_header_keys": ["Authorization"],
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
server.id = "server-123"
|
|
507
|
+
|
|
508
|
+
# No _unsaved_secrets set
|
|
509
|
+
|
|
510
|
+
server._save_secrets()
|
|
511
|
+
|
|
512
|
+
# Should not call update_settings
|
|
513
|
+
mock_config.update_settings.assert_not_called()
|
|
514
|
+
|
|
515
|
+
def test_delete_secrets(self, mock_config, remote_mcp_base_props):
|
|
516
|
+
"""Test deleting secrets from config."""
|
|
517
|
+
server = ExternalToolServer(
|
|
518
|
+
name="test-server",
|
|
519
|
+
type=ToolServerType.remote_mcp,
|
|
520
|
+
properties={
|
|
521
|
+
**remote_mcp_base_props,
|
|
522
|
+
"secret_header_keys": ["Authorization", "X-API-Key"],
|
|
523
|
+
},
|
|
524
|
+
)
|
|
525
|
+
server.id = "server-123"
|
|
526
|
+
|
|
527
|
+
# Mock existing config secrets
|
|
528
|
+
existing_secrets = {
|
|
529
|
+
"server-123::Authorization": "Bearer token",
|
|
530
|
+
"server-123::X-API-Key": "api-key",
|
|
531
|
+
"other-server::Authorization": "other-token",
|
|
532
|
+
}
|
|
533
|
+
mock_config.get_value.return_value = existing_secrets
|
|
534
|
+
|
|
535
|
+
server.delete_secrets()
|
|
536
|
+
|
|
537
|
+
# Should remove only this server's secrets
|
|
538
|
+
expected_secrets = {"other-server::Authorization": "other-token"}
|
|
539
|
+
mock_config.update_settings.assert_called_once_with(
|
|
540
|
+
{MCP_SECRETS_KEY: expected_secrets}
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def test_delete_secrets_with_no_existing_secrets(
|
|
544
|
+
self, mock_config, remote_mcp_base_props
|
|
545
|
+
):
|
|
546
|
+
"""Test deleting secrets when none exist in config."""
|
|
547
|
+
server = ExternalToolServer(
|
|
548
|
+
name="test-server",
|
|
549
|
+
type=ToolServerType.remote_mcp,
|
|
550
|
+
properties={
|
|
551
|
+
**remote_mcp_base_props,
|
|
552
|
+
"secret_header_keys": ["Authorization"],
|
|
553
|
+
},
|
|
554
|
+
)
|
|
555
|
+
server.id = "server-123"
|
|
556
|
+
|
|
557
|
+
# Mock empty config
|
|
558
|
+
mock_config.get_value.return_value = {}
|
|
559
|
+
|
|
560
|
+
server.delete_secrets()
|
|
561
|
+
|
|
562
|
+
# Should still call update_settings with empty dict
|
|
563
|
+
mock_config.update_settings.assert_called_once_with({MCP_SECRETS_KEY: {}})
|
|
564
|
+
|
|
565
|
+
def test_save_to_file_saves_secrets_first(self, mock_config, remote_mcp_base_props):
|
|
566
|
+
"""Test that save_to_file automatically saves unsaved secrets first."""
|
|
567
|
+
server = ExternalToolServer(
|
|
568
|
+
name="test-server",
|
|
569
|
+
type=ToolServerType.remote_mcp,
|
|
570
|
+
properties={
|
|
571
|
+
**remote_mcp_base_props,
|
|
572
|
+
"secret_header_keys": ["Authorization"],
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
server.id = "server-123"
|
|
576
|
+
server._unsaved_secrets = {"Authorization": "Bearer token"}
|
|
577
|
+
|
|
578
|
+
mock_config.get_value.return_value = {}
|
|
579
|
+
|
|
580
|
+
with patch(
|
|
581
|
+
"kiln_ai.datamodel.basemodel.KilnParentedModel.save_to_file"
|
|
582
|
+
) as mock_parent_save:
|
|
583
|
+
server.save_to_file()
|
|
584
|
+
|
|
585
|
+
# Should save secrets first
|
|
586
|
+
mock_config.update_settings.assert_called_once()
|
|
587
|
+
assert server._unsaved_secrets == {}
|
|
588
|
+
|
|
589
|
+
# Should call parent save_to_file
|
|
590
|
+
mock_parent_save.assert_called_once()
|
|
591
|
+
|
|
592
|
+
def test_save_to_file_no_unsaved_secrets(self, mock_config, remote_mcp_base_props):
|
|
593
|
+
"""Test save_to_file when no unsaved secrets exist."""
|
|
594
|
+
server = ExternalToolServer(
|
|
595
|
+
name="test-server",
|
|
596
|
+
type=ToolServerType.remote_mcp,
|
|
597
|
+
properties=remote_mcp_base_props,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
with patch(
|
|
601
|
+
"kiln_ai.datamodel.basemodel.KilnParentedModel.save_to_file"
|
|
602
|
+
) as mock_parent_save:
|
|
603
|
+
server.save_to_file()
|
|
604
|
+
|
|
605
|
+
# Should not save secrets
|
|
606
|
+
mock_config.update_settings.assert_not_called()
|
|
607
|
+
|
|
608
|
+
# Should still call parent save_to_file
|
|
609
|
+
mock_parent_save.assert_called_once()
|
|
610
|
+
|
|
611
|
+
def test_config_secret_key_format(self, mock_config, remote_mcp_base_props):
|
|
612
|
+
"""Test the _config_secret_key method formats keys correctly."""
|
|
613
|
+
server = ExternalToolServer(
|
|
614
|
+
name="test-server",
|
|
615
|
+
type=ToolServerType.remote_mcp,
|
|
616
|
+
properties=remote_mcp_base_props,
|
|
617
|
+
)
|
|
618
|
+
server.id = "server-123"
|
|
619
|
+
|
|
620
|
+
assert server._config_secret_key("Authorization") == "server-123::Authorization"
|
|
621
|
+
assert server._config_secret_key("X-API-Key") == "server-123::X-API-Key"
|
|
622
|
+
|
|
623
|
+
def test_model_serialization_excludes_secrets(self, mock_config):
|
|
624
|
+
"""Test that model serialization excludes _unsaved_secrets private attribute and secrets from properties."""
|
|
625
|
+
# Test all server types to ensure we update this test when new types are added
|
|
626
|
+
for server_type in ToolServerType:
|
|
627
|
+
match server_type:
|
|
628
|
+
case ToolServerType.remote_mcp:
|
|
629
|
+
server = ExternalToolServer(
|
|
630
|
+
name="test-remote-server",
|
|
631
|
+
type=server_type,
|
|
632
|
+
properties={
|
|
633
|
+
"server_url": "https://api.example.com/mcp",
|
|
634
|
+
"headers": {"Authorization": "Bearer secret"},
|
|
635
|
+
"secret_header_keys": ["Authorization"],
|
|
636
|
+
},
|
|
637
|
+
)
|
|
638
|
+
data = server.model_dump()
|
|
639
|
+
assert "_unsaved_secrets" not in data
|
|
640
|
+
assert "Authorization" not in data["properties"]["headers"]
|
|
641
|
+
|
|
642
|
+
case ToolServerType.local_mcp:
|
|
643
|
+
server = ExternalToolServer(
|
|
644
|
+
name="test-local-server",
|
|
645
|
+
type=server_type,
|
|
646
|
+
properties={
|
|
647
|
+
"command": "python",
|
|
648
|
+
"args": ["-m", "server"],
|
|
649
|
+
"env_vars": {"API_KEY": "secret"},
|
|
650
|
+
"secret_env_var_keys": ["API_KEY"],
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
data = server.model_dump()
|
|
654
|
+
assert "_unsaved_secrets" not in data
|
|
655
|
+
assert "API_KEY" not in data["properties"]["env_vars"]
|
|
656
|
+
|
|
657
|
+
case _:
|
|
658
|
+
raise_exhaustive_enum_error(server_type)
|
|
659
|
+
|
|
660
|
+
def test_empty_secret_keys_list(self, mock_config, remote_mcp_base_props):
|
|
661
|
+
"""Test behavior with empty secret_header_keys list."""
|
|
662
|
+
properties = {**remote_mcp_base_props, "secret_header_keys": []}
|
|
663
|
+
|
|
664
|
+
server = ExternalToolServer(
|
|
665
|
+
name="test-server", type=ToolServerType.remote_mcp, properties=properties
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
assert server.get_secret_keys() == []
|
|
669
|
+
secrets, missing = server.retrieve_secrets()
|
|
670
|
+
assert secrets == {}
|
|
671
|
+
assert missing == []
|
|
672
|
+
|
|
673
|
+
def test_none_mcp_secrets_in_config(self, mock_config, remote_mcp_base_props):
|
|
674
|
+
"""Test behavior when MCP_SECRETS_KEY returns None from config."""
|
|
675
|
+
server = ExternalToolServer(
|
|
676
|
+
name="test-server",
|
|
677
|
+
type=ToolServerType.remote_mcp,
|
|
678
|
+
properties={
|
|
679
|
+
**remote_mcp_base_props,
|
|
680
|
+
"secret_header_keys": ["Authorization"],
|
|
681
|
+
},
|
|
682
|
+
)
|
|
683
|
+
server.id = "server-123"
|
|
684
|
+
|
|
685
|
+
# Mock config returning None for MCP_SECRETS_KEY
|
|
686
|
+
mock_config.get_value.return_value = None
|
|
687
|
+
|
|
688
|
+
secrets, missing = server.retrieve_secrets()
|
|
689
|
+
|
|
690
|
+
assert secrets == {}
|
|
691
|
+
assert missing == ["Authorization"]
|
|
@@ -121,6 +121,6 @@ def test_benchmark_load_from_file(benchmark, task_run):
|
|
|
121
121
|
|
|
122
122
|
# I get 8k ops per second on my MBP. Lower value here for CI and parallel testing.
|
|
123
123
|
# Prior to optimization was 290 ops per second.
|
|
124
|
-
|
|
124
|
+
# sys.stdout.write(f"Ops per second: {ops_per_second:.6f}")
|
|
125
125
|
if ops_per_second < 500:
|
|
126
126
|
pytest.fail(f"Ops per second: {ops_per_second:.6f}, expected more than 1k ops")
|