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.
Files changed (53) hide show
  1. {cli2api-0.2.2 → cli2api-0.2.3}/PKG-INFO +1 -1
  2. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/prompts/tools_instruction.txt +11 -0
  3. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/providers/claude.py +6 -1
  4. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/validator.py +55 -2
  5. {cli2api-0.2.2 → cli2api-0.2.3}/pyproject.toml +1 -1
  6. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_providers.py +21 -0
  7. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_tool_validator.py +58 -0
  8. {cli2api-0.2.2 → cli2api-0.2.3}/.dockerignore +0 -0
  9. {cli2api-0.2.2 → cli2api-0.2.3}/.env.example +0 -0
  10. {cli2api-0.2.2 → cli2api-0.2.3}/.github/workflows/publish.yml +0 -0
  11. {cli2api-0.2.2 → cli2api-0.2.3}/.gitignore +0 -0
  12. {cli2api-0.2.2 → cli2api-0.2.3}/Dockerfile +0 -0
  13. {cli2api-0.2.2 → cli2api-0.2.3}/README.md +0 -0
  14. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/__init__.py +0 -0
  15. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/__main__.py +0 -0
  16. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/__init__.py +0 -0
  17. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/dependencies.py +0 -0
  18. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/router.py +0 -0
  19. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/utils.py +0 -0
  20. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/__init__.py +0 -0
  21. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/chat.py +0 -0
  22. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/models.py +0 -0
  23. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/api/v1/responses.py +0 -0
  24. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/config/__init__.py +0 -0
  25. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/config/settings.py +0 -0
  26. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/constants.py +0 -0
  27. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/main.py +0 -0
  28. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/prompts/__init__.py +0 -0
  29. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/providers/__init__.py +0 -0
  30. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/__init__.py +0 -0
  31. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/internal.py +0 -0
  32. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/schemas/openai.py +0 -0
  33. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/services/__init__.py +0 -0
  34. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/services/completion.py +0 -0
  35. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/__init__.py +0 -0
  36. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/sse.py +0 -0
  37. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/streaming/tool_parser.py +0 -0
  38. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/__init__.py +0 -0
  39. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/tools/handler.py +0 -0
  40. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/__init__.py +0 -0
  41. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/cli_detector.py +0 -0
  42. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api/utils/logging.py +0 -0
  43. {cli2api-0.2.2 → cli2api-0.2.3}/cli2api.sh +0 -0
  44. {cli2api-0.2.2 → cli2api-0.2.3}/docker-compose.yaml +0 -0
  45. {cli2api-0.2.2 → cli2api-0.2.3}/tests/__init__.py +0 -0
  46. {cli2api-0.2.2 → cli2api-0.2.3}/tests/conftest.py +0 -0
  47. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_api.py +0 -0
  48. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_cli_detector.py +0 -0
  49. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_config.py +0 -0
  50. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_integration.py +0 -0
  51. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_schemas.py +0 -0
  52. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_streaming.py +0 -0
  53. {cli2api-0.2.2 → cli2api-0.2.3}/tests/test_streaming_tool_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli2api
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: OpenAI-compatible API over Claude Code CLI
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.115.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 and value[0] in ('[', '{'):
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, removing those with missing required params.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cli2api"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "OpenAI-compatible API over Claude Code CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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