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.
Files changed (53) hide show
  1. {cli2api-0.2.1 → cli2api-0.2.2}/PKG-INFO +1 -1
  2. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/__init__.py +1 -1
  3. cli2api-0.2.2/cli2api/prompts/tools_instruction.txt +58 -0
  4. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/providers/claude.py +33 -5
  5. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/tools/handler.py +29 -47
  6. cli2api-0.2.2/cli2api/tools/validator.py +105 -0
  7. cli2api-0.2.2/cli2api/utils/__init__.py +0 -0
  8. {cli2api-0.2.1 → cli2api-0.2.2}/pyproject.toml +1 -1
  9. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_providers.py +173 -0
  10. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_streaming_tool_parser.py +19 -0
  11. cli2api-0.2.2/tests/test_tool_validator.py +201 -0
  12. {cli2api-0.2.1 → cli2api-0.2.2}/.dockerignore +0 -0
  13. {cli2api-0.2.1 → cli2api-0.2.2}/.env.example +0 -0
  14. {cli2api-0.2.1 → cli2api-0.2.2}/.github/workflows/publish.yml +0 -0
  15. {cli2api-0.2.1 → cli2api-0.2.2}/.gitignore +0 -0
  16. {cli2api-0.2.1 → cli2api-0.2.2}/Dockerfile +0 -0
  17. {cli2api-0.2.1 → cli2api-0.2.2}/README.md +0 -0
  18. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/__main__.py +0 -0
  19. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/__init__.py +0 -0
  20. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/dependencies.py +0 -0
  21. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/router.py +0 -0
  22. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/utils.py +0 -0
  23. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/__init__.py +0 -0
  24. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/chat.py +0 -0
  25. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/models.py +0 -0
  26. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/api/v1/responses.py +0 -0
  27. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/config/__init__.py +0 -0
  28. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/config/settings.py +0 -0
  29. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/constants.py +0 -0
  30. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/main.py +0 -0
  31. {cli2api-0.2.1/cli2api/utils → cli2api-0.2.2/cli2api/prompts}/__init__.py +0 -0
  32. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/providers/__init__.py +0 -0
  33. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/__init__.py +0 -0
  34. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/internal.py +0 -0
  35. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/schemas/openai.py +0 -0
  36. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/services/__init__.py +0 -0
  37. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/services/completion.py +0 -0
  38. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/__init__.py +0 -0
  39. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/sse.py +0 -0
  40. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/streaming/tool_parser.py +0 -0
  41. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/tools/__init__.py +0 -0
  42. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/utils/cli_detector.py +0 -0
  43. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api/utils/logging.py +0 -0
  44. {cli2api-0.2.1 → cli2api-0.2.2}/cli2api.sh +0 -0
  45. {cli2api-0.2.1 → cli2api-0.2.2}/docker-compose.yaml +0 -0
  46. {cli2api-0.2.1 → cli2api-0.2.2}/tests/__init__.py +0 -0
  47. {cli2api-0.2.1 → cli2api-0.2.2}/tests/conftest.py +0 -0
  48. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_api.py +0 -0
  49. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_cli_detector.py +0 -0
  50. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_config.py +0 -0
  51. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_integration.py +0 -0
  52. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_schemas.py +0 -0
  53. {cli2api-0.2.1 → cli2api-0.2.2}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli2api
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: OpenAI-compatible API over Claude Code CLI
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.115.0
@@ -1,3 +1,3 @@
1
1
  """CLI2API - OpenAI-compatible API over CLI tools."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.2"
@@ -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
- return ToolHandler.parse_tool_calls(content)
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
- lines = [
90
- "You have access to the following tools:\n",
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
- lines.append("\n---")
119
- lines.append("RESPONSE FORMAT:")
120
- lines.append("")
121
- lines.append("When calling tools, wrap EACH tool call in <tool_call> tags.")
122
- lines.append("You may include explanatory text before or after the tags.")
123
- lines.append("")
124
- lines.append("For a SINGLE tool call:")
125
- lines.append("<tool_call>")
126
- lines.append('{"name": "TOOL_NAME", "arguments": {...}}')
127
- lines.append("</tool_call>")
128
- lines.append("")
129
- lines.append("For MULTIPLE tool calls, use SEPARATE tags for each:")
130
- lines.append("<tool_call>")
131
- lines.append('{"name": "read_file", "arguments": {"path": "file1.py"}}')
132
- lines.append("</tool_call>")
133
- lines.append("<tool_call>")
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cli2api"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "OpenAI-compatible API over Claude Code CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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