#!/usr/bin/env python3 """ The /llm-summary route is start-then-wait, not a blocking request. Generating a summary is an LLM round-trip, so the route no longer holds a request thread for it: POST starts the work on a small pool and answers 202, and a read-only GET picks the result up out of the watch's summary cache - driven either by the llm_summary_ready event (realtime) or by polling (no realtime). These tests pin the properties that make that safe: - POST answers immediately instead of waiting for the LLM - a second click (or a poll) never starts a duplicate generation - that would bill twice - the GET is genuinely read-only, so it is safe to leave it as a GET - completion announces itself, since a realtime client makes exactly one request per event - every 'pending' reply carries the deadline the server itself is working to, because a client that gives up earlier reports a failure for a job that is still running - that deadline belongs to one job, not to the page: the watchlist has a [Summary] button per row and each one is its own request, started at its own moment plus the CSRF protection on the POST, which is the reason starting generation is not a GET in the first place: a bare on any page would otherwise spend the operator's tokens and mark the watch as viewed. """ import threading import time from unittest.mock import MagicMock, patch from flask import url_for from changedetectionio.tests.util import delete_all_watches, fetch_llm_summary def _configure_llm(client, api_base=None): ds = client.application.config.get('DATASTORE') existing = ds.data['settings']['application'].get('llm') or {} existing.update({'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) if api_base is not None: existing['api_base'] = api_base ds.data['settings']['application']['llm'] = existing def _watch_with_history(client, from_ts, to_ts): ds = client.application.config.get('DATASTORE') test_url = url_for('test_endpoint', content_type='text/html', content='v1', _external=True) uuid = ds.add_watch(url=test_url) watch = ds.data['watching'][uuid] watch.save_history_blob('old content\n', from_ts, 'snap-old') watch.save_history_blob('new content\n', to_ts, 'snap-new') return uuid, watch def _llm_response(text='Content changed from old to new.'): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = text mock_response.usage = MagicMock(total_tokens=50, prompt_tokens=40, completion_tokens=10) return mock_response def test_post_returns_pending_then_poll_returns_summary( client, live_server, measure_memory_usage, datastore_path): """The POST must not block on the LLM: it answers 202, the summary arrives via the poll.""" _configure_llm(client) uuid, watch = _watch_with_history(client, '5000000000', '5000000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5000000000', to_version='5000000001') released = threading.Event() def slow_completion(*args, **kwargs): released.wait(10) return _llm_response() with patch('litellm.completion', side_effect=slow_completion): start = client.post(url) assert start.status_code == 202, "POST should hand off to the background pool" assert start.get_json()['status'] == 'pending' assert start.get_json()['summary'] is None # Still generating — the poll reports pending without starting anything of its own pending = client.get(url) assert pending.status_code == 202 assert pending.get_json()['status'] == 'pending' released.set() deadline = time.time() + 15 while time.time() < deadline: res = client.get(url) if res.status_code != 202: break time.sleep(0.05) assert res.status_code == 200 data = res.get_json() assert data['status'] == 'done' assert data['summary'] == 'Content changed from old to new.' delete_all_watches(client) def test_repeated_clicks_generate_one_summary( client, live_server, measure_memory_usage, datastore_path): """Two tabs / an impatient double-click must not pay for the same summary twice.""" _configure_llm(client) uuid, watch = _watch_with_history(client, '5100000000', '5100000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5100000000', to_version='5100000001') calls = {'n': 0} released = threading.Event() entered = threading.Event() def slow_completion(*args, **kwargs): calls['n'] += 1 entered.set() released.wait(10) return _llm_response() try: with patch('litellm.completion', side_effect=slow_completion): first = client.post(url) assert first.status_code == 202 assert entered.wait(10), "background job never reached the LLM call" # Extra clicks while the first generation is still running for _ in range(3): again = client.post(url) assert again.status_code == 202 assert again.get_json()['status'] == 'pending' released.set() deadline = time.time() + 15 while time.time() < deadline: res = client.get(url) if res.status_code != 202: break time.sleep(0.05) finally: released.set() assert res.status_code == 200 assert res.get_json()['summary'] == 'Content changed from old to new.' assert calls['n'] == 1, f"LLM was called {calls['n']} times for one summary" # And once cached, a further click is answered straight from disk — still no new call with patch('litellm.completion', side_effect=slow_completion): cached = client.post(url) assert cached.status_code == 200 assert cached.get_json()['cached'] is True assert calls['n'] == 1 delete_all_watches(client) def test_poll_alone_never_starts_generation( client, live_server, measure_memory_usage, datastore_path): """The GET is read-only — that is what makes it safe to leave CSRF-exempt. With nothing cached and no job running it reports 'idle' so the client re-POSTs, rather than quietly spending tokens on a request that carried no CSRF token. """ _configure_llm(client) uuid, watch = _watch_with_history(client, '5200000000', '5200000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5200000000', to_version='5200000001') calls = {'n': 0} def counted(*args, **kwargs): calls['n'] += 1 return _llm_response() with patch('litellm.completion', side_effect=counted): for _ in range(3): res = client.get(url) assert res.status_code == 200 assert res.get_json()['status'] == 'idle' assert res.get_json()['summary'] is None assert calls['n'] == 0, "a GET poll must never trigger an LLM call" assert watch['last_viewed'] == 0, "a GET poll must not mark the watch as viewed either" delete_all_watches(client) def test_starting_generation_requires_a_csrf_token( client, live_server, measure_memory_usage, datastore_path): """Starting a generation spends tokens, so it must be CSRF-protected. conftest disables CSRF for the test client, so switch it back on for this one check to prove the route is actually covered by CSRFProtect (i.e. it was not exempted the way the JSON API is via `csrf.exempt`). """ _configure_llm(client) uuid, watch = _watch_with_history(client, '5300000000', '5300000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5300000000', to_version='5300000001') calls = {'n': 0} def counted(*args, **kwargs): calls['n'] += 1 return _llm_response() app = client.application app.config['WTF_CSRF_ENABLED'] = True try: with patch('litellm.completion', side_effect=counted): res = client.post(url) # no token supplied assert res.status_code in (400, 403), \ f"POST without a CSRF token was accepted ({res.status_code})" # The read-only poll is unaffected poll = client.get(url) assert poll.status_code == 200 assert poll.get_json()['status'] == 'idle' finally: app.config['WTF_CSRF_ENABLED'] = False assert calls['n'] == 0, "a request rejected for CSRF must not have reached the LLM" delete_all_watches(client) def test_a_job_finishing_mid_request_is_not_reported_as_idle( client, live_server, measure_memory_usage, datastore_path): """Regression: the poll must re-read the cache before it declares nothing is running. A job writes the summary and *then* clears its pending flag, so a request that reads the cache (miss), then checks the flag a moment later (clear), would announce 'idle' for a summary that is already on disk - and send the client back to POST, which is the request that spends money. Simulated by making the first cache read miss and the second one hit. """ _configure_llm(client) uuid, watch = _watch_with_history(client, '5600000000', '5600000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5600000000', to_version='5600000001') with patch.object(type(watch), 'get_llm_diff_summary', side_effect=['', 'Summary that landed mid-request.']): res = client.get(url) assert res.status_code == 200 data = res.get_json() assert data['status'] == 'done', f"poll reported {data['status']!r} for a cached summary" assert data['summary'] == 'Summary that landed mid-request.' delete_all_watches(client) def test_pending_reply_carries_the_servers_own_deadline( client, live_server, measure_memory_usage, datastore_path): """A 'pending' reply must say when the summary stops being worth waiting for. The browser cannot derive this: it knows neither the configured per-call timeout nor when the job started (another tab may have started it minutes ago). It used to guess 180s, which is shorter than every configured timeout here - so a slow model produced "AI summary timed out" on screen while the job was still running and still about to write its summary. """ from changedetectionio.llm import client as llm_client _configure_llm(client) uuid, watch = _watch_with_history(client, '5600000000', '5600000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5600000000', to_version='5600000001') released = threading.Event() try: with patch('litellm.completion', side_effect=lambda *a, **k: (released.wait(10), _llm_response())[1]): start = client.post(url) assert start.status_code == 202 body = start.get_json() assert body['expires_in'] >= llm_client.DEFAULT_TIMEOUT, \ f"client told to give up after {body['expires_in']}s, before the server would" assert body['timeout_at'] > time.time() # The poll reports the same deadline, not one restarted from when it was asked - # otherwise the wait could be extended indefinitely by polling. poll = client.get(url) assert poll.status_code == 202 assert abs(poll.get_json()['timeout_at'] - body['timeout_at']) <= 1, \ "each poll must report the same instant, anchored to the job's start" finally: released.set() delete_all_watches(client) def test_local_endpoint_gets_the_long_deadline( client, live_server, measure_memory_usage, datastore_path): """A local Ollama/vLLM box gets 30 minutes server-side - the browser must be told so. This is the case that produced the bug report: LLM_LOCAL_TIMEOUT is 1800s, so a client capped at 180s gave up ten times too early while Ollama was still churning on CPU. """ from changedetectionio.llm import client as llm_client _configure_llm(client, api_base='http://127.0.0.1:11434') uuid, watch = _watch_with_history(client, '5700000000', '5700000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5700000000', to_version='5700000001') released = threading.Event() try: with patch('litellm.completion', side_effect=lambda *a, **k: (released.wait(10), _llm_response())[1]): body = client.post(url).get_json() assert body['expires_in'] >= llm_client.DEFAULT_LOCAL_TIMEOUT, \ f"local endpoint deadline was only {body['expires_in']}s" finally: released.set() ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'].pop('api_base', None) delete_all_watches(client) def test_each_watch_gets_its_own_deadline( client, live_server, measure_memory_usage, datastore_path): """The watchlist has a [Summary] button per row, so a deadline is per job, never per page. Rows are clicked at different moments and the pool is small, so at any instant one row can be generating while another is still queued behind it. Sharing a deadline across the UI would expire a row that had only just been submitted. """ _configure_llm(client) first_uuid, _ = _watch_with_history(client, '5800000000', '5800000001') second_uuid, _ = _watch_with_history(client, '5800000000', '5800000001') first_url = url_for('ui.ui_diff.diff_llm_summary', uuid=first_uuid, from_version='5800000000', to_version='5800000001') second_url = url_for('ui.ui_diff.diff_llm_summary', uuid=second_uuid, from_version='5800000000', to_version='5800000001') released = threading.Event() try: with patch('litellm.completion', side_effect=lambda *a, **k: (released.wait(20), _llm_response())[1]): first = client.post(first_url).get_json() time.sleep(2) second = client.post(second_url).get_json() assert second['timeout_at'] > first['timeout_at'], \ "the row clicked later must not inherit the earlier row's deadline" # ...and the earlier row keeps its own as the later one runs on. assert client.get(first_url).get_json()['timeout_at'] == first['timeout_at'] finally: released.set() delete_all_watches(client) def test_completion_emits_llm_summary_ready_signal( client, live_server, measure_memory_usage, datastore_path): """Realtime clients do not poll, so the signal the socket server re-broadcasts must fire. Asserted at the blinker signal rather than through a Socket.IO client: that is the seam the socket server subscribes to, and it stays a no-op when realtime is disabled. """ from blinker import signal _configure_llm(client) uuid, watch = _watch_with_history(client, '5500000000', '5500000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5500000000', to_version='5500000001') received = [] fired = threading.Event() def listener(sender, **kwargs): received.append(kwargs) fired.set() sig = signal('llm_summary_ready') sig.connect(listener, weak=False) try: with patch('litellm.completion', return_value=_llm_response()): assert client.post(url).status_code == 202 assert fired.wait(15), "job finished without announcing llm_summary_ready" finally: sig.disconnect(listener) assert received[0]['watch_uuid'] == uuid assert received[0]['from_version'] == '5500000000' assert received[0]['to_version'] == '5500000001' # The announcement means "go look" - the summary must already be readable by then, because a # realtime client makes exactly one request in response to it. res = client.get(url) assert res.status_code == 200 assert res.get_json()['summary'] == 'Content changed from old to new.' delete_all_watches(client) def test_generation_failure_reaches_the_poller_once( client, live_server, measure_memory_usage, datastore_path): """A failure in the background thread must surface, not leave a permanent spinner.""" import litellm as _real_litellm _configure_llm(client) uuid, watch = _watch_with_history(client, '5400000000', '5400000001') url = url_for('ui.ui_diff.diff_llm_summary', uuid=uuid, from_version='5400000000', to_version='5400000001') exc = _real_litellm.AuthenticationError( 'litellm.AuthenticationError: Invalid API key.', llm_provider='openai', model='gpt-4o-mini', ) with patch('litellm.completion', side_effect=exc): res = fetch_llm_summary(client, url) assert res.status_code == 500 data = res.get_json() assert data['summary'] is None assert data['error'] assert data['status'] == 'error' # Delivered once: the next poll is back to 'idle' rather than repeating a stale failure followup = client.get(url) assert followup.status_code == 200 assert followup.get_json()['status'] == 'idle' delete_all_watches(client)