From 1d5962b40eb6d200d4ee15b024f40aedfa7d8219 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 22:02:50 +0200 Subject: [PATCH] fix: a model call with no timeout, and tests that measured Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generation run stalled at topic 28 with the process alive and the log frozen. `_call_model` had no timeout — every other call in ai_service.py has one — so a stalled connection to the proxy hung the caller indefinitely. An interactive request survives that because the person gives up; an unattended run of five hundred topics does not, it just stops quietly and looks busy. It now takes a timeout, generous by default and 150s from the article writer: long enough for a full article, short enough that a stall is noticed in minutes rather than found hours later with nothing written since. Separately, the AI Mode tests passed this morning and failed this evening with no code between them. Not flakiness: they call the real Redis rate limiter, and sixty-eight runs of the suite had exhausted a daily limit of sixty. A test that depends on shared external state stops testing the code and starts reporting how often it has been run, so the limiter is now patched out for those tests. 208 backend tests green, and the run is moving again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/ai_service.py | 12 ++++++++++-- backend/app/services/article_writer.py | 6 +++++- backend/tests/test_ai_mode.py | 7 +++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 3baaeea..3a119eb 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -216,14 +216,22 @@ def extract_questions( raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}") -def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str: - """Call the configured LLM and return raw text response.""" +def _call_model(prompt: str, model_id: str | None, api_key: str | None, + timeout: int = 180) -> str: + """Call the configured LLM and return raw text response. + + The timeout is not optional in practice: every other call in this module has + one, and this one did not. A stalled connection to the proxy hung the caller + for good — which an interactive request survives by the user giving up, and + an unattended run of several hundred topics does not. + """ use_model = _proxy_model(model_id or settings.LITELLM_MODEL) use_key = api_key or settings.LITELLM_API_KEY kwargs = { "model": use_model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.1, + "timeout": timeout, } if use_key: kwargs["api_key"] = use_key diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py index ef11fb6..a966bc6 100644 --- a/backend/app/services/article_writer.py +++ b/backend/app/services/article_writer.py @@ -33,6 +33,9 @@ PASSAGES = 14 # Enough retrieved prose to write from. Below this the model is being asked to # write a medical article out of fragments, and it will either refuse or invent. MIN_SOURCE_CHARS = 3000 +# Generous for a long article, short enough that a stalled call is noticed in +# minutes rather than discovered hours later with nothing written since. +WRITE_TIMEOUT = 150 # Long enough to be worth reading, short enough that nobody skims past the point. LONG_WORDS = 550 SHELF = "Pediatrics" @@ -148,7 +151,8 @@ def write_article(db: Session, topic: str, category_id: int | None = None, "passages": len(passages)} model_id, api_key = get_model_for_task(db, "extraction") - raw = _call_model(_prompt(topic, passages), model_id, api_key) + # One topic must not be able to stall a run of five hundred. + raw = _call_model(_prompt(topic, passages), model_id, api_key, timeout=WRITE_TIMEOUT) text = raw.strip() if text.startswith("```"): text = re.sub(r"^```[a-z]*\n?|```$", "", text).strip() diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py index 9a30edd..0faabae 100644 --- a/backend/tests/test_ai_mode.py +++ b/backend/tests/test_ai_mode.py @@ -67,6 +67,13 @@ class _AiModeBase(unittest.TestCase): """Fixtures shared by the route and retrieval cases; holds no tests itself.""" def setUp(self): + # The daily limit is enforced through the real Redis, so without this a + # suite that passes today fails once it has been run sixty times — the + # tests would be measuring shared state rather than this code. + self._no_limit = patch("app.routers.ai_mode.check_rate_limit", lambda **kwargs: None) + self._no_limit.start() + self.addCleanup(self._no_limit.stop) + self.bank = fixtures.BuilderTests() self.bank.setUp() self.client = self.bank.client