cli2api 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {cli2api-0.2.1 → cli2api-0.2.2}/PKG-INFO +1 -1
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/__init__.py +1 -1
- cli2api-0.2.2/cli2api/prompts/tools_instruction.txt +58 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/providers/claude.py +33 -5
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/tools/handler.py +29 -47
- cli2api-0.2.2/cli2api/tools/validator.py +105 -0
- cli2api-0.2.2/cli2api/utils/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/pyproject.toml +1 -1
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_providers.py +173 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_streaming_tool_parser.py +19 -0
- cli2api-0.2.2/tests/test_tool_validator.py +201 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/.dockerignore +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/.env.example +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/.github/workflows/publish.yml +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/.gitignore +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/Dockerfile +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/README.md +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/__main__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/dependencies.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/router.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/utils.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/chat.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/models.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/responses.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/config/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/config/settings.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/constants.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/main.py +0 -0
- {cli2api-0.2.1/cli2api/utils → cli2api-0.2.2/cli2api/prompts}/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/providers/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/internal.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/openai.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/services/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/services/completion.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/sse.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/tool_parser.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/tools/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/utils/cli_detector.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/utils/logging.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/cli2api.sh +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/docker-compose.yaml +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/__init__.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/conftest.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_api.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_cli_detector.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_config.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_integration.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_schemas.py +0 -0
- {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_streaming.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
You have access to the following tools:
|
|
2
|
+
|
|
3
|
+
{tool_definitions}
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
RESPONSE FORMAT:
|
|
7
|
+
|
|
8
|
+
When calling tools, wrap EACH tool call in <tool_call> tags.
|
|
9
|
+
You may include explanatory text before or after the tags.
|
|
10
|
+
|
|
11
|
+
For a SINGLE tool call:
|
|
12
|
+
<tool_call>
|
|
13
|
+
{{"name": "TOOL_NAME", "arguments": {{...}}}}
|
|
14
|
+
</tool_call>
|
|
15
|
+
|
|
16
|
+
For MULTIPLE tool calls, use SEPARATE tags for each:
|
|
17
|
+
<tool_call>
|
|
18
|
+
{{"name": "read_file", "arguments": {{"path": "file1.py"}}}}
|
|
19
|
+
</tool_call>
|
|
20
|
+
<tool_call>
|
|
21
|
+
{{"name": "read_file", "arguments": {{"path": "file2.py"}}}}
|
|
22
|
+
</tool_call>
|
|
23
|
+
|
|
24
|
+
CRITICAL RULES:
|
|
25
|
+
- ALWAYS wrap tool calls in <tool_call>...</tool_call> tags
|
|
26
|
+
- Each tool call must be in its own tag pair
|
|
27
|
+
- ALWAYS include the "arguments" key with ALL required parameters filled in
|
|
28
|
+
- NEVER output {{"name": "tool_name"}} without "arguments" — this is INVALID
|
|
29
|
+
- NEVER output empty arguments {{}} when the tool has required parameters
|
|
30
|
+
- You CAN include text explanation before/after tags
|
|
31
|
+
- Do NOT execute tools yourself — just output the tags
|
|
32
|
+
- If user wants to read a file -> use read_file tool
|
|
33
|
+
- If user wants to list files -> use list_files tool
|
|
34
|
+
|
|
35
|
+
WRONG — missing arguments:
|
|
36
|
+
<tool_call>
|
|
37
|
+
{{"name": "execute_command"}}
|
|
38
|
+
</tool_call>
|
|
39
|
+
|
|
40
|
+
WRONG — empty arguments when tool requires parameters:
|
|
41
|
+
<tool_call>
|
|
42
|
+
{{"name": "execute_command", "arguments": {{}}}}
|
|
43
|
+
</tool_call>
|
|
44
|
+
|
|
45
|
+
CORRECT:
|
|
46
|
+
<tool_call>
|
|
47
|
+
{{"name": "execute_command", "arguments": {{"command": "ls -la"}}}}
|
|
48
|
+
</tool_call>
|
|
49
|
+
|
|
50
|
+
{tool_examples}
|
|
51
|
+
|
|
52
|
+
ATTEMPT_COMPLETION USAGE:
|
|
53
|
+
When using attempt_completion, ALWAYS include the 'result' parameter:
|
|
54
|
+
<tool_call>
|
|
55
|
+
{{"name": "attempt_completion", "arguments": {{"result": "Your response text here"}}}}
|
|
56
|
+
</tool_call>
|
|
57
|
+
- The 'result' parameter is REQUIRED and must contain your final response
|
|
58
|
+
- Use attempt_completion ONLY after you have gathered all needed information
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
import uuid
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, AsyncIterator, Optional
|
|
7
8
|
|
|
9
|
+
from cli2api.constants import ID_HEX_LENGTH, TOOL_CALL_ID_PREFIX
|
|
8
10
|
from cli2api.schemas.internal import ProviderChunk, ProviderResult
|
|
9
11
|
from cli2api.schemas.openai import ChatMessage
|
|
10
12
|
from cli2api.streaming.tool_parser import StreamingToolParser
|
|
11
13
|
from cli2api.tools.handler import ToolHandler
|
|
14
|
+
from cli2api.tools.validator import filter_valid_tool_calls
|
|
12
15
|
from cli2api.utils.logging import get_logger
|
|
13
16
|
|
|
14
17
|
logger = get_logger(__name__)
|
|
@@ -221,7 +224,9 @@ class ClaudeCodeProvider:
|
|
|
221
224
|
) -> tuple[str, Optional[list[dict]]]:
|
|
222
225
|
"""Parse content for tool calls if tools are provided."""
|
|
223
226
|
if tools and content:
|
|
224
|
-
|
|
227
|
+
remaining, tool_calls = ToolHandler.parse_tool_calls(content)
|
|
228
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
229
|
+
return remaining, tool_calls
|
|
225
230
|
return content, None
|
|
226
231
|
|
|
227
232
|
def _create_final_chunk(
|
|
@@ -237,12 +242,27 @@ class ClaudeCodeProvider:
|
|
|
237
242
|
usage=usage,
|
|
238
243
|
)
|
|
239
244
|
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _deserialize_string_values(obj: dict) -> dict:
|
|
247
|
+
"""Deserialize JSON-encoded string values in tool arguments.
|
|
248
|
+
|
|
249
|
+
Claude sometimes returns structured data (arrays, objects) as
|
|
250
|
+
JSON-encoded strings. Kilo Code expects native types, so we
|
|
251
|
+
parse them back. E.g. follow_up: '[{"text":"Yes"}]' -> [{"text":"Yes"}]
|
|
252
|
+
"""
|
|
253
|
+
result = {}
|
|
254
|
+
for key, value in obj.items():
|
|
255
|
+
if isinstance(value, str) and value and value[0] in ('[', '{'):
|
|
256
|
+
try:
|
|
257
|
+
result[key] = json.loads(value)
|
|
258
|
+
except (json.JSONDecodeError, ValueError):
|
|
259
|
+
result[key] = value
|
|
260
|
+
else:
|
|
261
|
+
result[key] = value
|
|
262
|
+
return result
|
|
263
|
+
|
|
240
264
|
def _extract_native_tool_call(self, block: dict) -> Optional[dict]:
|
|
241
265
|
"""Extract tool call from Claude native tool_use block."""
|
|
242
|
-
import json
|
|
243
|
-
import uuid
|
|
244
|
-
from cli2api.constants import ID_HEX_LENGTH, TOOL_CALL_ID_PREFIX
|
|
245
|
-
|
|
246
266
|
tool_name = block.get("name")
|
|
247
267
|
tool_input = block.get("input", {})
|
|
248
268
|
tool_id = block.get("id", f"{TOOL_CALL_ID_PREFIX}{uuid.uuid4().hex[:ID_HEX_LENGTH]}")
|
|
@@ -250,6 +270,9 @@ class ClaudeCodeProvider:
|
|
|
250
270
|
if not tool_name:
|
|
251
271
|
return None
|
|
252
272
|
|
|
273
|
+
if isinstance(tool_input, dict):
|
|
274
|
+
tool_input = self._deserialize_string_values(tool_input)
|
|
275
|
+
|
|
253
276
|
return {
|
|
254
277
|
"id": tool_id,
|
|
255
278
|
"type": "function",
|
|
@@ -391,12 +414,14 @@ class ClaudeCodeProvider:
|
|
|
391
414
|
# Native tool calling (stop_reason=tool_use)
|
|
392
415
|
if stop_reason == "tool_use":
|
|
393
416
|
tool_calls = native_tool_calls if native_tool_calls else None
|
|
417
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
394
418
|
logger.info(f"[STREAM] Native tool_calls: {len(tool_calls) if tool_calls else 0}")
|
|
395
419
|
elif tool_parser:
|
|
396
420
|
final_result = tool_parser.finalize()
|
|
397
421
|
if final_result.text:
|
|
398
422
|
yield ProviderChunk(content=final_result.text)
|
|
399
423
|
tool_calls = tool_parser.get_all_tool_calls()
|
|
424
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
400
425
|
else:
|
|
401
426
|
_, tool_calls = self._handle_tool_calls_in_content(
|
|
402
427
|
accumulated_content, tools
|
|
@@ -433,6 +458,7 @@ class ClaudeCodeProvider:
|
|
|
433
458
|
if final_result.text:
|
|
434
459
|
yield ProviderChunk(content=final_result.text)
|
|
435
460
|
tool_calls = tool_parser.get_all_tool_calls()
|
|
461
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
436
462
|
else:
|
|
437
463
|
_, tool_calls = self._handle_tool_calls_in_content(
|
|
438
464
|
accumulated_content, tools
|
|
@@ -476,6 +502,7 @@ class ClaudeCodeProvider:
|
|
|
476
502
|
if final_result.text:
|
|
477
503
|
yield ProviderChunk(content=final_result.text)
|
|
478
504
|
tool_calls = tool_parser.get_all_tool_calls()
|
|
505
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
479
506
|
else:
|
|
480
507
|
_, tool_calls = self._handle_tool_calls_in_content(
|
|
481
508
|
accumulated_content, tools
|
|
@@ -489,6 +516,7 @@ class ClaudeCodeProvider:
|
|
|
489
516
|
if final_result.text:
|
|
490
517
|
yield ProviderChunk(content=final_result.text)
|
|
491
518
|
tool_calls = tool_parser.get_all_tool_calls()
|
|
519
|
+
tool_calls = filter_valid_tool_calls(tool_calls, tools)
|
|
492
520
|
else:
|
|
493
521
|
_, tool_calls = self._handle_tool_calls_in_content(accumulated_content, tools)
|
|
494
522
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
5
|
import uuid
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
6
8
|
from typing import Any, Optional
|
|
7
9
|
|
|
8
10
|
from cli2api.constants import (
|
|
@@ -14,6 +16,13 @@ from cli2api.constants import (
|
|
|
14
16
|
from cli2api.schemas.openai import ChatMessage
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
@lru_cache(maxsize=4)
|
|
20
|
+
def _load_prompt_template(filename: str) -> str:
|
|
21
|
+
"""Load prompt template from cli2api/prompts/ directory."""
|
|
22
|
+
path = Path(__file__).parent.parent / "prompts" / filename
|
|
23
|
+
return path.read_text()
|
|
24
|
+
|
|
25
|
+
|
|
17
26
|
# ==================== Message Helpers ====================
|
|
18
27
|
|
|
19
28
|
|
|
@@ -86,9 +95,8 @@ class ToolHandler:
|
|
|
86
95
|
key=lambda t: t.get("function", {}).get("name", "")
|
|
87
96
|
)
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
]
|
|
98
|
+
tool_definitions = []
|
|
99
|
+
tool_examples = []
|
|
92
100
|
|
|
93
101
|
for tool in tools:
|
|
94
102
|
if tool.get("type") != "function":
|
|
@@ -98,14 +106,10 @@ class ToolHandler:
|
|
|
98
106
|
name = func.get("name", "unknown")
|
|
99
107
|
description = func.get("description", "No description")
|
|
100
108
|
params = func.get("parameters", {})
|
|
101
|
-
|
|
102
|
-
lines.append(f"## {name}")
|
|
103
|
-
lines.append(f"{description}\n")
|
|
104
|
-
|
|
105
|
-
# Format parameters
|
|
106
109
|
properties = params.get("properties", {})
|
|
107
110
|
required = params.get("required", [])
|
|
108
111
|
|
|
112
|
+
lines = [f"## {name}", f"{description}\n"]
|
|
109
113
|
if properties:
|
|
110
114
|
lines.append("Parameters:")
|
|
111
115
|
for pname, pinfo in properties.items():
|
|
@@ -114,45 +118,23 @@ class ToolHandler:
|
|
|
114
118
|
req = " (required)" if pname in required else " (optional)"
|
|
115
119
|
lines.append(f"- {pname} ({ptype}{req}): {pdesc}")
|
|
116
120
|
lines.append("")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
lines.append('{"name": "read_file", "arguments": {"path": "file2.py"}}')
|
|
135
|
-
lines.append("</tool_call>")
|
|
136
|
-
lines.append("")
|
|
137
|
-
lines.append("CRITICAL RULES:")
|
|
138
|
-
lines.append("- ALWAYS wrap tool calls in <tool_call>...</tool_call> tags")
|
|
139
|
-
lines.append("- Each tool call must be in its own tag pair")
|
|
140
|
-
lines.append("- When reading multiple files, include multiple <tool_call> tags")
|
|
141
|
-
lines.append("- You CAN include text explanation before/after tags")
|
|
142
|
-
lines.append("- Do NOT execute tools yourself - just output the tags")
|
|
143
|
-
lines.append("- If user wants to read a file -> use read_file tool")
|
|
144
|
-
lines.append("- If user wants to list files -> use list_files tool")
|
|
145
|
-
lines.append("- ALWAYS include ALL required parameters for each tool")
|
|
146
|
-
lines.append("")
|
|
147
|
-
lines.append("ATTEMPT_COMPLETION USAGE:")
|
|
148
|
-
lines.append("When using attempt_completion, ALWAYS include the 'result' parameter:")
|
|
149
|
-
lines.append("<tool_call>")
|
|
150
|
-
lines.append('{"name": "attempt_completion", "arguments": {"result": "Your response text here"}}')
|
|
151
|
-
lines.append("</tool_call>")
|
|
152
|
-
lines.append("- The 'result' parameter is REQUIRED and must contain your final response")
|
|
153
|
-
lines.append("- Use attempt_completion ONLY after you have gathered all needed information")
|
|
154
|
-
|
|
155
|
-
return "\n".join(lines)
|
|
121
|
+
tool_definitions.append("\n".join(lines))
|
|
122
|
+
|
|
123
|
+
# Generate example call for tools with required parameters
|
|
124
|
+
if required:
|
|
125
|
+
example_args = {p: "<value>" for p in required}
|
|
126
|
+
example_json = json.dumps({"name": name, "arguments": example_args})
|
|
127
|
+
tool_examples.append(f"<tool_call>\n{example_json}\n</tool_call>")
|
|
128
|
+
|
|
129
|
+
template = _load_prompt_template("tools_instruction.txt")
|
|
130
|
+
examples_section = ""
|
|
131
|
+
if tool_examples:
|
|
132
|
+
examples_section = "Required parameter examples:\n" + "\n".join(tool_examples)
|
|
133
|
+
|
|
134
|
+
return template.format(
|
|
135
|
+
tool_definitions="\n".join(tool_definitions),
|
|
136
|
+
tool_examples=examples_section,
|
|
137
|
+
)
|
|
156
138
|
|
|
157
139
|
@staticmethod
|
|
158
140
|
def generate_tool_call_id() -> str:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tool call argument validation against OpenAI tool schemas."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from cli2api.utils.logging import get_logger
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_required_params_index(tools: list[dict]) -> dict[str, list[str]]:
|
|
12
|
+
"""Build a lookup: tool_name -> list of required parameter names.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
tools: OpenAI-format tool definitions.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dict mapping tool name to list of required param names.
|
|
19
|
+
"""
|
|
20
|
+
index = {}
|
|
21
|
+
for tool in tools:
|
|
22
|
+
if tool.get("type") != "function":
|
|
23
|
+
continue
|
|
24
|
+
func = tool.get("function", {})
|
|
25
|
+
name = func.get("name")
|
|
26
|
+
if not name:
|
|
27
|
+
continue
|
|
28
|
+
params = func.get("parameters", {})
|
|
29
|
+
index[name] = params.get("required", [])
|
|
30
|
+
return index
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_tool_call(
|
|
34
|
+
tool_call: dict,
|
|
35
|
+
required_params_index: dict[str, list[str]],
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Validate a single tool call has all required arguments.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
tool_call: Parsed tool call in OpenAI format.
|
|
41
|
+
required_params_index: From build_required_params_index().
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if valid (all required params present), False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
func = tool_call.get("function", {})
|
|
47
|
+
name = func.get("name", "")
|
|
48
|
+
arguments_str = func.get("arguments", "{}")
|
|
49
|
+
|
|
50
|
+
required = required_params_index.get(name)
|
|
51
|
+
if required is None:
|
|
52
|
+
# Unknown tool — can't validate, let it pass
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
if not required:
|
|
56
|
+
# Tool has no required params — always valid
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
arguments = json.loads(arguments_str)
|
|
61
|
+
except (json.JSONDecodeError, TypeError):
|
|
62
|
+
logger.warning(f"Tool call '{name}' has unparseable arguments: {arguments_str!r}")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
if not isinstance(arguments, dict):
|
|
66
|
+
logger.warning(f"Tool call '{name}' arguments is not a dict: {type(arguments)}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
missing = [p for p in required if p not in arguments]
|
|
70
|
+
if missing:
|
|
71
|
+
logger.warning(
|
|
72
|
+
f"Tool call '{name}' missing required params: {missing}. "
|
|
73
|
+
f"Got: {list(arguments.keys())}"
|
|
74
|
+
)
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def filter_valid_tool_calls(
|
|
81
|
+
tool_calls: Optional[list[dict]],
|
|
82
|
+
tools: Optional[list[dict]],
|
|
83
|
+
) -> Optional[list[dict]]:
|
|
84
|
+
"""Filter tool calls, removing those with missing required params.
|
|
85
|
+
|
|
86
|
+
Invalid tool calls are dropped with a WARNING log.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
tool_calls: Parsed tool calls (may be None).
|
|
90
|
+
tools: Original tool definitions (may be None).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Filtered list, or None if empty/input was None.
|
|
94
|
+
"""
|
|
95
|
+
if not tool_calls or not tools:
|
|
96
|
+
return tool_calls
|
|
97
|
+
|
|
98
|
+
index = build_required_params_index(tools)
|
|
99
|
+
valid = [tc for tc in tool_calls if validate_tool_call(tc, index)]
|
|
100
|
+
|
|
101
|
+
if len(valid) < len(tool_calls):
|
|
102
|
+
dropped = len(tool_calls) - len(valid)
|
|
103
|
+
logger.warning(f"Dropped {dropped} invalid tool call(s) with missing required params")
|
|
104
|
+
|
|
105
|
+
return valid if valid else None
|
|
File without changes
|
|
@@ -195,3 +195,176 @@ class TestMockProvider:
|
|
|
195
195
|
|
|
196
196
|
content = "".join(c.content for c in chunks if c.content)
|
|
197
197
|
assert content == "ABC"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestDeserializeStringValues:
|
|
201
|
+
"""Tests for _deserialize_string_values in ClaudeCodeProvider."""
|
|
202
|
+
|
|
203
|
+
@pytest.fixture
|
|
204
|
+
def provider(self):
|
|
205
|
+
return ClaudeCodeProvider(
|
|
206
|
+
executable_path=Path("/usr/bin/claude"),
|
|
207
|
+
default_timeout=300,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def test_json_array_string_is_deserialized(self, provider):
|
|
211
|
+
"""JSON array encoded as string should be parsed to native array."""
|
|
212
|
+
obj = {"follow_up": '[{"text":"Yes","mode":null}]'}
|
|
213
|
+
result = provider._deserialize_string_values(obj)
|
|
214
|
+
assert isinstance(result["follow_up"], list)
|
|
215
|
+
assert result["follow_up"][0]["text"] == "Yes"
|
|
216
|
+
|
|
217
|
+
def test_json_object_string_is_deserialized(self, provider):
|
|
218
|
+
"""JSON object encoded as string should be parsed to native dict."""
|
|
219
|
+
obj = {"data": '{"key": "value"}'}
|
|
220
|
+
result = provider._deserialize_string_values(obj)
|
|
221
|
+
assert isinstance(result["data"], dict)
|
|
222
|
+
assert result["data"]["key"] == "value"
|
|
223
|
+
|
|
224
|
+
def test_plain_string_is_unchanged(self, provider):
|
|
225
|
+
"""Non-JSON strings should pass through unchanged."""
|
|
226
|
+
obj = {"question": "What do you want?", "command": "ls -la"}
|
|
227
|
+
result = provider._deserialize_string_values(obj)
|
|
228
|
+
assert result["question"] == "What do you want?"
|
|
229
|
+
assert result["command"] == "ls -la"
|
|
230
|
+
|
|
231
|
+
def test_non_string_values_are_unchanged(self, provider):
|
|
232
|
+
"""Non-string values (int, bool, list, dict) should pass through."""
|
|
233
|
+
obj = {"count": 5, "active": True, "items": [1, 2], "meta": {"k": "v"}}
|
|
234
|
+
result = provider._deserialize_string_values(obj)
|
|
235
|
+
assert result == obj
|
|
236
|
+
|
|
237
|
+
def test_invalid_json_starting_with_bracket_is_unchanged(self, provider):
|
|
238
|
+
"""Strings starting with [ or { but not valid JSON should pass through."""
|
|
239
|
+
obj = {"note": "[not valid json", "other": "{also not valid"}
|
|
240
|
+
result = provider._deserialize_string_values(obj)
|
|
241
|
+
assert result["note"] == "[not valid json"
|
|
242
|
+
assert result["other"] == "{also not valid"
|
|
243
|
+
|
|
244
|
+
def test_empty_string_is_unchanged(self, provider):
|
|
245
|
+
"""Empty strings should pass through."""
|
|
246
|
+
obj = {"empty": ""}
|
|
247
|
+
result = provider._deserialize_string_values(obj)
|
|
248
|
+
assert result["empty"] == ""
|
|
249
|
+
|
|
250
|
+
def test_real_kilo_code_follow_up(self, provider):
|
|
251
|
+
"""Reproduce exact Kilo Code ask_followup_question scenario."""
|
|
252
|
+
obj = {
|
|
253
|
+
"question": "Согласны с планом?",
|
|
254
|
+
"follow_up": '[{"text":"Y — Да","mode":null},{"text":"N — Нет","mode":null}]',
|
|
255
|
+
}
|
|
256
|
+
result = provider._deserialize_string_values(obj)
|
|
257
|
+
assert isinstance(result["follow_up"], list)
|
|
258
|
+
assert len(result["follow_up"]) == 2
|
|
259
|
+
assert result["follow_up"][0]["text"] == "Y — Да"
|
|
260
|
+
assert result["question"] == "Согласны с планом?"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TestExtractNativeToolCall:
|
|
264
|
+
"""Tests for _extract_native_tool_call — the full path that caused 'o.map is not a function'.
|
|
265
|
+
|
|
266
|
+
Claude Opus returns native tool_use blocks. When tool arguments contain
|
|
267
|
+
JSON-encoded strings (e.g. follow_up as "[{...}]"), Kilo Code receives
|
|
268
|
+
a string instead of an array and crashes on .map().
|
|
269
|
+
|
|
270
|
+
These tests verify that _extract_native_tool_call deserializes such
|
|
271
|
+
strings so the final 'arguments' JSON contains native arrays/objects.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
@pytest.fixture
|
|
275
|
+
def provider(self):
|
|
276
|
+
return ClaudeCodeProvider(
|
|
277
|
+
executable_path=Path("/usr/bin/claude"),
|
|
278
|
+
default_timeout=300,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def test_ask_followup_question_follow_up_is_array(self, provider):
|
|
282
|
+
"""Exact reproduction of the o.map crash: follow_up must be array, not string."""
|
|
283
|
+
block = {
|
|
284
|
+
"type": "tool_use",
|
|
285
|
+
"id": "toolu_01ABC",
|
|
286
|
+
"name": "ask_followup_question",
|
|
287
|
+
"input": {
|
|
288
|
+
"question": "Согласны с включением шагов 2.4 и 2.5?",
|
|
289
|
+
"follow_up": '[{"text":"Y — Да, включить","mode":null},{"text":"N — Нужно обсудить","mode":null}]',
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
result = provider._extract_native_tool_call(block)
|
|
294
|
+
|
|
295
|
+
assert result is not None
|
|
296
|
+
assert result["function"]["name"] == "ask_followup_question"
|
|
297
|
+
assert result["id"] == "toolu_01ABC"
|
|
298
|
+
|
|
299
|
+
# Parse the arguments JSON that Kilo Code will receive
|
|
300
|
+
args = json.loads(result["function"]["arguments"])
|
|
301
|
+
assert args["question"] == "Согласны с включением шагов 2.4 и 2.5?"
|
|
302
|
+
|
|
303
|
+
# THIS is the critical assertion — follow_up must be a list, not a string
|
|
304
|
+
assert isinstance(args["follow_up"], list), (
|
|
305
|
+
f"follow_up should be list but got {type(args['follow_up']).__name__}: "
|
|
306
|
+
f"{args['follow_up']!r}"
|
|
307
|
+
)
|
|
308
|
+
assert len(args["follow_up"]) == 2
|
|
309
|
+
assert args["follow_up"][0]["text"] == "Y — Да, включить"
|
|
310
|
+
|
|
311
|
+
def test_simple_tool_call_unchanged(self, provider):
|
|
312
|
+
"""Tool calls with plain string arguments should not be affected."""
|
|
313
|
+
block = {
|
|
314
|
+
"type": "tool_use",
|
|
315
|
+
"id": "toolu_02DEF",
|
|
316
|
+
"name": "execute_command",
|
|
317
|
+
"input": {"command": "ls -la"},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
result = provider._extract_native_tool_call(block)
|
|
321
|
+
args = json.loads(result["function"]["arguments"])
|
|
322
|
+
|
|
323
|
+
assert args["command"] == "ls -la"
|
|
324
|
+
assert isinstance(args["command"], str)
|
|
325
|
+
|
|
326
|
+
def test_tool_call_with_nested_json_object_string(self, provider):
|
|
327
|
+
"""JSON object encoded as string in arguments should be deserialized."""
|
|
328
|
+
block = {
|
|
329
|
+
"type": "tool_use",
|
|
330
|
+
"id": "toolu_03GHI",
|
|
331
|
+
"name": "some_tool",
|
|
332
|
+
"input": {
|
|
333
|
+
"config": '{"key": "value", "nested": true}',
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
result = provider._extract_native_tool_call(block)
|
|
338
|
+
args = json.loads(result["function"]["arguments"])
|
|
339
|
+
|
|
340
|
+
assert isinstance(args["config"], dict)
|
|
341
|
+
assert args["config"]["key"] == "value"
|
|
342
|
+
|
|
343
|
+
def test_missing_tool_name_returns_none(self, provider):
|
|
344
|
+
"""Block without tool name should return None."""
|
|
345
|
+
block = {"type": "tool_use", "input": {"x": 1}}
|
|
346
|
+
|
|
347
|
+
assert provider._extract_native_tool_call(block) is None
|
|
348
|
+
|
|
349
|
+
def test_empty_input_returns_empty_args(self, provider):
|
|
350
|
+
"""Block with empty input should produce empty arguments."""
|
|
351
|
+
block = {
|
|
352
|
+
"type": "tool_use",
|
|
353
|
+
"name": "list_files",
|
|
354
|
+
"input": {},
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
result = provider._extract_native_tool_call(block)
|
|
358
|
+
args = json.loads(result["function"]["arguments"])
|
|
359
|
+
assert args == {}
|
|
360
|
+
|
|
361
|
+
def test_generates_id_when_missing(self, provider):
|
|
362
|
+
"""Should generate a call_ prefixed ID when block has no id."""
|
|
363
|
+
block = {
|
|
364
|
+
"type": "tool_use",
|
|
365
|
+
"name": "test_tool",
|
|
366
|
+
"input": {},
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
result = provider._extract_native_tool_call(block)
|
|
370
|
+
assert result["id"].startswith("call_")
|
|
@@ -298,3 +298,22 @@ class TestParseResultDataclass:
|
|
|
298
298
|
result = ParseResult(text="hello", tool_calls=[{"id": "test"}])
|
|
299
299
|
assert result.text == "hello"
|
|
300
300
|
assert result.tool_calls == [{"id": "test"}]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestToolCallEdgeCases:
|
|
304
|
+
"""Tests for tool call edge cases (missing arguments, etc)."""
|
|
305
|
+
|
|
306
|
+
def test_tool_call_without_arguments_key(self):
|
|
307
|
+
"""Tool call JSON without 'arguments' key produces empty args dict.
|
|
308
|
+
|
|
309
|
+
This documents current parser behavior — validation happens downstream.
|
|
310
|
+
"""
|
|
311
|
+
parser = StreamingToolParser()
|
|
312
|
+
|
|
313
|
+
text = '<tool_call>{"name": "execute_command"}</tool_call>'
|
|
314
|
+
result = parser.feed(text)
|
|
315
|
+
|
|
316
|
+
assert len(result.tool_calls) == 1
|
|
317
|
+
assert result.tool_calls[0]["function"]["name"] == "execute_command"
|
|
318
|
+
args = json.loads(result.tool_calls[0]["function"]["arguments"])
|
|
319
|
+
assert args == {}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tests for tool call argument validation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from cli2api.tools.validator import (
|
|
8
|
+
build_required_params_index,
|
|
9
|
+
filter_valid_tool_calls,
|
|
10
|
+
validate_tool_call,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# === Fixtures ===
|
|
15
|
+
|
|
16
|
+
SAMPLE_TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
"type": "function",
|
|
19
|
+
"function": {
|
|
20
|
+
"name": "execute_command",
|
|
21
|
+
"description": "Execute a shell command",
|
|
22
|
+
"parameters": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"command": {"type": "string", "description": "The command"},
|
|
26
|
+
},
|
|
27
|
+
"required": ["command"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {
|
|
34
|
+
"name": "read_file",
|
|
35
|
+
"description": "Read a file",
|
|
36
|
+
"parameters": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"path": {"type": "string", "description": "File path"},
|
|
40
|
+
"encoding": {"type": "string", "description": "Encoding"},
|
|
41
|
+
},
|
|
42
|
+
"required": ["path"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "function",
|
|
48
|
+
"function": {
|
|
49
|
+
"name": "attempt_completion",
|
|
50
|
+
"description": "Complete the task",
|
|
51
|
+
"parameters": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"result": {"type": "string", "description": "Result text"},
|
|
55
|
+
},
|
|
56
|
+
"required": ["result"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "function",
|
|
62
|
+
"function": {
|
|
63
|
+
"name": "list_files",
|
|
64
|
+
"description": "List directory contents",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"path": {"type": "string", "description": "Directory path"},
|
|
69
|
+
},
|
|
70
|
+
"required": [],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_tool_call(name: str, arguments: dict) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"id": "call_test123",
|
|
80
|
+
"type": "function",
|
|
81
|
+
"function": {
|
|
82
|
+
"name": name,
|
|
83
|
+
"arguments": json.dumps(arguments),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _make_tool_call_raw(name: str, arguments_str: str) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"id": "call_test123",
|
|
91
|
+
"type": "function",
|
|
92
|
+
"function": {
|
|
93
|
+
"name": name,
|
|
94
|
+
"arguments": arguments_str,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# === Tests for build_required_params_index ===
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestBuildRequiredParamsIndex:
|
|
103
|
+
def test_builds_index(self):
|
|
104
|
+
index = build_required_params_index(SAMPLE_TOOLS)
|
|
105
|
+
assert index["execute_command"] == ["command"]
|
|
106
|
+
assert index["read_file"] == ["path"]
|
|
107
|
+
assert index["attempt_completion"] == ["result"]
|
|
108
|
+
assert index["list_files"] == []
|
|
109
|
+
|
|
110
|
+
def test_empty_tools(self):
|
|
111
|
+
assert build_required_params_index([]) == {}
|
|
112
|
+
|
|
113
|
+
def test_skips_non_function_tools(self):
|
|
114
|
+
tools = [{"type": "code_interpreter"}]
|
|
115
|
+
assert build_required_params_index(tools) == {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# === Tests for validate_tool_call ===
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestValidateToolCall:
|
|
122
|
+
def setup_method(self):
|
|
123
|
+
self.index = build_required_params_index(SAMPLE_TOOLS)
|
|
124
|
+
|
|
125
|
+
def test_valid_tool_call_passes(self):
|
|
126
|
+
tc = _make_tool_call("execute_command", {"command": "ls -la"})
|
|
127
|
+
assert validate_tool_call(tc, self.index) is True
|
|
128
|
+
|
|
129
|
+
def test_valid_with_extra_params(self):
|
|
130
|
+
tc = _make_tool_call("read_file", {"path": "test.py", "encoding": "utf-8"})
|
|
131
|
+
assert validate_tool_call(tc, self.index) is True
|
|
132
|
+
|
|
133
|
+
def test_missing_required_param_fails(self):
|
|
134
|
+
tc = _make_tool_call("execute_command", {})
|
|
135
|
+
assert validate_tool_call(tc, self.index) is False
|
|
136
|
+
|
|
137
|
+
def test_empty_arguments_with_required_params_fails(self):
|
|
138
|
+
tc = _make_tool_call_raw("execute_command", "{}")
|
|
139
|
+
assert validate_tool_call(tc, self.index) is False
|
|
140
|
+
|
|
141
|
+
def test_no_required_params_always_passes(self):
|
|
142
|
+
tc = _make_tool_call("list_files", {})
|
|
143
|
+
assert validate_tool_call(tc, self.index) is True
|
|
144
|
+
|
|
145
|
+
def test_unknown_tool_passes(self):
|
|
146
|
+
tc = _make_tool_call("unknown_tool", {})
|
|
147
|
+
assert validate_tool_call(tc, self.index) is True
|
|
148
|
+
|
|
149
|
+
def test_unparseable_arguments_fails(self):
|
|
150
|
+
tc = _make_tool_call_raw("execute_command", "not-json")
|
|
151
|
+
assert validate_tool_call(tc, self.index) is False
|
|
152
|
+
|
|
153
|
+
def test_non_dict_arguments_fails(self):
|
|
154
|
+
tc = _make_tool_call_raw("execute_command", '"just a string"')
|
|
155
|
+
assert validate_tool_call(tc, self.index) is False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# === Tests for filter_valid_tool_calls ===
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestFilterValidToolCalls:
|
|
162
|
+
def test_all_valid_passes_through(self):
|
|
163
|
+
tool_calls = [
|
|
164
|
+
_make_tool_call("execute_command", {"command": "ls"}),
|
|
165
|
+
_make_tool_call("read_file", {"path": "test.py"}),
|
|
166
|
+
]
|
|
167
|
+
result = filter_valid_tool_calls(tool_calls, SAMPLE_TOOLS)
|
|
168
|
+
assert len(result) == 2
|
|
169
|
+
|
|
170
|
+
def test_filters_invalid_keeps_valid(self):
|
|
171
|
+
tool_calls = [
|
|
172
|
+
_make_tool_call("execute_command", {"command": "ls"}), # valid
|
|
173
|
+
_make_tool_call("read_file", {}), # invalid — missing path
|
|
174
|
+
]
|
|
175
|
+
result = filter_valid_tool_calls(tool_calls, SAMPLE_TOOLS)
|
|
176
|
+
assert len(result) == 1
|
|
177
|
+
assert result[0]["function"]["name"] == "execute_command"
|
|
178
|
+
|
|
179
|
+
def test_all_invalid_returns_none(self):
|
|
180
|
+
tool_calls = [
|
|
181
|
+
_make_tool_call("execute_command", {}),
|
|
182
|
+
_make_tool_call("read_file", {}),
|
|
183
|
+
]
|
|
184
|
+
result = filter_valid_tool_calls(tool_calls, SAMPLE_TOOLS)
|
|
185
|
+
assert result is None
|
|
186
|
+
|
|
187
|
+
def test_none_tool_calls_returns_none(self):
|
|
188
|
+
assert filter_valid_tool_calls(None, SAMPLE_TOOLS) is None
|
|
189
|
+
|
|
190
|
+
def test_empty_tool_calls_returns_empty(self):
|
|
191
|
+
assert filter_valid_tool_calls([], SAMPLE_TOOLS) == []
|
|
192
|
+
|
|
193
|
+
def test_none_tools_returns_tool_calls_unchanged(self):
|
|
194
|
+
tool_calls = [_make_tool_call("execute_command", {})]
|
|
195
|
+
result = filter_valid_tool_calls(tool_calls, None)
|
|
196
|
+
assert result == tool_calls
|
|
197
|
+
|
|
198
|
+
def test_no_required_params_tool_always_passes(self):
|
|
199
|
+
tool_calls = [_make_tool_call("list_files", {})]
|
|
200
|
+
result = filter_valid_tool_calls(tool_calls, SAMPLE_TOOLS)
|
|
201
|
+
assert len(result) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|