omnimancer-cli 0.2.15__tar.gz → 0.2.20__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 (157) hide show
  1. {omnimancer_cli-0.2.15/omnimancer_cli.egg-info → omnimancer_cli-0.2.20}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/tool_handler.py +12 -0
  3. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/file_system_manager.py +41 -4
  4. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/models.py +23 -0
  5. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/azure.py +7 -3
  6. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/base.py +30 -0
  7. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/mistral.py +7 -3
  8. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/openai.py +38 -19
  9. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/openrouter.py +16 -6
  10. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/xai.py +7 -3
  11. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20/omnimancer_cli.egg-info}/PKG-INFO +1 -1
  12. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/SOURCES.txt +1 -0
  13. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/pyproject.toml +1 -1
  14. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_models.py +114 -65
  15. omnimancer_cli-0.2.20/tests/test_openai_request_timeout.py +129 -0
  16. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openrouter_provider.py +97 -0
  17. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_tool_handler.py +15 -0
  18. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_xai_provider.py +5 -3
  19. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/LICENSE +0 -0
  20. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/README.md +0 -0
  21. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/__init__.py +0 -0
  22. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/__main__.py +0 -0
  23. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/__init__.py +0 -0
  24. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/agent_loop.py +0 -0
  25. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_formatter.py +0 -0
  26. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_integration.py +0 -0
  27. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_prompt.py +0 -0
  28. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/command_dispatch.py +0 -0
  29. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/commands.py +0 -0
  30. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/completion.py +0 -0
  31. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/display.py +0 -0
  32. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/dynamic_commands.py +0 -0
  33. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/handlers/__init__.py +0 -0
  34. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/headless.py +0 -0
  35. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/interface.py +0 -0
  36. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/subagent.py +0 -0
  37. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/system_prompts.py +0 -0
  38. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/__init__.py +0 -0
  39. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/__init__.py +0 -0
  40. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_context.py +0 -0
  41. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_interface.py +0 -0
  42. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_manager.py +0 -0
  43. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/config.py +0 -0
  44. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/program_executor.py +0 -0
  45. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  46. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_core.py +0 -0
  47. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_manager.py +0 -0
  48. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/tool_definitions.py +0 -0
  49. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/types.py +0 -0
  50. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/web_client.py +0 -0
  51. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  52. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_engine.py +0 -0
  53. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_managers.py +0 -0
  54. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_mode_manager.py +0 -0
  55. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/chat_manager.py +0 -0
  56. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_manager.py +0 -0
  57. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_migration.py +0 -0
  58. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_validator.py +0 -0
  59. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/conversation_manager.py +0 -0
  60. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/engine.py +0 -0
  61. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/env_loader.py +0 -0
  62. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/fallback_manager.py +0 -0
  63. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/health_monitor.py +0 -0
  64. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/history_manager.py +0 -0
  65. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/hooks.py +0 -0
  66. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/mcp_integration_layer.py +0 -0
  67. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_capabilities.py +0 -0
  68. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_initializer.py +0 -0
  69. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_registry.py +0 -0
  70. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/rate_limit_fallback.py +0 -0
  71. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/__init__.py +0 -0
  72. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/approval_workflow.py +0 -0
  73. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/audit_logger.py +0 -0
  74. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_controller.py +0 -0
  75. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_rules.py +0 -0
  76. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/sandbox_manager.py +0 -0
  77. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/security_manager.py +0 -0
  78. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/signal_handler.py +0 -0
  79. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/__init__.py +0 -0
  80. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/client.py +0 -0
  81. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/manager.py +0 -0
  82. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/__init__.py +0 -0
  83. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/bedrock.py +0 -0
  84. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude.py +0 -0
  85. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_code.py +0 -0
  86. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_credentials.py +0 -0
  87. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/cohere.py +0 -0
  88. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/digitalocean.py +0 -0
  89. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/factory.py +0 -0
  90. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/gemini.py +0 -0
  91. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/ollama.py +0 -0
  92. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/perplexity.py +0 -0
  93. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/vertex.py +0 -0
  94. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/__init__.py +0 -0
  95. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/cancellation_handler.py +0 -0
  96. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/progress_indicator.py +0 -0
  97. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/streaming_display.py +0 -0
  98. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/__init__.py +0 -0
  99. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/error_handler.py +0 -0
  100. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/errors.py +0 -0
  101. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/retry.py +0 -0
  102. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  103. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  104. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/requires.txt +0 -0
  105. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/top_level.txt +0 -0
  106. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/setup.cfg +0 -0
  107. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_integration_compatibility.py +0 -0
  108. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_preview_format.py +0 -0
  109. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_remember.py +0 -0
  110. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_system.py +0 -0
  111. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_azure_provider.py +0 -0
  112. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_bedrock_provider.py +0 -0
  113. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_code_provider.py +0 -0
  114. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_credentials.py +0 -0
  115. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_provider.py +0 -0
  116. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_cohere_provider.py +0 -0
  117. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_command_approval_security.py +0 -0
  118. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_config_comprehensive.py +0 -0
  119. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_conversation_history.py +0 -0
  120. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_conversation_manager.py +0 -0
  121. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_core_engine.py +0 -0
  122. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_digitalocean_provider.py +0 -0
  123. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_enhanced_model_info.py +0 -0
  124. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_env_loader.py +0 -0
  125. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_error_handling_robustness.py +0 -0
  126. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_execution_timeout_errors.py +0 -0
  127. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_fallback_manager.py +0 -0
  128. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_fixtures.py +0 -0
  129. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_gemini_provider.py +0 -0
  130. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_hooks.py +0 -0
  131. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_httpx_compatibility.py +0 -0
  132. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_import_validation.py +0 -0
  133. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_interface_smoke_tests.py +0 -0
  134. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_integration.py +0 -0
  135. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_integration_layer.py +0 -0
  136. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_management_commands.py +0 -0
  137. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mistral_provider.py +0 -0
  138. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_ollama_provider.py +0 -0
  139. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openai_context_fit.py +0 -0
  140. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openai_provider_tools.py +0 -0
  141. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_permission_rules.py +0 -0
  142. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_perplexity_provider.py +0 -0
  143. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_program_executor.py +0 -0
  144. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_base.py +0 -0
  145. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_capability_consistency.py +0 -0
  146. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_discovery.py +0 -0
  147. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_integration.py +0 -0
  148. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_registry.py +0 -0
  149. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_rate_limit_fallback.py +0 -0
  150. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_retry.py +0 -0
  151. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_security_validation.py +0 -0
  152. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_system_integration.py +0 -0
  153. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_system_prompts.py +0 -0
  154. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_tool_calling_integration.py +0 -0
  155. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_vertex_provider.py +0 -0
  156. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_web_request_markers.py +0 -0
  157. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_workflows_comprehensive.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.15
