omnimancer-cli 0.2.19__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.19/omnimancer_cli.egg-info → omnimancer_cli-0.2.20}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/base.py +30 -0
  3. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/openai.py +32 -12
  4. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/openrouter.py +5 -2
  5. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20/omnimancer_cli.egg-info}/PKG-INFO +1 -1
  6. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/SOURCES.txt +1 -0
  7. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/pyproject.toml +1 -1
  8. omnimancer_cli-0.2.20/tests/test_openai_request_timeout.py +129 -0
  9. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/LICENSE +0 -0
  10. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/README.md +0 -0
  11. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/__init__.py +0 -0
  12. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/__main__.py +0 -0
  13. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/__init__.py +0 -0
  14. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/agent_loop.py +0 -0
  15. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_formatter.py +0 -0
  16. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_integration.py +0 -0
  17. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_prompt.py +0 -0
  18. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/command_dispatch.py +0 -0
  19. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/commands.py +0 -0
  20. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/completion.py +0 -0
  21. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/display.py +0 -0
  22. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/dynamic_commands.py +0 -0
  23. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/handlers/__init__.py +0 -0
  24. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/headless.py +0 -0
  25. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/interface.py +0 -0
  26. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/subagent.py +0 -0
  27. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/system_prompts.py +0 -0
  28. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/cli/tool_handler.py +0 -0
  29. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/__init__.py +0 -0
  30. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/__init__.py +0 -0
  31. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_context.py +0 -0
  32. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_interface.py +0 -0
  33. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_manager.py +0 -0
  34. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/config.py +0 -0
  35. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/file_system_manager.py +0 -0
  36. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/program_executor.py +0 -0
  37. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  38. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_core.py +0 -0
  39. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_manager.py +0 -0
  40. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/tool_definitions.py +0 -0
  41. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/types.py +0 -0
  42. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/web_client.py +0 -0
  43. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  44. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent_engine.py +0 -0
  45. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent_managers.py +0 -0
  46. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/agent_mode_manager.py +0 -0
  47. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/chat_manager.py +0 -0
  48. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/config_manager.py +0 -0
  49. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/config_migration.py +0 -0
  50. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/config_validator.py +0 -0
  51. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/conversation_manager.py +0 -0
  52. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/engine.py +0 -0
  53. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/env_loader.py +0 -0
  54. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/fallback_manager.py +0 -0
  55. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/health_monitor.py +0 -0
  56. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/history_manager.py +0 -0
  57. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/hooks.py +0 -0
  58. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/mcp_integration_layer.py +0 -0
  59. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/models.py +0 -0
  60. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/provider_capabilities.py +0 -0
  61. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/provider_initializer.py +0 -0
  62. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/provider_registry.py +0 -0
  63. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/rate_limit_fallback.py +0 -0
  64. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/__init__.py +0 -0
  65. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/approval_workflow.py +0 -0
  66. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/audit_logger.py +0 -0
  67. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_controller.py +0 -0
  68. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_rules.py +0 -0
  69. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/sandbox_manager.py +0 -0
  70. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/security/security_manager.py +0 -0
  71. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/core/signal_handler.py +0 -0
  72. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/mcp/__init__.py +0 -0
  73. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/mcp/client.py +0 -0
  74. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/mcp/manager.py +0 -0
  75. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/__init__.py +0 -0
  76. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/azure.py +0 -0
  77. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/bedrock.py +0 -0
  78. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/claude.py +0 -0
  79. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_code.py +0 -0
  80. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_credentials.py +0 -0
  81. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/cohere.py +0 -0
  82. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/digitalocean.py +0 -0
  83. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/factory.py +0 -0
  84. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/gemini.py +0 -0
  85. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/mistral.py +0 -0
  86. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/ollama.py +0 -0
  87. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/perplexity.py +0 -0
  88. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/vertex.py +0 -0
  89. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/providers/xai.py +0 -0
  90. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/ui/__init__.py +0 -0
  91. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/ui/cancellation_handler.py +0 -0
  92. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/ui/progress_indicator.py +0 -0
  93. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/ui/streaming_display.py +0 -0
  94. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/utils/__init__.py +0 -0
  95. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/utils/error_handler.py +0 -0
  96. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/utils/errors.py +0 -0
  97. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer/utils/retry.py +0 -0
  98. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  99. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  100. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/requires.txt +0 -0
  101. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/top_level.txt +0 -0
  102. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/setup.cfg +0 -0
  103. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_approval_integration_compatibility.py +0 -0
  104. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_approval_preview_format.py +0 -0
  105. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_approval_remember.py +0 -0
  106. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_approval_system.py +0 -0
  107. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_azure_provider.py +0 -0
  108. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_bedrock_provider.py +0 -0
  109. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_claude_code_provider.py +0 -0
  110. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_claude_credentials.py +0 -0
  111. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_claude_provider.py +0 -0
  112. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_cohere_provider.py +0 -0
  113. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_command_approval_security.py +0 -0
  114. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_config_comprehensive.py +0 -0
  115. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_conversation_history.py +0 -0
  116. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_conversation_manager.py +0 -0
  117. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_core_engine.py +0 -0
  118. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_digitalocean_provider.py +0 -0
  119. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_enhanced_model_info.py +0 -0
  120. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_env_loader.py +0 -0
  121. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_error_handling_robustness.py +0 -0
  122. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_execution_timeout_errors.py +0 -0
  123. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_fallback_manager.py +0 -0
  124. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_fixtures.py +0 -0
  125. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_gemini_provider.py +0 -0
  126. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_hooks.py +0 -0
  127. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_httpx_compatibility.py +0 -0
  128. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_import_validation.py +0 -0
  129. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_interface_smoke_tests.py +0 -0
  130. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_mcp_integration.py +0 -0
  131. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_mcp_integration_layer.py +0 -0
  132. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_mcp_management_commands.py +0 -0
  133. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_mistral_provider.py +0 -0
  134. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_models.py +0 -0
  135. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_ollama_provider.py +0 -0
  136. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_openai_context_fit.py +0 -0
  137. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_openai_provider_tools.py +0 -0
  138. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_openrouter_provider.py +0 -0
  139. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_permission_rules.py +0 -0
  140. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_perplexity_provider.py +0 -0
  141. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_program_executor.py +0 -0
  142. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_provider_base.py +0 -0
  143. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_provider_capability_consistency.py +0 -0
  144. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_provider_discovery.py +0 -0
  145. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_provider_integration.py +0 -0
  146. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_provider_registry.py +0 -0
  147. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_rate_limit_fallback.py +0 -0
  148. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_retry.py +0 -0
  149. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_security_validation.py +0 -0
  150. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_system_integration.py +0 -0
  151. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_system_prompts.py +0 -0
  152. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_tool_calling_integration.py +0 -0
  153. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_tool_handler.py +0 -0
  154. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_vertex_provider.py +0 -0
  155. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_web_request_markers.py +0 -0
  156. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/tests/test_workflows_comprehensive.py +0 -0
  157. {omnimancer_cli-0.2.19 → omnimancer_cli-0.2.20}/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.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
@@ -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.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.19"
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"
@@ -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