cli2api 0.2.2__tar.gz → 0.2.3__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.2 → cli2api-0.2.3}/PKG-INFO +1 -1
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/prompts/tools_instruction.txt +11 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/providers/claude.py +6 -1
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/validator.py +55 -2
- {cli2api-0.2.2 → cli2api-0.2.3}/pyproject.toml +1 -1
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_providers.py +21 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_tool_validator.py +58 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/.dockerignore +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/.env.example +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/.github/workflows/publish.yml +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/.gitignore +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/Dockerfile +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/README.md +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/__main__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/dependencies.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/router.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/utils.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/chat.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/models.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/responses.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/config/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/config/settings.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/constants.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/main.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/prompts/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/providers/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/internal.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/openai.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/services/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/services/completion.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/sse.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/tool_parser.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/handler.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/cli_detector.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/logging.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/cli2api.sh +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/docker-compose.yaml +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/__init__.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/conftest.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_api.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_cli_detector.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_config.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_integration.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_schemas.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_streaming.py +0 -0
- {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_streaming_tool_parser.py +0 -0
|
@@ -27,6 +27,7 @@ CRITICAL RULES:
|
|
|
27
27
|
- ALWAYS include the "arguments" key with ALL required parameters filled in
|
|
28
28
|
- NEVER output {{"name": "tool_name"}} without "arguments" — this is INVALID
|
|
29
29
|
- NEVER output empty arguments {{}} when the tool has required parameters
|
|
30
|
+
- For optional parameters you want to omit, use JSON null (not the string "null")
|
|
30
31
|
- You CAN include text explanation before/after tags
|
|
31
32
|
- Do NOT execute tools yourself — just output the tags
|
|
32
33
|
- If user wants to read a file -> use read_file tool
|
|
@@ -42,11 +43,21 @@ WRONG — empty arguments when tool requires parameters:
|
|
|
42
43
|
{{"name": "execute_command", "arguments": {{}}}}
|
|
43
44
|
</tool_call>
|
|
44
45
|
|
|
46
|
+
WRONG — string "null" instead of JSON null for optional params:
|
|
47
|
+
<tool_call>
|
|
48
|
+
{{"name": "execute_command", "arguments": {{"command": "ls -la", "cwd": "null"}}}}
|
|
49
|
+
</tool_call>
|
|
50
|
+
|
|
45
51
|
CORRECT:
|
|
46
52
|
<tool_call>
|
|
47
53
|
{{"name": "execute_command", "arguments": {{"command": "ls -la"}}}}
|
|
48
54
|
</tool_call>
|
|
49
55
|
|
|
56
|
+
CORRECT — optional param with JSON null:
|
|
57
|
+
<tool_call>
|
|
58
|
+
{{"name": "execute_command", "arguments": {{"command": "ls -la", "cwd": null}}}}
|
|
59
|
+
</tool_call>
|
|
60
|
+
|
|
50
61
|
{tool_examples}
|
|
51
62
|
|
|
52
63
|
ATTEMPT_COMPLETION USAGE:
|
|
@@ -249,10 +249,15 @@ class ClaudeCodeProvider:
|
|
|
249
249
|
Claude sometimes returns structured data (arrays, objects) as
|
|
250
250
|
JSON-encoded strings. Kilo Code expects native types, so we
|
|
251
251
|
parse them back. E.g. follow_up: '[{"text":"Yes"}]' -> [{"text":"Yes"}]
|
|
252
|
+
|
|
253
|
+
Also converts string "null" to None — Claude sometimes generates
|
|
254
|
+
"cwd": "null" instead of "cwd": null for optional parameters.
|
|
252
255
|
"""
|
|
253
256
|
result = {}
|
|
254
257
|
for key, value in obj.items():
|
|
255
|
-
if isinstance(value, str) and value
|
|
258
|
+
if isinstance(value, str) and value == "null":
|
|
259
|
+
result[key] = None
|
|
260
|
+
elif isinstance(value, str) and value and value[0] in ('[', '{'):
|
|
256
261
|
try:
|
|
257
262
|
result[key] = json.loads(value)
|
|
258
263
|
except (json.JSONDecodeError, ValueError):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tool call argument validation against OpenAI tool schemas."""
|
|
1
|
+
"""Tool call argument validation and sanitization against OpenAI tool schemas."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from typing import Optional
|
|
@@ -8,6 +8,29 @@ from cli2api.utils.logging import get_logger
|
|
|
8
8
|
logger = get_logger(__name__)
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def sanitize_tool_arguments(arguments: dict) -> dict:
|
|
12
|
+
"""Sanitize tool call arguments: convert string "null" to None.
|
|
13
|
+
|
|
14
|
+
Claude sometimes generates "cwd": "null" (string) instead of
|
|
15
|
+
"cwd": null (JSON null) for optional parameters. This causes
|
|
16
|
+
downstream errors when the consumer interprets "null" as a
|
|
17
|
+
literal directory name.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
arguments: Parsed tool call arguments dict.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Sanitized arguments dict with "null" strings replaced by None.
|
|
24
|
+
"""
|
|
25
|
+
result = {}
|
|
26
|
+
for key, value in arguments.items():
|
|
27
|
+
if isinstance(value, str) and value == "null":
|
|
28
|
+
result[key] = None
|
|
29
|
+
else:
|
|
30
|
+
result[key] = value
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
11
34
|
def build_required_params_index(tools: list[dict]) -> dict[str, list[str]]:
|
|
12
35
|
"""Build a lookup: tool_name -> list of required parameter names.
|
|
13
36
|
|
|
@@ -77,11 +100,38 @@ def validate_tool_call(
|
|
|
77
100
|
return True
|
|
78
101
|
|
|
79
102
|
|
|
103
|
+
def _sanitize_tool_call(tool_call: dict) -> dict:
|
|
104
|
+
"""Sanitize a single tool call's arguments in-place.
|
|
105
|
+
|
|
106
|
+
Parses the arguments JSON, applies sanitize_tool_arguments,
|
|
107
|
+
and re-serializes. Returns the (possibly modified) tool call.
|
|
108
|
+
"""
|
|
109
|
+
func = tool_call.get("function", {})
|
|
110
|
+
arguments_str = func.get("arguments", "{}")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
arguments = json.loads(arguments_str)
|
|
114
|
+
except (json.JSONDecodeError, TypeError):
|
|
115
|
+
return tool_call
|
|
116
|
+
|
|
117
|
+
if not isinstance(arguments, dict):
|
|
118
|
+
return tool_call
|
|
119
|
+
|
|
120
|
+
sanitized = sanitize_tool_arguments(arguments)
|
|
121
|
+
if sanitized != arguments:
|
|
122
|
+
func["arguments"] = json.dumps(sanitized)
|
|
123
|
+
|
|
124
|
+
return tool_call
|
|
125
|
+
|
|
126
|
+
|
|
80
127
|
def filter_valid_tool_calls(
|
|
81
128
|
tool_calls: Optional[list[dict]],
|
|
82
129
|
tools: Optional[list[dict]],
|
|
83
130
|
) -> Optional[list[dict]]:
|
|
84
|
-
"""Filter tool calls
|
|
131
|
+
"""Filter and sanitize tool calls.
|
|
132
|
+
|
|
133
|
+
1. Sanitizes arguments (e.g. string "null" -> JSON null).
|
|
134
|
+
2. Removes tool calls with missing required params.
|
|
85
135
|
|
|
86
136
|
Invalid tool calls are dropped with a WARNING log.
|
|
87
137
|
|
|
@@ -95,6 +145,9 @@ def filter_valid_tool_calls(
|
|
|
95
145
|
if not tool_calls or not tools:
|
|
96
146
|
return tool_calls
|
|
97
147
|
|
|
148
|
+
# Sanitize all tool calls first
|
|
149
|
+
tool_calls = [_sanitize_tool_call(tc) for tc in tool_calls]
|
|
150
|
+
|
|
98
151
|
index = build_required_params_index(tools)
|
|
99
152
|
valid = [tc for tc in tool_calls if validate_tool_call(tc, index)]
|
|
100
153
|
|
|
@@ -247,6 +247,13 @@ class TestDeserializeStringValues:
|
|
|
247
247
|
result = provider._deserialize_string_values(obj)
|
|
248
248
|
assert result["empty"] == ""
|
|
249
249
|
|
|
250
|
+
def test_null_string_becomes_none(self, provider):
|
|
251
|
+
"""String "null" should be converted to Python None."""
|
|
252
|
+
obj = {"cwd": "null", "command": "ls -la"}
|
|
253
|
+
result = provider._deserialize_string_values(obj)
|
|
254
|
+
assert result["cwd"] is None
|
|
255
|
+
assert result["command"] == "ls -la"
|
|
256
|
+
|
|
250
257
|
def test_real_kilo_code_follow_up(self, provider):
|
|
251
258
|
"""Reproduce exact Kilo Code ask_followup_question scenario."""
|
|
252
259
|
obj = {
|
|
@@ -358,6 +365,20 @@ class TestExtractNativeToolCall:
|
|
|
358
365
|
args = json.loads(result["function"]["arguments"])
|
|
359
366
|
assert args == {}
|
|
360
367
|
|
|
368
|
+
def test_cwd_null_string_becomes_json_null(self, provider):
|
|
369
|
+
"""String "null" for cwd should be converted to JSON null in arguments."""
|
|
370
|
+
block = {
|
|
371
|
+
"type": "tool_use",
|
|
372
|
+
"id": "toolu_04CWD",
|
|
373
|
+
"name": "execute_command",
|
|
374
|
+
"input": {"command": "ls -la", "cwd": "null"},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
result = provider._extract_native_tool_call(block)
|
|
378
|
+
args = json.loads(result["function"]["arguments"])
|
|
379
|
+
assert args["cwd"] is None
|
|
380
|
+
assert args["command"] == "ls -la"
|
|
381
|
+
|
|
361
382
|
def test_generates_id_when_missing(self, provider):
|
|
362
383
|
"""Should generate a call_ prefixed ID when block has no id."""
|
|
363
384
|
block = {
|
|
@@ -7,6 +7,7 @@ import pytest
|
|
|
7
7
|
from cli2api.tools.validator import (
|
|
8
8
|
build_required_params_index,
|
|
9
9
|
filter_valid_tool_calls,
|
|
10
|
+
sanitize_tool_arguments,
|
|
10
11
|
validate_tool_call,
|
|
11
12
|
)
|
|
12
13
|
|
|
@@ -199,3 +200,60 @@ class TestFilterValidToolCalls:
|
|
|
199
200
|
tool_calls = [_make_tool_call("list_files", {})]
|
|
200
201
|
result = filter_valid_tool_calls(tool_calls, SAMPLE_TOOLS)
|
|
201
202
|
assert len(result) == 1
|
|
203
|
+
|
|
204
|
+
def test_sanitizes_null_string_to_json_null(self):
|
|
205
|
+
"""filter_valid_tool_calls should convert "null" strings to JSON null."""
|
|
206
|
+
tc = _make_tool_call_raw(
|
|
207
|
+
"execute_command",
|
|
208
|
+
'{"command": "ls -la", "cwd": "null"}',
|
|
209
|
+
)
|
|
210
|
+
result = filter_valid_tool_calls([tc], SAMPLE_TOOLS)
|
|
211
|
+
assert len(result) == 1
|
|
212
|
+
args = json.loads(result[0]["function"]["arguments"])
|
|
213
|
+
assert args["cwd"] is None
|
|
214
|
+
assert args["command"] == "ls -la"
|
|
215
|
+
|
|
216
|
+
def test_sanitizes_preserves_real_null(self):
|
|
217
|
+
"""JSON null should remain null after sanitization."""
|
|
218
|
+
tc = _make_tool_call_raw(
|
|
219
|
+
"execute_command",
|
|
220
|
+
'{"command": "ls -la", "cwd": null}',
|
|
221
|
+
)
|
|
222
|
+
result = filter_valid_tool_calls([tc], SAMPLE_TOOLS)
|
|
223
|
+
assert len(result) == 1
|
|
224
|
+
args = json.loads(result[0]["function"]["arguments"])
|
|
225
|
+
assert args["cwd"] is None
|
|
226
|
+
assert args["command"] == "ls -la"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# === Tests for sanitize_tool_arguments ===
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TestSanitizeToolArguments:
|
|
233
|
+
def test_null_string_becomes_none(self):
|
|
234
|
+
result = sanitize_tool_arguments({"cwd": "null", "command": "ls"})
|
|
235
|
+
assert result["cwd"] is None
|
|
236
|
+
assert result["command"] == "ls"
|
|
237
|
+
|
|
238
|
+
def test_none_stays_none(self):
|
|
239
|
+
result = sanitize_tool_arguments({"cwd": None, "command": "ls"})
|
|
240
|
+
assert result["cwd"] is None
|
|
241
|
+
|
|
242
|
+
def test_normal_strings_unchanged(self):
|
|
243
|
+
result = sanitize_tool_arguments({"path": "/tmp", "command": "ls"})
|
|
244
|
+
assert result["path"] == "/tmp"
|
|
245
|
+
assert result["command"] == "ls"
|
|
246
|
+
|
|
247
|
+
def test_non_string_values_unchanged(self):
|
|
248
|
+
result = sanitize_tool_arguments({"count": 5, "flag": True})
|
|
249
|
+
assert result["count"] == 5
|
|
250
|
+
assert result["flag"] is True
|
|
251
|
+
|
|
252
|
+
def test_empty_dict(self):
|
|
253
|
+
assert sanitize_tool_arguments({}) == {}
|
|
254
|
+
|
|
255
|
+
def test_string_containing_null_substring_unchanged(self):
|
|
256
|
+
"""Only exact 'null' string should be converted, not substrings."""
|
|
257
|
+
result = sanitize_tool_arguments({"note": "nullable", "path": "nullify"})
|
|
258
|
+
assert result["note"] == "nullable"
|
|
259
|
+
assert result["path"] == "nullify"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|