cli2api 0.2.0__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 (54) hide show
  1. cli2api-0.2.2/.env.example +32 -0
  2. {cli2api-0.2.0 → cli2api-0.2.2}/PKG-INFO +1 -1
  3. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/__init__.py +1 -1
  4. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/dependencies.py +21 -1
  5. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/config/settings.py +28 -15
  6. cli2api-0.2.2/cli2api/prompts/tools_instruction.txt +58 -0
  7. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/providers/claude.py +33 -5
  8. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/tools/handler.py +29 -47
  9. cli2api-0.2.2/cli2api/tools/validator.py +105 -0
  10. cli2api-0.2.2/cli2api/utils/__init__.py +0 -0
  11. cli2api-0.2.2/cli2api/utils/cli_detector.py +337 -0
  12. {cli2api-0.2.0 → cli2api-0.2.2}/pyproject.toml +1 -1
  13. cli2api-0.2.2/tests/test_cli_detector.py +423 -0
  14. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_config.py +21 -14
  15. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_providers.py +173 -0
  16. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_streaming_tool_parser.py +19 -0
  17. cli2api-0.2.2/tests/test_tool_validator.py +201 -0
  18. cli2api-0.2.0/.env.example +0 -22
  19. {cli2api-0.2.0 → cli2api-0.2.2}/.dockerignore +0 -0
  20. {cli2api-0.2.0 → cli2api-0.2.2}/.github/workflows/publish.yml +0 -0
  21. {cli2api-0.2.0 → cli2api-0.2.2}/.gitignore +0 -0
  22. {cli2api-0.2.0 → cli2api-0.2.2}/Dockerfile +0 -0
  23. {cli2api-0.2.0 → cli2api-0.2.2}/README.md +0 -0
  24. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/__main__.py +0 -0
  25. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/__init__.py +0 -0
  26. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/router.py +0 -0
  27. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/utils.py +0 -0
  28. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/v1/__init__.py +0 -0
  29. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/v1/chat.py +0 -0
  30. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/v1/models.py +0 -0
  31. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/api/v1/responses.py +0 -0
  32. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/config/__init__.py +0 -0
  33. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/constants.py +0 -0
  34. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/main.py +0 -0
  35. {cli2api-0.2.0/cli2api/utils → cli2api-0.2.2/cli2api/prompts}/__init__.py +0 -0
  36. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/providers/__init__.py +0 -0
  37. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/schemas/__init__.py +0 -0
  38. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/schemas/internal.py +0 -0
  39. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/schemas/openai.py +0 -0
  40. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/services/__init__.py +0 -0
  41. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/services/completion.py +0 -0
  42. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/streaming/__init__.py +0 -0
  43. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/streaming/sse.py +0 -0
  44. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/streaming/tool_parser.py +0 -0
  45. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/tools/__init__.py +0 -0
  46. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api/utils/logging.py +0 -0
  47. {cli2api-0.2.0 → cli2api-0.2.2}/cli2api.sh +0 -0
  48. {cli2api-0.2.0 → cli2api-0.2.2}/docker-compose.yaml +0 -0
  49. {cli2api-0.2.0 → cli2api-0.2.2}/tests/__init__.py +0 -0
  50. {cli2api-0.2.0 → cli2api-0.2.2}/tests/conftest.py +0 -0
  51. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_api.py +0 -0
  52. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_integration.py +0 -0
  53. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_schemas.py +0 -0
  54. {cli2api-0.2.0 → cli2api-0.2.2}/tests/test_streaming.py +0 -0