3
+ Version: 0.2.20
4
4
  Summary: A unified command-line interface for multiple AI language models
5
5
  Author-email: Kellan Strong <kellan@jite.ai>
6
6
  License: MIT
@@ -150,6 +150,18 @@ class ToolHandler:
150
150
  return list(CODING_AGENT_TOOLS)
151
151
 
152
152
  async def execute_tool_call(self, tool_call: ToolCall) -> ToolResult:
153
+ # ToolCall normalizes JSON-string arguments on construction; anything
154
+ # still not a dict here is malformed — tell the model instead of
155
+ # crashing on args.get(...) inside a handler.
156
+ if not isinstance(tool_call.arguments, dict):
157
+ return ToolResult(
158
+ content="",
159
+ error=(
160
+ f"Malformed arguments for {tool_call.name}: expected a "
161
+ f"JSON object, got: {str(tool_call.arguments)[:200]}. "
162
+ "Re-issue the call with the parameters as a JSON object."
163
+ ),
164
+ )
153
165
  try:
154
166
  # Read and Edit need post-processing / multi-step logic.
155
167
  if tool_call.name in _EDIT_TOOLS:
@@ -250,14 +250,51 @@ class FileSystemManager:
250
250
  """
251
251
  Simple boolean check for file existence.
252
252
 
253
+ Performs a lightweight path-validation check followed by an async
254
+ filesystem probe. Unlike :meth:`check_file_exists`, this method does
255
+ *not* invoke the full security-audit pipeline (permission logging, risk
256
+ assessment, approval workflow). It is intended for high-frequency
257
+ callers (e.g. batch operations) that need a fast boolean answer.
258
+
259
+ .. warning::
260
+ This method enforces path-level access controls — hard-restricted
261
+ system paths (``/etc``, ``~/.ssh``, cloud-credential stores, etc.)
262
+ and sensitive-name patterns are still blocked via
263
+ :meth:`~omnimancer.core.security.permission_controller.PermissionController.validate_path_access`.
264
+ However, it skips the per-operation audit-log entries produced by
265
+ :meth:`~omnimancer.core.security.SecurityManager.secure_file_access`.
266
+ Do not use this method where per-access audit trails are required.
267
+
253
268
  Args:
254
- path: Path to check for existence
269
+ path: Path to check for existence.
255
270
 
256
271
  Returns:
257
- True if file exists and is accessible, False otherwise
272
+ True if the path passes security validation and exists on the
273
+ filesystem; False if the path is security-denied or does not exist.
258
274
  """
