omnimancer-cli 0.2.19__tar.gz → 0.2.22__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.19/omnimancer_cli.egg-info → omnimancer_cli-0.2.22}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/headless.py +77 -4
  3. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/base.py +30 -0
  4. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/openai.py +32 -12
  5. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/openrouter.py +5 -2
  6. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22/omnimancer_cli.egg-info}/PKG-INFO +1 -1
  7. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer_cli.egg-info/SOURCES.txt +1 -0
  8. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/pyproject.toml +1 -1
  9. omnimancer_cli-0.2.22/tests/test_openai_request_timeout.py +129 -0
  10. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/LICENSE +0 -0
  11. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/README.md +0 -0
  12. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/__init__.py +0 -0
  13. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/__main__.py +0 -0
  14. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/__init__.py +0 -0
  15. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/agent_loop.py +0 -0
  16. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/approval_formatter.py +0 -0
  17. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/approval_integration.py +0 -0
  18. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/approval_prompt.py +0 -0
  19. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/command_dispatch.py +0 -0
  20. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/commands.py +0 -0
  21. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/completion.py +0 -0
  22. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/display.py +0 -0
  23. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/dynamic_commands.py +0 -0
  24. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/handlers/__init__.py +0 -0
  25. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/interface.py +0 -0
  26. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/subagent.py +0 -0
  27. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/system_prompts.py +0 -0
  28. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/cli/tool_handler.py +0 -0
  29. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/__init__.py +0 -0
  30. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/__init__.py +0 -0
  31. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/approval_context.py +0 -0
  32. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/approval_interface.py +0 -0
  33. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/approval_manager.py +0 -0
  34. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/config.py +0 -0
  35. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/file_system_manager.py +0 -0
  36. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/program_executor.py +0 -0
  37. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  38. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/status_core.py +0 -0
  39. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/status_manager.py +0 -0
  40. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/tool_definitions.py +0 -0
  41. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/types.py +0 -0
  42. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/web_client.py +0 -0
  43. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  44. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent_engine.py +0 -0
  45. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent_managers.py +0 -0
  46. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/agent_mode_manager.py +0 -0
  47. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/chat_manager.py +0 -0
  48. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/config_manager.py +0 -0
  49. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/config_migration.py +0 -0
  50. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/config_validator.py +0 -0
  51. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/conversation_manager.py +0 -0
  52. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/engine.py +0 -0
  53. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/env_loader.py +0 -0
  54. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/fallback_manager.py +0 -0
  55. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/health_monitor.py +0 -0
  56. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/history_manager.py +0 -0
  57. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/hooks.py +0 -0
  58. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/mcp_integration_layer.py +0 -0
  59. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/models.py +0 -0
  60. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/provider_capabilities.py +0 -0
  61. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/provider_initializer.py +0 -0
  62. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/provider_registry.py +0 -0
  63. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/rate_limit_fallback.py +0 -0
  64. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/__init__.py +0 -0
  65. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/approval_workflow.py +0 -0
  66. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/audit_logger.py +0 -0
  67. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/permission_controller.py +0 -0
  68. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/permission_rules.py +0 -0
  69. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/sandbox_manager.py +0 -0
  70. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/security/security_manager.py +0 -0
  71. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/core/signal_handler.py +0 -0
  72. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/mcp/__init__.py +0 -0
  73. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/mcp/client.py +0 -0
  74. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/mcp/manager.py +0 -0
  75. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/__init__.py +0 -0
  76. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/azure.py +0 -0
  77. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/bedrock.py +0 -0
  78. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/claude.py +0 -0
  79. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/claude_code.py +0 -0
  80. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/claude_credentials.py +0 -0
  81. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/cohere.py +0 -0
  82. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/digitalocean.py +0 -0
  83. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/factory.py +0 -0
  84. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/gemini.py +0 -0
  85. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/mistral.py +0 -0
  86. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/ollama.py +0 -0
  87. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/perplexity.py +0 -0
  88. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/vertex.py +0 -0
  89. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/providers/xai.py +0 -0
  90. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/ui/__init__.py +0 -0
  91. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/ui/cancellation_handler.py +0 -0
  92. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/ui/progress_indicator.py +0 -0
  93. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/ui/streaming_display.py +0 -0
  94. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/utils/__init__.py +0 -0
  95. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/utils/error_handler.py +0 -0
  96. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/utils/errors.py +0 -0
  97. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer/utils/retry.py +0 -0
  98. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  99. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  100. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer_cli.egg-info/requires.txt +0 -0
  101. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/omnimancer_cli.egg-info/top_level.txt +0 -0
  102. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/setup.cfg +0 -0
  103. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_approval_integration_compatibility.py +0 -0
  104. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_approval_preview_format.py +0 -0
  105. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_approval_remember.py +0 -0
  106. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_approval_system.py +0 -0
  107. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_azure_provider.py +0 -0
  108. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_bedrock_provider.py +0 -0
  109. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_claude_code_provider.py +0 -0
  110. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_claude_credentials.py +0 -0
  111. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_claude_provider.py +0 -0
  112. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_cohere_provider.py +0 -0
  113. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_command_approval_security.py +0 -0
  114. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_config_comprehensive.py +0 -0
  115. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_conversation_history.py +0 -0
  116. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_conversation_manager.py +0 -0
  117. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_core_engine.py +0 -0
  118. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_digitalocean_provider.py +0 -0
  119. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_enhanced_model_info.py +0 -0
  120. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_env_loader.py +0 -0
  121. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_error_handling_robustness.py +0 -0
  122. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_execution_timeout_errors.py +0 -0
  123. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_fallback_manager.py +0 -0
  124. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_fixtures.py +0 -0
  125. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_gemini_provider.py +0 -0
  126. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_hooks.py +0 -0
  127. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_httpx_compatibility.py +0 -0
  128. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_import_validation.py +0 -0
  129. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_interface_smoke_tests.py +0 -0
  130. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_mcp_integration.py +0 -0
  131. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_mcp_integration_layer.py +0 -0
  132. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_mcp_management_commands.py +0 -0
  133. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_mistral_provider.py +0 -0
  134. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_models.py +0 -0
  135. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_ollama_provider.py +0 -0
  136. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_openai_context_fit.py +0 -0
  137. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_openai_provider_tools.py +0 -0
  138. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_openrouter_provider.py +0 -0
  139. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_permission_rules.py +0 -0
  140. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_perplexity_provider.py +0 -0
  141. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_program_executor.py +0 -0
  142. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_provider_base.py +0 -0
  143. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_provider_capability_consistency.py +0 -0
  144. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_provider_discovery.py +0 -0
  145. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_provider_integration.py +0 -0
  146. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_provider_registry.py +0 -0
  147. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_rate_limit_fallback.py +0 -0
  148. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_retry.py +0 -0
  149. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_security_validation.py +0 -0
  150. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_system_integration.py +0 -0
  151. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_system_prompts.py +0 -0
  152. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_tool_calling_integration.py +0 -0
  153. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_tool_handler.py +0 -0
  154. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_vertex_provider.py +0 -0
  155. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_web_request_markers.py +0 -0
  156. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_workflows_comprehensive.py +0 -0
  157. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.22}/tests/test_xai_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.19
