mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-06 21:02:07 +00:00
fix(notifications): give a local AI 60 seconds to rewrite a notification
This commit is contained in:
@@ -24,9 +24,10 @@ class OpenAIProvider(AIProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "openai"
|
NAME = "openai"
|
||||||
# The Monitor gives any rewrite 25 s; a local model behind an
|
# The Monitor gives a cloud rewrite 25 s and one on the user's own server
|
||||||
# OpenAI-compatible endpoint may need all of it.
|
# 60 s; each request ends a little before its limit.
|
||||||
REQUEST_TIMEOUT_SECONDS = 25
|
REQUEST_TIMEOUT_SECONDS = 25
|
||||||
|
LOCAL_REQUEST_TIMEOUT_SECONDS = 55
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
|
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
|
||||||
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
|
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
|
||||||
@@ -229,7 +230,9 @@ class OpenAIProvider(AIProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
api_url = self._get_api_url()
|
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:
|
try:
|
||||||
return result['choices'][0]['message']['content'].strip()
|
return result['choices'][0]['message']['content'].strip()
|
||||||
|
|||||||
@@ -805,6 +805,18 @@ class BurstAggregator:
|
|||||||
# on the rewrite at AI_REWRITE_TIMEOUT_SECONDS and ship the non-AI version.
|
# on the rewrite at AI_REWRITE_TIMEOUT_SECONDS and ship the non-AI version.
|
||||||
# Audit Tier 3.2 #2.
|
# Audit Tier 3.2 #2.
|
||||||
AI_REWRITE_TIMEOUT_SECONDS = 25
|
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
|
# 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
|
# 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 ─────────────────────────────────────────
|
# ─── Notification Manager ─────────────────────────────────────────
|
||||||
|
|
||||||
def _format_backup_with_ai_bounded(*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, **kwargs)
|
result = _format_with_ai_bounded(format_backup_diagnostics_with_ai, *args, timeout=timeout, **kwargs)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
return format_backup_diagnostics_with_ai(*args, **kwargs, fallback_only=True)
|
return format_backup_diagnostics_with_ai(*args, **kwargs, fallback_only=True)
|
||||||
@@ -1488,6 +1500,7 @@ class NotificationManager:
|
|||||||
ai_result = _format_backup_with_ai_bounded(
|
ai_result = _format_backup_with_ai_bounded(
|
||||||
ch_title, ch_body, severity, channel_ai_config,
|
ch_title, ch_body, severity, channel_ai_config,
|
||||||
event_type=event_type, data=data,
|
event_type=event_type, data=data,
|
||||||
|
timeout=_ai_rewrite_timeout(channel_ai_config),
|
||||||
detail_level=detail_level, use_emojis=use_rich_format,
|
detail_level=detail_level, use_emojis=use_rich_format,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1508,6 +1521,7 @@ class NotificationManager:
|
|||||||
ai_result = _format_with_ai_bounded(
|
ai_result = _format_with_ai_bounded(
|
||||||
format_with_ai_full,
|
format_with_ai_full,
|
||||||
ch_title, ch_body, severity, channel_ai_config,
|
ch_title, ch_body, severity, channel_ai_config,
|
||||||
|
timeout=_ai_rewrite_timeout(channel_ai_config),
|
||||||
detail_level=detail_level,
|
detail_level=detail_level,
|
||||||
journal_context=enriched_context,
|
journal_context=enriched_context,
|
||||||
use_emojis=use_rich_format,
|
use_emojis=use_rich_format,
|
||||||
@@ -2590,6 +2604,7 @@ class NotificationManager:
|
|||||||
ai_result = _format_backup_with_ai_bounded(
|
ai_result = _format_backup_with_ai_bounded(
|
||||||
title, message, severity, channel_ai_config,
|
title, message, severity, channel_ai_config,
|
||||||
event_type=event_type, data=data,
|
event_type=event_type, data=data,
|
||||||
|
timeout=_ai_rewrite_timeout(channel_ai_config),
|
||||||
detail_level=detail_level, use_emojis=use_rich_format,
|
detail_level=detail_level, use_emojis=use_rich_format,
|
||||||
)
|
)
|
||||||
ch_title = title
|
ch_title = title
|
||||||
|
|||||||
@@ -120,13 +120,31 @@ class OpenAICompatibleModelTests(unittest.TestCase):
|
|||||||
self.assertNotIn("reasoning_effort", provider.captured_payload)
|
self.assertNotIn("reasoning_effort", provider.captured_payload)
|
||||||
self.assertNotIn("max_completion_tokens", 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):
|
def test_a_local_request_waits_longer_but_ends_before_the_monitor_limit(self):
|
||||||
# A local model behind an OpenAI-compatible endpoint is slow; the
|
# A local model behind an OpenAI-compatible endpoint is slow: its request
|
||||||
# request must not give up before the Monitor's own limit.
|
# 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",
|
provider = CapturingOpenAIProvider(api_key="token", model="gemma-3-1b-it-Q4_K_M",
|
||||||
base_url="http://192.0.2.22:8080/v1")
|
base_url="http://192.0.2.22:8080/v1")
|
||||||
provider.generate("system", "user", max_tokens=100)
|
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):
|
def test_official_openai_reasoning_model_still_uses_reasoning_contract(self):
|
||||||
provider = CapturingOpenAIProvider(
|
provider = CapturingOpenAIProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user