259
- result = await self.check_file_exists(path)
260
- return result["exists"] and result["error"] is None
275
+ try:
276
+ resolved = Path(path).resolve()
277
+ except (OSError, ValueError) as e:
278
+ logger.warning("file_exists: invalid path %r: %s", path, e)
279
+ return False
280
+
281
+ # Validate path against security policy without invoking the expensive
282
+ # audit pipeline. This blocks hard-restricted system paths and
283
+ # sensitive-name patterns (e.g. /etc, ~/.ssh, *.db) the same way the
284
+ # full pipeline would.
285
+ if not self.security.permissions.validate_path_access(str(resolved), "read"):
286
+ logger.warning(
287
+ "file_exists: access denied by security policy for %s", resolved
288
+ )
289
+ return False
290
+
291
+ try:
292
+ exists = bool(await aiofiles.os.path.exists(resolved))
293
+ logger.debug("file_exists: %s → %s", resolved, exists)
294
+ return exists
295
+ except OSError as e:
296
+ logger.warning("file_exists: OS error checking %s: %s", resolved, e)
297
+ return False
261
298
 
262
299
  async def write_file_with_confirmation(
263
300
  self,
@@ -113,6 +113,29 @@ class ToolCall:
113
113
  # Provider-assigned call id (OpenAI protocol pairs results to calls by id).
114
114
  id: Optional[str] = None
115
115
 
116
+ def __post_init__(self) -> None:
117
+ """Normalize arguments that arrive as a JSON-encoded string.
118
+
119
+ OpenAI-protocol servers send ``function.arguments`` as a JSON string,
120
+ and some models double-encode it. A value that doesn't decode to an
121
+ object is left as-is so the tool handler can report it to the model.
122
+ """
123
+ import json
124
+
125
+ if self.arguments is None:
126
+ self.arguments = {}
127
+ return
128
+ decoded: Any = self.arguments
129
+ for _ in range(2):
130
+ if not isinstance(decoded, str):
131
+ break
132
+ try:
133
+ decoded = json.loads(decoded)
134
+ except ValueError:
135
+ return
136
+ if isinstance(decoded, dict):
137
+ self.arguments = decoded
138
+
116
139
 
117
140
  @dataclass
118
141
  class ToolResultRecord:
@@ -363,19 +363,23 @@ class AzureProvider(BaseProvider):
363
363
 
364
364
  if choices and len(choices) > 0:
365
365
  message = choices[0].get("message", {})
366
- content = message.get("content", "")
366
+ # Tool-call responses carry "content": null — coalesce to "".
367
+ content = message.get("content") or ""
367
368
  usage = data.get("usage", {})
368
369
 
369
- # Extract tool calls if present
370
+ # Extract tool calls if present. The wire protocol sends
371
+ # function.arguments as a JSON string; ToolCall normalizes
372
+ # it to a dict on construction.
370
373
  tool_calls = []
371
374
  if "tool_calls" in message:
372
- for tool_call in message["tool_calls"]:
375
+ for i, tool_call in enumerate(message["tool_calls"]):
373
376
  if tool_call.get("type") == "function":
374
377
  function = tool_call.get("function", {})
375
378
  tool_calls.append(
376
379
  ToolCall(
377
380
  name=function.get("name", ""),
378
381
  arguments=function.get("arguments", {}),
382
+ id=tool_call.get("id") or f"call_{i}",
379
383
  )
380
384
  )
381
385
 
@@ -6,6 +6,8 @@ to ensure consistent behavior across different AI services.
6
6
  """
7
7
 
8
8
  import json
9
+ import logging
10
+ import os
9
11
  from abc import ABC, abstractmethod
10
12
  from typing import Any, AsyncIterator, Dict, List, Union
11
13
 
@@ -19,6 +21,8 @@ from ..core.models import (
19
21
  ToolDefinition,
20
22
  )
21
23
 
24
+ logger = logging.getLogger(__name__)
25
+
22
26
 
23
27
  class BaseProvider(ABC):
24
28
  """
@@ -28,6 +32,32 @@ class BaseProvider(ABC):
28
32
  the required abstract methods to ensure consistent behavior.
29
33
  """
30
34
 
35
+ # Default chat-completion timeout. Large models on serverless backends
36
+ # (e.g. 300B+ models on DigitalOcean inference) can take well over a
37
+ # minute to first byte with tool-heavy agent prompts — hardcoded 30s/60s
38
+ # timeouts made those models unusable in headless runs.
39
+ DEFAULT_REQUEST_TIMEOUT = 120.0
40
+
41
+ @classmethod
42
+ def _resolve_request_timeout(cls, configured: Any) -> float:
43
+ """
44
+ Resolve the chat-completion timeout: explicit config wins, then the
45
+ OMNIMANCER_REQUEST_TIMEOUT environment variable, then the default.
46
+ Invalid or non-positive values fall back to the default.
47
+ """
48
+ for raw in (configured, os.environ.get("OMNIMANCER_REQUEST_TIMEOUT")):
49
+ if raw is None or raw == "":
50
+ continue
51
+ try:
52
+ value = float(raw)
53
+ except (TypeError, ValueError):
54
+ logger.warning("Ignoring invalid request timeout %r", raw)
55
+ continue
56
+ if value > 0:
57
+ return value
58
+ logger.warning("Ignoring non-positive request timeout %r", raw)
59
+ return cls.DEFAULT_REQUEST_TIMEOUT
60
+
31
61
  def __init__(self, api_key: str, model: str, **kwargs: Any) -> None:
32
62
  """
33
63
  Initialize the provider.
@@ -324,19 +324,23 @@ class MistralProvider(BaseProvider):
324
324
 
325
325
  if choices and len(choices) > 0:
326
326
  message = choices[0].get("message", {})
327
- content = message.get("content", "")
327
+ # Tool-call responses carry "content": null — coalesce to "".
328
+ content = message.get("content") or ""
328
329
  usage = data.get("usage", {})
329
330
 
330
- # Extract tool calls if present
331
+ # Extract tool calls if present. The wire protocol sends
332
+ # function.arguments as a JSON string; ToolCall normalizes
333
+ # it to a dict on construction.
331
334
  tool_calls = []
332
335
  if "tool_calls" in message:
333
- for tool_call in message["tool_calls"]:
336
+ for i, tool_call in enumerate(message["tool_calls"]):
334
337
  if tool_call.get("type") == "function":
335
338
  function = tool_call.get("function", {})
336
339
  tool_calls.append(
337
340
  ToolCall(
338
341
  name=function.get("name", ""),
339
342
  arguments=function.get("arguments", {}),
343
+ id=tool_call.get("id") or f"call_{i}",
340
344
  )
341
345
  )
342
346
 
@@ -57,13 +57,18 @@ class OpenAIProvider(BaseProvider):
57
57
  Args:
58
58
  api_key: OpenAI API key
59
59
  model: OpenAI model to use (e.g., 'gpt-4', 'gpt-4o', 'gpt-3.5-turbo')
60
- **kwargs: Additional configuration
60
+ **kwargs: Additional configuration. Supports ``request_timeout``
61
+ (seconds) for chat completions; the ``OMNIMANCER_REQUEST_TIMEOUT``
62
+ environment variable is used when the kwarg is not given.
61
63
  """
62
64
  super().__init__(api_key, model or "gpt-4", **kwargs)
63
65
  # Allow overriding the API endpoint for OpenAI-compatible services
64
66
  self.base_url = (kwargs.get("base_url") or self.BASE_URL).rstrip("/")
65
67
  self.max_tokens = kwargs.get("max_tokens", 4096)
66
68
  self.temperature = kwargs.get("temperature", 0.7)
69
+ self.request_timeout = self._resolve_request_timeout(
70
+ kwargs.get("request_timeout")
71
+ )
67
72
 
68
73
  async def send_message(self, message: str, context: ChatContext) -> ChatResponse:
69
74
  """
@@ -88,7 +93,7 @@ class OpenAIProvider(BaseProvider):
88
93
  "max_tokens": self.max_tokens,
89
94
  "temperature": self.temperature,
90
95
  },
