diff --git a/AppImage/scripts/ai_providers/openai_provider.py b/AppImage/scripts/ai_providers/openai_provider.py index 8ff3dedf..e1f60f87 100644 --- a/AppImage/scripts/ai_providers/openai_provider.py +++ b/AppImage/scripts/ai_providers/openai_provider.py @@ -24,9 +24,10 @@ class OpenAIProvider(AIProvider): """ NAME = "openai" - # The Monitor gives any rewrite 25 s; a local model behind an - # OpenAI-compatible endpoint may need all of it. + # The Monitor gives a cloud rewrite 25 s and one on the user's own server + # 60 s; each request ends a little before its limit. REQUEST_TIMEOUT_SECONDS = 25 + LOCAL_REQUEST_TIMEOUT_SECONDS = 55 REQUIRES_API_KEY = True DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions" DEFAULT_MODELS_URL = "https://api.openai.com/v1/models" @@ -229,7 +230,9 @@ class OpenAIProvider(AIProvider): } api_url = self._get_api_url() - result = self._make_request(api_url, payload, headers, timeout=self.REQUEST_TIMEOUT_SECONDS) + local = bool(self.base_url) and 'api.openai.com' not in self.base_url + timeout = self.LOCAL_REQUEST_TIMEOUT_SECONDS if local else self.REQUEST_TIMEOUT_SECONDS + result = self._make_request(api_url, payload, headers, timeout=timeout) try: return result['choices'][0]['message']['content'].strip() diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index c2e30a26..b3ed6084 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -805,6 +805,18 @@ class BurstAggregator: # on the rewrite at AI_REWRITE_TIMEOUT_SECONDS and ship the non-AI version. # Audit Tier 3.2 #2. AI_REWRITE_TIMEOUT_SECONDS = 25 +# A model on the user's own hardware (Ollama, or an OpenAI-compatible server +# at their own address) is much slower than a cloud API. A notification that +# arrives a little later but translated is worth more than one in English. +LOCAL_AI_REWRITE_TIMEOUT_SECONDS = 60 + + +def _ai_rewrite_timeout(ai_config): + provider = str((ai_config or {}).get('ai_provider') or '') + base_url = str((ai_config or {}).get('ai_openai_base_url') or '').strip() + if provider == 'ollama' or (provider == 'openai' and base_url and 'api.openai.com' not in base_url): + return LOCAL_AI_REWRITE_TIMEOUT_SECONDS + return AI_REWRITE_TIMEOUT_SECONDS # A small dedicated pool so an in-flight AI call never starves the dispatch # thread itself. One worker is enough — events serialize through the dispatch @@ -835,8 +847,8 @@ def _format_with_ai_bounded(format_fn, *args, timeout=AI_REWRITE_TIMEOUT_SECONDS # ─── Notification Manager ───────────────────────────────────────── -def _format_backup_with_ai_bounded(*args, **kwargs): - result = _format_with_ai_bounded(format_backup_diagnostics_with_ai, *args, **kwargs) +def _format_backup_with_ai_bounded(*args, timeout=AI_REWRITE_TIMEOUT_SECONDS, **kwargs): + result = _format_with_ai_bounded(format_backup_diagnostics_with_ai, *args, timeout=timeout, **kwargs) if result is not None: return result return format_backup_diagnostics_with_ai(*args, **kwargs, fallback_only=True) @@ -1488,6 +1500,7 @@ class NotificationManager: ai_result = _format_backup_with_ai_bounded( ch_title, ch_body, severity, channel_ai_config, event_type=event_type, data=data, + timeout=_ai_rewrite_timeout(channel_ai_config), detail_level=detail_level, use_emojis=use_rich_format, ) else: @@ -1508,6 +1521,7 @@ class NotificationManager: ai_result = _format_with_ai_bounded( format_with_ai_full, ch_title, ch_body, severity, channel_ai_config, + timeout=_ai_rewrite_timeout(channel_ai_config), detail_level=detail_level, journal_context=enriched_context, use_emojis=use_rich_format, @@ -2590,6 +2604,7 @@ class NotificationManager: ai_result = _format_backup_with_ai_bounded( title, message, severity, channel_ai_config, event_type=event_type, data=data, + timeout=_ai_rewrite_timeout(channel_ai_config), detail_level=detail_level, use_emojis=use_rich_format, ) ch_title = title diff --git a/AppImage/scripts/tests/test_openai_compatible_ai_models.py b/AppImage/scripts/tests/test_openai_compatible_ai_models.py index 1c8441a8..898e5ab5 100644 --- a/AppImage/scripts/tests/test_openai_compatible_ai_models.py +++ b/AppImage/scripts/tests/test_openai_compatible_ai_models.py @@ -120,13 +120,31 @@ class OpenAICompatibleModelTests(unittest.TestCase): self.assertNotIn("reasoning_effort", provider.captured_payload) self.assertNotIn("max_completion_tokens", provider.captured_payload) - def test_a_request_waits_as_long_as_the_monitor_allows_a_rewrite(self): - # A local model behind an OpenAI-compatible endpoint is slow; the - # request must not give up before the Monitor's own limit. + def test_a_local_request_waits_longer_but_ends_before_the_monitor_limit(self): + # A local model behind an OpenAI-compatible endpoint is slow: its request + # gets more than a cloud one and still ends before the rewrite limit. provider = CapturingOpenAIProvider(api_key="token", model="gemma-3-1b-it-Q4_K_M", base_url="http://192.0.2.22:8080/v1") provider.generate("system", "user", max_tokens=100) - self.assertEqual(provider.captured_timeout, notification_manager.AI_REWRITE_TIMEOUT_SECONDS) + self.assertGreater(provider.captured_timeout, notification_manager.AI_REWRITE_TIMEOUT_SECONDS) + self.assertLess(provider.captured_timeout, notification_manager.LOCAL_AI_REWRITE_TIMEOUT_SECONDS) + + def test_a_cloud_request_keeps_the_short_limit(self): + provider = CapturingOpenAIProvider(api_key="token", model="gpt-5-mini") + provider.generate("system", "user", max_tokens=100) + self.assertLess(provider.captured_timeout, notification_manager.LOCAL_AI_REWRITE_TIMEOUT_SECONDS) + self.assertLessEqual(provider.captured_timeout, notification_manager.AI_REWRITE_TIMEOUT_SECONDS) + + def test_the_rewrite_limit_follows_where_the_model_runs(self): + limit = notification_manager._ai_rewrite_timeout + local = notification_manager.LOCAL_AI_REWRITE_TIMEOUT_SECONDS + cloud = notification_manager.AI_REWRITE_TIMEOUT_SECONDS + self.assertEqual(limit({'ai_provider': 'ollama', 'ai_ollama_url': 'http://localhost:11434'}), local) + self.assertEqual(limit({'ai_provider': 'openai', 'ai_openai_base_url': 'http://192.0.2.22:8080/v1'}), local) + self.assertEqual(limit({'ai_provider': 'openai', 'ai_openai_base_url': ''}), cloud) + self.assertEqual(limit({'ai_provider': 'openai', 'ai_openai_base_url': 'https://api.openai.com/v1'}), cloud) + for provider in ('groq', 'gemini', 'anthropic', 'openrouter'): + self.assertEqual(limit({'ai_provider': provider}), cloud) def test_official_openai_reasoning_model_still_uses_reasoning_contract(self): provider = CapturingOpenAIProvider(