@@ -0,0 +1,32 @@
1
+ # CLI2API Configuration
2
+ # Copy this file to .env and customize as needed
3
+
4
+ # Server settings
5
+ CLI2API_HOST=0.0.0.0
6
+ CLI2API_PORT=8000
7
+ CLI2API_DEBUG=false
8
+
9
+ # CLI executable path (auto-detected if not set)
10
+ # Auto-detection checks (in order):
11
+ # 1. Cached path (~/.cache/cli2api/claude_cli_path)
12
+ # 2. System PATH (shutil.which)
13
+ # 3. Running Claude processes (ps aux / wmic)
14
+ # 4. VSCode extensions (~/.vscode/extensions/anthropic.claude-code-*)
15
+ # 5. NPM global packages
16
+ # 6. Common paths (/opt/homebrew/bin, /usr/local/bin, ~/.local/bin)
17
+ #
18
+ # Explicitly set if auto-detection fails or you want to override:
19
+ # CLI2API_CLAUDE_CLI_PATH=/opt/homebrew/bin/claude
20
+ # CLI2API_CLAUDE_CLI_PATH=/Users/username/.vscode/extensions/anthropic.claude-code-2.1.31-darwin-arm64/resources/native-binary/claude
21
+
22
+ # Timeout (in seconds)
23
+ CLI2API_DEFAULT_TIMEOUT=300
24
+
25
+ # Default model
26
+ CLI2API_DEFAULT_MODEL=sonnet
27
+
28
+ # Custom models (comma-separated)
29
+ # CLI2API_CLAUDE_MODELS=sonnet,opus,haiku
30
+
31
+ # Logging
32
+ CLI2API_LOG_LEVEL=INFO
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli2api
3
- Version: 0.2.0
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"
@@ -29,7 +29,27 @@ def get_provider() -> ClaudeCodeProvider:
29
29
  """
30
30
  settings = get_settings()
31
31
  if not settings.claude_cli_path:
32
- raise RuntimeError("Claude CLI not found. Set CLI2API_CLAUDE_CLI_PATH.")
32
+ error_msg = """
33
+ Claude CLI not found. Please:
34
+
35
+ 1. Install Claude Code: https://docs.anthropic.com/en/docs/claude-code
36
+ 2. Or set environment variable: export CLI2API_CLAUDE_CLI_PATH=/path/to/claude
37
+ 3. Or ensure Claude is in your PATH
38
+
39
+ Auto-detection checked:
40
+ - Cached path (~/.cache/cli2api/claude_cli_path)
41
+ - System PATH (shutil.which)
42
+ - Running processes (ps aux / wmic)
43
+ - VSCode extensions (platform-specific)
44
+ - NPM global packages
45
+ - Common paths (platform-specific)
46
+
47
+ For debugging, run: CLI2API_LOG_LEVEL=DEBUG cli2api
48
+
49
+ Note: Detected path is cached in ~/.cache/cli2api/claude_cli_path
50
+ To force re-detection, delete the cache file.
51
+ """
52
+ raise RuntimeError(error_msg.strip())
33
53
 
34
54
  return ClaudeCodeProvider(
35
55
  executable_path=Path(settings.claude_cli_path),
@@ -1,6 +1,5 @@
1
1
  """Application settings."""
2
2
 
3
- import shutil
4
3
  from pathlib import Path
5
4
  from typing import Optional
6
5
 
@@ -68,19 +67,33 @@ class Settings(BaseSettings):
68
67
  @classmethod
69
68
  def detect_claude_cli(cls, v: Optional[str]) -> Optional[str]:
70
69
  """Auto-detect claude CLI path if not provided."""
70
+ from cli2api.utils.cli_detector import (
71
+ cache_path,
72
+ detect_claude_cli,
73
+ verify_claude_executable,
74
+ )
75
+ from cli2api.utils.logging import get_logger
76
+
77
+ logger = get_logger(__name__)
78
+
71
79
  if v:
72
- return v
73
- # Try which first
74
- path = shutil.which("claude")
75
- if path:
76
- return path
77
- # Fallback to common paths
78
- common_paths = [
79
- "/opt/homebrew/bin/claude",
80
- "/usr/local/bin/claude",
81
- Path.home() / ".local/bin/claude",
82
- ]
83
- for p in common_paths:
84
- if Path(p).exists():
85
- return str(p)
80
+ # User explicitly set path - verify it
81
+ path = Path(v)
82
+ if verify_claude_executable(path):
83
+ logger.info(f"Using explicitly set Claude CLI path: {v}")
84
+ # Cache explicitly set path
85
+ cache_path(path)
86
+ return v
87
+ logger.warning(f"Explicitly set Claude CLI path is invalid: {v}")
88
+
89
+ # Auto-detect (includes cache check)
90
+ detected_path = detect_claude_cli()
91
+
92
+ if detected_path:
93
+ logger.info(f"Auto-detected Claude CLI: {detected_path}")
94
+ # Cache detected path for next startup
95
+ cache_path(detected_path)
96
+ return str(detected_path)
97
+
98
+ logger.error("Claude CLI not found")
86
99
  return None
@@ -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