Add API timeout to all backends + mesh-aware system prompt

All three LLM backends (Google, OpenAI, Anthropic) now wrap API calls
in asyncio.wait_for() using config.timeout (default 30s). Previously
Gemini could hang indefinitely with grounding+AFC enabled.

Router catches TimeoutError with user-friendly "request timed out" message.
Empty context buffer now injects "[No recent mesh traffic observed yet.]"
so the LLM knows the capability exists even when buffer is empty.
Default system prompt updated to mention mesh awareness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-02-25 07:27:39 +00:00
commit 1172b9b67f
5 changed files with 54 additions and 17 deletions

View file

@ -1,5 +1,6 @@
"""Anthropic (Claude) LLM backend with rolling summary memory.""" """Anthropic (Claude) LLM backend with rolling summary memory."""
import asyncio
import logging import logging
from typing import Optional from typing import Optional
@ -114,17 +115,23 @@ class AnthropicBackend(LLMBackend):
final_messages = messages final_messages = messages
try: try:
response = await self._client.messages.create( response = await asyncio.wait_for(
self._client.messages.create(
model=self.config.model, model=self.config.model,
max_tokens=max_tokens, max_tokens=max_tokens,
system=enhanced_system, system=enhanced_system,
messages=final_messages, messages=final_messages,
),
timeout=self.config.timeout,
) )
# Extract text from response # Extract text from response
content = response.content[0].text if response.content else "" content = response.content[0].text if response.content else ""
return content.strip() return content.strip()
except asyncio.TimeoutError:
logger.error(f"Anthropic API timed out after {self.config.timeout}s")
raise
except Exception as e: except Exception as e:
logger.error(f"Anthropic API error: {e}") logger.error(f"Anthropic API error: {e}")
raise raise

View file

@ -1,5 +1,6 @@
"""Google Gemini LLM backend with rolling summary memory and Google Search grounding.""" """Google Gemini LLM backend with rolling summary memory and Google Search grounding."""
import asyncio
import logging import logging
from typing import Optional from typing import Optional
@ -53,15 +54,20 @@ class GoogleBackend(LLMBackend):
prompt = _SUMMARIZE_PROMPT.format(conversation=conversation) prompt = _SUMMARIZE_PROMPT.format(conversation=conversation)
try: try:
response = await self._client.aio.models.generate_content( response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.config.model, model=self.config.model,
contents=prompt, contents=prompt,
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
max_output_tokens=150, max_output_tokens=150,
temperature=0.3, temperature=0.3,
), ),
),
timeout=self.config.timeout,
) )
return response.text.strip() if response.text else f"Previous conversation: {len(messages)} messages." return response.text.strip() if response.text else f"Previous conversation: {len(messages)} messages."
except asyncio.TimeoutError:
logger.warning(f"Summary generation timed out after {self.config.timeout}s")
except Exception as e: except Exception as e:
logger.warning(f"Failed to generate summary: {e}") logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics." return f"Previous conversation: {len(messages)} messages about various topics."
@ -112,14 +118,20 @@ class GoogleBackend(LLMBackend):
tools=tools if tools else None, tools=tools if tools else None,
) )
response = await self._client.aio.models.generate_content( response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.config.model, model=self.config.model,
contents=contents, contents=contents,
config=config, config=config,
),
timeout=self.config.timeout,
) )
return response.text.strip() if response.text else "" return response.text.strip() if response.text else ""
except asyncio.TimeoutError:
logger.error(f"Google API timed out after {self.config.timeout}s")
raise
except Exception as e: except Exception as e:
logger.error(f"Google API error: {e}") logger.error(f"Google API error: {e}")
raise raise

View file

@ -1,5 +1,6 @@
"""OpenAI-compatible LLM backend with rolling summary memory.""" """OpenAI-compatible LLM backend with rolling summary memory."""
import asyncio
import logging import logging
from typing import Optional from typing import Optional
@ -134,11 +135,17 @@ class OpenAIBackend(LLMBackend):
if getattr(self.config, 'web_search', False): if getattr(self.config, 'web_search', False):
request_kwargs["extra_body"] = {"features": {"web_search": True}} request_kwargs["extra_body"] = {"features": {"web_search": True}}
response = await self._client.chat.completions.create(**request_kwargs) response = await asyncio.wait_for(
self._client.chat.completions.create(**request_kwargs),
timeout=self.config.timeout,
)
content = response.choices[0].message.content content = response.choices[0].message.content
return content.strip() if content else "" return content.strip() if content else ""
except asyncio.TimeoutError:
logger.error(f"OpenAI API timed out after {self.config.timeout}s")
raise
except Exception as e: except Exception as e:
logger.error(f"OpenAI API error: {e}") logger.error(f"OpenAI API error: {e}")
raise raise

View file

@ -98,7 +98,10 @@ class LLMConfig:
system_prompt: str = ( system_prompt: str = (
"You are a helpful assistant on a Meshtastic mesh network. " "You are a helpful assistant on a Meshtastic mesh network. "
"Keep responses VERY brief - under 250 characters total. " "Keep responses VERY brief - under 250 characters total. "
"Be concise but friendly. No markdown formatting." "Be concise but friendly. No markdown formatting. "
"You can passively observe recent mesh traffic when available. "
"If asked about mesh activity and no recent traffic is shown below, "
"say you haven't observed any traffic yet rather than claiming you lack access."
) )
use_system_prompt: bool = True # Toggle to disable sending system prompt use_system_prompt: bool = True # Toggle to disable sending system prompt
web_search: bool = False # Enable web search (Open WebUI feature) web_search: bool = False # Enable web search (Open WebUI feature)

View file

@ -1,5 +1,6 @@
"""Message routing logic for MeshAI.""" """Message routing logic for MeshAI."""
import asyncio
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
@ -159,6 +160,10 @@ class MessageRouter:
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n" "\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
+ context_block + context_block
) )
else:
system_prompt += (
"\n\n[No recent mesh traffic observed yet.]"
)
try: try:
response = await self.llm.generate( response = await self.llm.generate(
@ -166,6 +171,9 @@ class MessageRouter:
system_prompt=system_prompt, system_prompt=system_prompt,
max_tokens=500, max_tokens=500,
) )
except asyncio.TimeoutError:
logger.error("LLM request timed out")
response = "Sorry, request timed out. Try again."
except Exception as e: except Exception as e:
logger.error(f"LLM generation error: {e}") logger.error(f"LLM generation error: {e}")
response = "Sorry, I encountered an error. Please try again." response = "Sorry, I encountered an error. Please try again."