3
+ Version: 0.2.22
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
@@ -5,6 +5,8 @@ results to stdout. No Rich console, no readline, no interactive UI.
5
5
  """
6
6
 
7
7
  import json
8
+ import logging
9
+ import os
8
10
  import sys
9
11
  import uuid
10
12
  from enum import Enum
@@ -24,6 +26,53 @@ from .tool_handler import (
24
26
  ToolHandler,
25
27
  )
26
28
 
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # A turn with no tool calls used to end the run immediately. Models that
32
+ # narrate their plan ("Let me look into X...") without attaching a tool call
33
+ # made headless runs "complete" with zero work done, so the runner now nudges
34
+ # the model to either keep working or explicitly declare completion.
35
+ NO_TOOL_CALL_NUDGE = (
36
+ "You did not call any tools. If the task is fully complete, reply with "
37
+ "exactly DONE. Otherwise continue working now by calling tools — do not "
38
+ "describe what you plan to do without doing it."
39
+ )
40
+
41
+ # Consecutive tool-less turns tolerated before the run is ended anyway.
42
+ MAX_NO_TOOL_NUDGES = 2
43
+
44
+
45
+ def _is_done_reply(content: Optional[str]) -> bool:
46
+ """True when the model explicitly declares completion per the nudge."""
47
+ if not content:
48
+ return False
49
+ lines = content.strip().splitlines()
50
+ if not lines:
51
+ return False
52
+ return lines[-1].strip().rstrip(".!").upper() == "DONE"
53
+
54
+
55
+ def _resolve_max_iterations(explicit: Optional[int]) -> int:
56
+ """Resolve the tool-iteration cap: explicit value wins, then the
57
+ OMNIMANCER_MAX_ITERATIONS environment variable, then the default.
58
+
59
+ The default (25) suits interactive use; orchestrators running real
60
+ coding tasks inject a higher cap — exhausting it mid-task previously
61
+ looked like a clean "completed" run with no changes.
62
+ """
63
+ if explicit:
64
+ return explicit
65
+ raw = os.environ.get("OMNIMANCER_MAX_ITERATIONS")
66
+ if raw:
67
+ try:
68
+ value = int(raw)
69
+ if value > 0:
70
+ return value
71
+ logger.warning("Ignoring non-positive OMNIMANCER_MAX_ITERATIONS %r", raw)
72
+ except ValueError:
73
+ logger.warning("Ignoring invalid OMNIMANCER_MAX_ITERATIONS %r", raw)
74
+ return MAX_TOOL_ITERATIONS
75
+
27
76
 
28
77
  class OutputFormat(Enum):
29
78
  TEXT = "text"
@@ -229,7 +278,7 @@ class HeadlessRunner:
229
278
  self._verbose = verbose
230
279
  # Headless runs are unattended, so a cap (not a check-in prompt) is
231
280
  # the safety net; --max-iterations sizes it to the job.
232
- self._max_iterations = max_iterations or MAX_TOOL_ITERATIONS
281
+ self._max_iterations = _resolve_max_iterations(max_iterations)
233
282
  session_id = str(uuid.uuid4())
234
283
  self._emitter = HeadlessOutputEmitter(output_format, session_id, verbose)
235
284
  self._tokens = TokenAccumulator()
@@ -296,6 +345,7 @@ class HeadlessRunner:
296
345
 
297
346
  current_message = f"{agent_prompt}\n\nUser: {prompt}"
298
347
  last_content = ""
348
+ prev_content = ""
299
349
  last_model = model
300
350
  last_stop_reason = "end_turn"
301
351
  # Record of the actions the agent took, surfaced in the JSON result.
@@ -304,6 +354,7 @@ class HeadlessRunner:
304
354
  # Identical repeated calls get skipped with a corrective nudge; the
305
355
  # run is aborted only if the model keeps repeating despite nudges.
306
356
  repeat_tracker = RepeatedCallTracker()
357
+ no_tool_nudges = 0
307
358
 
308
359
  for iteration in range(self._max_iterations):
309
360
  turns = iteration + 1
@@ -322,6 +373,7 @@ class HeadlessRunner:
322
373
  last_stop_reason = response.stop_reason or "end_turn"
323
374
 
324
375
  if response.content:
376
+ prev_content = last_content
325
377
  last_content = response.content
326
378
  self._emitter.emit_assistant(
327
379
  response.content,
@@ -334,9 +386,30 @@ class HeadlessRunner:
334
386
  # those so the run doesn't end with the work undone.
335
387
  if not response.tool_calls:
336
388
  recovered = parse_described_tool_calls(response.content)
337
- if not recovered:
338
- break
339
- response.tool_calls = recovered
389
+ if recovered:
390
+ response.tool_calls = recovered
391
+ else:
392
+ # No tool call: either the final answer or narration from
393
+ # a model that stopped without acting. End the run only on
394
+ # an explicit DONE or after repeated nudges — breaking
395
+ # immediately made agents "complete" with zero changes.
396
+ if (
397
+ _is_done_reply(response.content)
398
+ or no_tool_nudges >= MAX_NO_TOOL_NUDGES
399
+ ):
400
+ # A bare "DONE" acknowledgment must not replace the
401
+ # model's actual final summary in the result payload.
402
+ if (
403
+ response.content
404
+ and response.content.strip().rstrip(".!").upper() == "DONE"
405
+ and prev_content
406
+ ):
407
+ last_content = prev_content
408
+ break
409
+ no_tool_nudges += 1
410
+ current_message = NO_TOOL_CALL_NUDGE
411
+ continue
412
+ no_tool_nudges = 0
340
413
 
341
414
  repeat_tracker.record(response.tool_calls)
342
415
  offender = repeat_tracker.abort_offender(response.tool_calls)
@@ -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.
@@ -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)
@@ -414,7 +434,7 @@ class OpenAIProvider(BaseProvider):
414
434
  if tools:
415
435
  request_body["tools"] = tools
416
436
 
417
- response = await self._post_chat(request_body, timeout=60.0)
437
+ response = await self._post_chat(request_body, timeout=self.request_timeout)
418
438
 
419
439
  return self._handle_response_with_tools(response)
420
440
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.19
3
+ Version: 0.2.22
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.19"
7
+ version = "0.2.22"
8
8
  description = "A unified command-line interface for multiple AI language models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,129 @@
1
+ """Tests for chat-completion request timeouts and timeout retry.
2
+
3
+ Covers the OpenAI provider and, by inheritance, the DigitalOcean and other
4
+ OpenAI-compatible providers. Large models on serverless backends (e.g.
5
+ DigitalOcean inference) exceed the old hardcoded 30s/60s timeouts, and the
6
+ backend also stalls sporadically — headless agent runs died with
7
+ "Request to OpenAI API timed out" and no output.
8
+ """
9
+
10
+ from unittest.mock import AsyncMock, MagicMock, patch
11
+
12
+ import httpx
13
+ import pytest
14
+
15
+ from omnimancer.core.models import ChatContext
16
+ from omnimancer.providers.digitalocean import DigitalOceanProvider
17
+ from omnimancer.providers.openai import OpenAIProvider
18
+ from omnimancer.providers.openrouter import OpenRouterProvider
19
+ from omnimancer.utils.errors import NetworkError
20
+
21
+
22
+ def _ok_response(content="done"):
23
+ resp = MagicMock()
24
+ resp.status_code = 200
25
+ resp.json = MagicMock(
26
+ return_value={
27
+ "choices": [{"message": {"content": content}, "finish_reason": "stop"}],
28
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1},
29
+ }
30
+ )
31
+ return resp
32
+
33
+
34
+ @pytest.fixture
35
+ def context():
36
+ return ChatContext(messages=[], current_model="m", session_id="s")
37
+
38
+
39
+ class TestTimeoutResolution:
40
+ def test_default(self, monkeypatch):
41
+ monkeypatch.delenv("OMNIMANCER_REQUEST_TIMEOUT", raising=False)
42
+ provider = OpenAIProvider(api_key="k")
43
+ assert provider.request_timeout == OpenAIProvider.DEFAULT_REQUEST_TIMEOUT
44
+
45
+ def test_kwarg_wins_over_env(self, monkeypatch):
46
+ monkeypatch.setenv("OMNIMANCER_REQUEST_TIMEOUT", "300")
47
+ provider = OpenAIProvider(api_key="k", request_timeout=45)
48
+ assert provider.request_timeout == 45.0
49
+
50
+ def test_env_var(self, monkeypatch):
51
+ monkeypatch.setenv("OMNIMANCER_REQUEST_TIMEOUT", "300")
52
+ provider = DigitalOceanProvider(api_key="k", model="qwen-x")
53
+ assert provider.request_timeout == 300.0
54
+
55
+ @pytest.mark.parametrize("bad", ["not-a-number", "-5", "0"])
56
+ def test_invalid_values_fall_back(self, monkeypatch, bad):
57
+ monkeypatch.setenv("OMNIMANCER_REQUEST_TIMEOUT", bad)
58
+ provider = OpenAIProvider(api_key="k")
59
+ assert provider.request_timeout == OpenAIProvider.DEFAULT_REQUEST_TIMEOUT
60
+
61
+ def test_openrouter_env_var(self, monkeypatch):
62
+ monkeypatch.setenv("OMNIMANCER_REQUEST_TIMEOUT", "300")
63
+ provider = OpenRouterProvider(api_key="k")
64
+ assert provider.request_timeout == 300.0
65
+
66
+
67
+ class TestOpenRouterTimeoutUsed:
68
+ async def test_send_message_uses_configured_timeout(self, context):
69
+ provider = OpenRouterProvider(api_key="k", request_timeout=250)
70
+ seen = []
71
+
72
+ async def fake_post(url, **kwargs):
73
+ seen.append(kwargs["timeout"])
74
+ return _ok_response()
75
+
76
+ with patch("httpx.AsyncClient") as client:
77
+ inst = client.return_value.__aenter__.return_value
78
+ inst.post = AsyncMock(side_effect=fake_post)
79
+ await provider.send_message("hi", context)
80
+
81
+ assert seen == [250]
82
+
83
+
84
+ class TestTimeoutUsedOnRequests:
85
+ async def test_send_message_uses_configured_timeout(self, context):
86
+ provider = OpenAIProvider(api_key="k", request_timeout=222)
87
+ seen = []
88
+
89
+ async def fake_post(url, **kwargs):
90
+ seen.append(kwargs["timeout"])
91
+ return _ok_response()
92
+
93
+ with patch("httpx.AsyncClient") as client:
94
+ inst = client.return_value.__aenter__.return_value
95
+ inst.post = AsyncMock(side_effect=fake_post)
96
+ await provider.send_message("hi", context)
97
+
98
+ assert seen == [222]
99
+
100
+
101
+ class TestTimeoutRetry:
102
+ async def test_retries_once_on_timeout_then_succeeds(self, context):
103
+ provider = DigitalOceanProvider(api_key="k", model="qwen-x")
104
+ calls = []
105
+
106
+ async def fake_post(url, **kwargs):
107
+ calls.append(url)
108
+ if len(calls) == 1:
109
+ raise httpx.ReadTimeout("stalled")
110
+ return _ok_response("recovered")
111
+
112
+ with patch("httpx.AsyncClient") as client:
113
+ inst = client.return_value.__aenter__.return_value
114
+ inst.post = AsyncMock(side_effect=fake_post)
115
+ result = await provider.send_message("hi", context)
116
+
117
+ assert result.content == "recovered"
118
+ assert len(calls) == 2
119
+
120
+ async def test_second_timeout_raises_network_error(self, context):
121
+ provider = DigitalOceanProvider(api_key="k", model="qwen-x")
122
+
123
+ with patch("httpx.AsyncClient") as client:
124
+ inst = client.return_value.__aenter__.return_value
125
+ inst.post = AsyncMock(side_effect=httpx.ReadTimeout("stalled"))
126
+ with pytest.raises(NetworkError):
127
+ await provider.send_message("hi", context)
128
+
129
+ assert inst.post.await_count == 2
File without changes