91
- timeout=30.0,
96
+ timeout=self.request_timeout,
92
97
  )
93
98
 
94
99
  # Handle response
@@ -249,6 +254,19 @@ class OpenAIProvider(BaseProvider):
249
254
  provider=provider,
250
255
  )
251
256
 
257
+ async def _post_once(self, body: Dict[str, Any], timeout: float) -> httpx.Response:
258
+ """Single POST to the chat completions endpoint."""
259
+ async with httpx.AsyncClient() as client:
260
+ return await client.post(
261
+ f"{self.base_url}/chat/completions",
262
+ headers={
263
+ "Content-Type": "application/json",
264
+ "Authorization": f"Bearer {self.api_key}",
265
+ },
266
+ json=body,
267
+ timeout=timeout,
268
+ )
269
+
252
270
  async def _post_chat(
253
271
  self, request_body: Dict[str, Any], timeout: float
254
272
  ) -> httpx.Response:
@@ -264,16 +282,18 @@ class OpenAIProvider(BaseProvider):
264
282
  self._require_api_key()
265
283
 
266
284
  async def _do_post(body: Dict[str, Any]) -> httpx.Response:
267
- async with httpx.AsyncClient() as client:
268
- return await client.post(
269
- f"{self.base_url}/chat/completions",
270
- headers={
271
- "Content-Type": "application/json",
272
- "Authorization": f"Bearer {self.api_key}",
273
- },
274
- json=body,
275
- timeout=timeout,
285
+ # Serverless backends (notably DigitalOcean inference) sporadically
286
+ # time out on an otherwise-fine request; one retry absorbs those
287
+ # transient stalls instead of failing the whole agent run.
288
+ try:
289
+ return await self._post_once(body, timeout)
290
+ except httpx.TimeoutException:
291
+ logger.warning(
292
+ "%s chat completion timed out after %.0fs — retrying once",
293
+ self.PROVIDER_LABEL,
294
+ timeout,
276
295
  )
296
+ return await self._post_once(body, timeout)
277
297
 
278
298
  body = request_body
279
299
  response = await _do_post(body)
@@ -332,7 +352,8 @@ class OpenAIProvider(BaseProvider):
332
352
 
333
353
  if choices and len(choices) > 0:
334
354
  message = choices[0].get("message", {})
335
- content = message.get("content", "")
355
+ # Tool-call responses carry "content": null — coalesce to "".
356
+ content = message.get("content") or ""
336
357
  usage = data.get("usage", {})
337
358
 
338
359
  return ChatResponse(
@@ -413,7 +434,7 @@ class OpenAIProvider(BaseProvider):
413
434
  if tools:
414
435
  request_body["tools"] = tools
415
436
 
416
- response = await self._post_chat(request_body, timeout=60.0)
437
+ response = await self._post_chat(request_body, timeout=self.request_timeout)
417
438
 
418
439
  return self._handle_response_with_tools(response)
419
440
 
@@ -462,18 +483,16 @@ class OpenAIProvider(BaseProvider):
462
483
  tool_calls = None
463
484
  raw_tool_calls = msg.get("tool_calls")
464
485
  if raw_tool_calls:
465
- import json
466
-
467
486
  tool_calls = []
468
487
  for i, tc in enumerate(raw_tool_calls):
469
488
  func = tc.get("function", {})
470
- args = func.get("arguments", "{}")
471
- if isinstance(args, str):
472
- args = json.loads(args)
473
489
  tool_calls.append(
474
490
  ToolCall(
491
+ # ToolCall normalizes JSON-string arguments
492
+ # (including double-encoded ones) on
493
+ # construction.
475
494
  name=func.get("name", ""),
476
- arguments=args,
495
+ arguments=func.get("arguments", {}),
477
496
  # Some OpenAI-compatible servers omit ids;
478
497
  # synthesize one so results can pair to calls.
479
498
  id=tc.get("id") or f"call_{i}",
@@ -67,6 +67,9 @@ class OpenRouterProvider(BaseProvider):
67
67
  self.top_p = kwargs.get("top_p", 1.0)
68
68
  self.frequency_penalty = kwargs.get("frequency_penalty", 0.0)
69
69
  self.presence_penalty = kwargs.get("presence_penalty", 0.0)
70
+ self.request_timeout = self._resolve_request_timeout(
71
+ kwargs.get("request_timeout")
72
+ )
70
73
 
71
74
  # Cost optimization settings
72
75
  self.enable_fallback = kwargs.get("enable_fallback", True)
@@ -115,7 +118,7 @@ class OpenRouterProvider(BaseProvider):
115
118
  f"{self.base_url}/chat/completions",
116
119
  headers=self._get_headers(),
117
120
  json=payload,
118
- timeout=30.0,
121
+ timeout=self.request_timeout,
119
122
  )
120
123
 
121
124
  # If we get here, the request succeeded
@@ -201,7 +204,7 @@ class OpenRouterProvider(BaseProvider):
201
204
  f"{self.base_url}/chat/completions",
202
205
  headers=self._get_headers(),
203
206
  json=payload,
204
- timeout=30.0,
207
+ timeout=self.request_timeout,
205
208
  )
206
209
 
207
210
  # If we get here, the request succeeded
@@ -377,7 +380,9 @@ class OpenRouterProvider(BaseProvider):
377
380
 
378
381
  if choices and len(choices) > 0:
379
382
  message = choices[0].get("message", {})
380
- content = message.get("content", "")
383
+ # Tool-call responses carry "content": null — coalesce to ""
384
+ # so the fallback-warning concatenation below can't TypeError.
385
+ content = message.get("content") or ""
381
386
  usage = data.get("usage", {})
382
387
 
383
388
  # Extract model used (OpenRouter may route to different models)
@@ -446,19 +451,24 @@ class OpenRouterProvider(BaseProvider):
446
451
 
447
452
  if choices and len(choices) > 0:
448
453
  message = choices[0].get("message", {})
449
- content = message.get("content", "")
454
+ # Tool-call responses carry "content": null — coalesce to ""
455
+ # so the fallback-warning concatenation below can't TypeError.
456
+ content = message.get("content") or ""
450
457
  usage = data.get("usage", {})
451
458
 
452
- # Extract tool calls if present
459
+ # Extract tool calls if present. The wire protocol sends
460
+ # function.arguments as a JSON string; ToolCall normalizes
461
+ # it to a dict on construction.
453
462
  tool_calls = []
454
463
  if "tool_calls" in message:
455
- for tool_call in message["tool_calls"]:
464
+ for i, tool_call in enumerate(message["tool_calls"]):
456
465
  if tool_call.get("type") == "function":
457
466
  function = tool_call.get("function", {})
458
467
  tool_calls.append(
459
468
  ToolCall(
460
469
  name=function.get("name", ""),
461
470
  arguments=function.get("arguments", {}),
471
+ id=tool_call.get("id") or f"call_{i}",
462
472
  )
463
473
  )
464
474
 
@@ -327,19 +327,23 @@ class XAIProvider(BaseProvider):
327
327
 
328
328
  if choices and len(choices) > 0:
329
329
  message = choices[0].get("message", {})
330
- content = message.get("content", "")
330
+ # Tool-call responses carry "content": null — coalesce to "".
331
+ content = message.get("content") or ""
331
332
  usage = data.get("usage", {})
332
333
 
333
- # Extract tool calls if present
334
+ # Extract tool calls if present. The wire protocol sends
335
+ # function.arguments as a JSON string; ToolCall normalizes
336
+ # it to a dict on construction.
334
337
  tool_calls = []
335
338
  if "tool_calls" in message:
336
- for tool_call in message["tool_calls"]:
339
+ for i, tool_call in enumerate(message["tool_calls"]):
337
340
  if tool_call.get("type") == "function":
338
341
  function = tool_call.get("function", {})
339
342
  tool_calls.append(
340
343
  ToolCall(
341
344
  name=function.get("name", ""),
342
345
  arguments=function.get("arguments", {}),
346
+ id=tool_call.get("id") or f"call_{i}",
343
347
  )
344
348
  )
345
349
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.15
3
+ Version: 0.2.20
4
4
  Summary: A unified command-line interface for multiple AI language models
5
5
  Author-email: Kellan Strong <kellan@jite.ai>
6
6
  License: MIT
@@ -132,6 +132,7 @@ tests/test_models.py
132
132
  tests/test_ollama_provider.py
133
133
  tests/test_openai_context_fit.py
134
134
  tests/test_openai_provider_tools.py
135
+ tests/test_openai_request_timeout.py
135
136
  tests/test_openrouter_provider.py
136
137
  tests/test_permission_rules.py
137
138
  tests/test_perplexity_provider.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "omnimancer-cli"
7
- version = "0.2.15"
7
+ version = "0.2.20"
8
8
  description = "A unified command-line interface for multiple AI language models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -591,68 +591,117 @@ class TestStreamEvent:
591
591
 
592
592
  event = StreamEvent(type=StreamEventType.ERROR, error="Connection lost")
593
593
  assert event.error == "Connection lost"
594
-
595
-
596
- class TestParseDescribedToolCalls:
597
- """parse_described_tool_calls recovers calls a model emitted as text.
598
-
599
- Weak models mimic the "[Called tools: ...]" history notation instead of
600
- issuing native tool calls; the parser turns that text back into ToolCall
601
- objects so the agent loop can keep going.
602
- """
603
-
604
- def test_round_trips_describe_output(self):
605
- from omnimancer.core.models import (
606
- ToolCall,
607
- describe_tool_calls,
608
- parse_described_tool_calls,
609
- )
610
-
611
- calls = [
612
- ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
613
- ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
614
- ]
615
- parsed = parse_described_tool_calls(describe_tool_calls(calls))
616
-
617
- assert [(c.name, c.arguments) for c in parsed] == [
618
- (c.name, c.arguments) for c in calls
619
- ]
620
-
621
- def test_plain_text_returns_empty(self):
622
- from omnimancer.core.models import parse_described_tool_calls
623
-
624
- assert parse_described_tool_calls("The auth flow looks correct.") == []
625
- assert parse_described_tool_calls("") == []
626
- assert parse_described_tool_calls(None) == []
627
-
628
- def test_nested_json_with_braces_and_commas(self):
629
- from omnimancer.core.models import parse_described_tool_calls
630
-
631
- text = (
632
- '[Called tools: Grep({"glob": "*.{ts,tsx}", '
633
- '"output_mode": "files_with_matches", '
634
- '"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
635
- )
636
- parsed = parse_described_tool_calls(text)
637
-
638
- assert len(parsed) == 1
639
- assert parsed[0].name == "Grep"
640
- assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
641
-
642
- def test_marker_embedded_in_other_text(self):
643
- from omnimancer.core.models import parse_described_tool_calls
644
-
645
- text = (
646
- "Let me search for that.\n"
647
- '[Called tools: Bash({"command": "git log --oneline -5"})]'
648
- )
649
- parsed = parse_described_tool_calls(text)
650
-
651
- assert len(parsed) == 1
652
- assert parsed[0].name == "Bash"
653
- assert parsed[0].arguments == {"command": "git log --oneline -5"}
654
-
655
- def test_malformed_json_is_skipped(self):
656
- from omnimancer.core.models import parse_described_tool_calls
657
-
658
- assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
594
+
595
+
596
+ class TestParseDescribedToolCalls:
597
+ """parse_described_tool_calls recovers calls a model emitted as text.
598
+
599
+ Weak models mimic the "[Called tools: ...]" history notation instead of
600
+ issuing native tool calls; the parser turns that text back into ToolCall
601
+ objects so the agent loop can keep going.
602
+ """
603
+
604
+ def test_round_trips_describe_output(self):
605
+ from omnimancer.core.models import (
606
+ ToolCall,
607
+ describe_tool_calls,
608
+ parse_described_tool_calls,
609
+ )
610
+
611
+ calls = [
612
+ ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
613
+ ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
614
+ ]
615
+ parsed = parse_described_tool_calls(describe_tool_calls(calls))
616
+
617
+ assert [(c.name, c.arguments) for c in parsed] == [
618
+ (c.name, c.arguments) for c in calls
619
+ ]
620
+
621
+ def test_plain_text_returns_empty(self):
622
+ from omnimancer.core.models import parse_described_tool_calls
623
+
624
+ assert parse_described_tool_calls("The auth flow looks correct.") == []
625
+ assert parse_described_tool_calls("") == []
626
+ assert parse_described_tool_calls(None) == []
627
+
628
+ def test_nested_json_with_braces_and_commas(self):
629
+ from omnimancer.core.models import parse_described_tool_calls
630
+
631
+ text = (
632
+ '[Called tools: Grep({"glob": "*.{ts,tsx}", '
633
+ '"output_mode": "files_with_matches", '
634
+ '"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
635
+ )
636
+ parsed = parse_described_tool_calls(text)
637
+
638
+ assert len(parsed) == 1
639
+ assert parsed[0].name == "Grep"
640
+ assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
641
+
642
+ def test_marker_embedded_in_other_text(self):
643
+ from omnimancer.core.models import parse_described_tool_calls
644
+
645
+ text = (
646
+ "Let me search for that.\n"
647
+ '[Called tools: Bash({"command": "git log --oneline -5"})]'
648
+ )
649
+ parsed = parse_described_tool_calls(text)
650
+
651
+ assert len(parsed) == 1
652
+ assert parsed[0].name == "Bash"
653
+ assert parsed[0].arguments == {"command": "git log --oneline -5"}
654
+
655
+ def test_malformed_json_is_skipped(self):
656
+ from omnimancer.core.models import parse_described_tool_calls
657
+
658
+ assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
659
+
660
+
661
+ class TestToolCallArgumentNormalization:
662
+ """ToolCall normalizes arguments that arrive as JSON-encoded strings.
663
+
664
+ OpenAI-protocol servers send function.arguments as a JSON string (and
665
+ some models double-encode it). Providers that passed the raw value
666
+ through crashed the tool handler with "'str' object has no attribute
667
+ 'get'".
668
+ """
669
+
670
+ def test_json_string_arguments_become_dict(self):
671
+ from omnimancer.core.models import ToolCall
672
+
673
+ tc = ToolCall(name="Glob", arguments='{"pattern": "**/*.py"}')
674
+ assert tc.arguments == {"pattern": "**/*.py"}
675
+
676
+ def test_double_encoded_arguments_become_dict(self):
677
+ import json
678
+
679
+ from omnimancer.core.models import ToolCall
680
+
681
+ double = json.dumps(json.dumps({"pattern": "TODO"}))
682
+ tc = ToolCall(name="Grep", arguments=double)
683
+ assert tc.arguments == {"pattern": "TODO"}
684
+
685
+ def test_none_arguments_become_empty_dict(self):
686
+ from omnimancer.core.models import ToolCall
687
+
688
+ tc = ToolCall(name="Read", arguments=None)
689
+ assert tc.arguments == {}
690
+
691
+ def test_dict_arguments_unchanged(self):
692
+ from omnimancer.core.models import ToolCall
693
+
694
+ tc = ToolCall(name="Read", arguments={"file_path": "/src/main.py"})
695
+ assert tc.arguments == {"file_path": "/src/main.py"}
696
+
697
+ def test_unparseable_string_left_for_handler_to_report(self):
698
+ from omnimancer.core.models import ToolCall
699
+
700
+ tc = ToolCall(name="Glob", arguments="not json at all")
701
+ assert tc.arguments == "not json at all"
702
+
703
+ def test_non_object_json_left_for_handler_to_report(self):
704
+ from omnimancer.core.models import ToolCall
705
+
706
+ tc = ToolCall(name="Glob", arguments="[1, 2, 3]")
707
+ assert tc.arguments == "[1, 2, 3]"