From ea6d7d275ec162966278ddb9d16765d99da921aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 21:43:34 +0000 Subject: [PATCH] fix(receipts): send no temperature to the AI service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer models (Anthropic's Claude Haiku 5.5 among them) refuse sampling settings with a 400, so saving Anthropic in "Bonnetjes lezen met AI" failed with "gaf een antwoord dat Huismus niet begrijpt". The request now leaves them to the service's default, the test fake refuses them like those models do, and a failed test logs what the service said. Playbook: n.v.t. — bugfix in Huismus zelf, geen werkwijze Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6o7JHCnFJVV7ub63VfxGH --- BUGS-BY-HAND.md | 1 + huismus/expenses/ai_service.py | 32 +++++++++++++++++-------------- tests/ai_fakes.py | 5 +++++ tests/expenses/test_ai_service.py | 17 ++++++++++++++++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/BUGS-BY-HAND.md b/BUGS-BY-HAND.md index 0a3440d..f9aaf34 100644 --- a/BUGS-BY-HAND.md +++ b/BUGS-BY-HAND.md @@ -59,3 +59,4 @@ toe (vaak in `tests/e2e/test_every_page.py`) en zet hier een regel bij. | 2026-11-07 | Kwartaaltabel van het jaaroverzicht rechts afgeknipt op een telefoon ("Winst", en zonder KOR "Voorbelasting", onzichtbaar); de factuurlijst 30px op 1024px (M21 deel 3, deze PR) | tabel breder dan zijn kaart wordt afgeknipt | `test_every_page`: geen `.table-wrap` met `overflow: hidden` en een bredere tabel; `.table-wrap` scrolt nu opzij | | 2026-11-07 | De eigenaar zocht de plek voor de API-sleutel van zijn eigen AI-dienst en vond hem niet: die stond onder Instellingen → "Bonnetjes lezen", een naam zonder AI of sleutel, en op AI-assistenten (waar hij keek) maakt Huismus juist zelf een sleutel (deze PR) | instelling vindbaar alleen voor wie weet waarvoor hij dient | de kaart heet "Bonnetjes lezen met AI" en noemt de API-sleutel, AI-assistenten wijst ernaar; `test_the_card_says_it_is_where_your_own_ai_key_goes`, `test_the_page_points_to_where_your_own_ai_key_goes`. Vindbaarheid zelf: geen geautomatiseerde check | | 2026-11-13 | Desktop-app: "Koppel met Claude" deed niets, en geen enkel formulier meer, tot het venster dichtging. Een htmx-verzoek ging weg van een deel dat zichzelf ververst; een paginawissel verving dat deel voordat het antwoord kwam. htmx meldt het einde alleen aan het element zelf, dus app.js bleef wachten en hield elk POST-formulier tegen (de eigenaar; deze PR) | boekhouding van de pagina hangt aan een htmx-event van een element dat weg kan zijn | `test_browser`: `test_a_request_whose_element_is_gone_does_not_hold_up_the_forms` (geldt voor elk htmx-verzoek: app.js telt nu ook het einde van het verzoek zelf, `loadend`) | +| 2026-11-13 | Bonnetjes lezen met Anthropic (Claude Haiku 5.5) niet op te slaan: "Anthropic gaf een antwoord dat Huismus niet begrijpt". Huismus stuurde `temperature: 0` mee en nieuwere modellen weigeren dat met een 400; wat de dienst zei, kwam nergens in het log (de eigenaar, op de Pi; deze PR) | verzoek naar een externe dienst met een instelling die nieuwere versies weigeren | `tests/ai_fakes.py`: de nep-dienst weigert `temperature`, `top_p` en `top_k` bij elk verzoek in elke test; een mislukte test van de dienst zet het antwoord van de dienst in het log (`test_a_failed_test_logs_what_the_service_said`) | diff --git a/huismus/expenses/ai_service.py b/huismus/expenses/ai_service.py index 18a7b9a..ae01610 100644 --- a/huismus/expenses/ai_service.py +++ b/huismus/expenses/ai_service.py @@ -218,12 +218,10 @@ def _message_of(body: bytes) -> str: def chat(config: Config, content: list[dict], timeout: float = TIMEOUT) -> str: """Sends one user message (OpenAI's content parts: text and image_url) and returns the answer's text. ServiceError when it fails.""" + # No temperature or other sampling settings: newer models refuse them + # with a 400 (Anthropic's Claude Haiku 5.5 among them). body = json.dumps( - { - "model": config.model, - "messages": [{"role": "user", "content": content}], - "temperature": 0, - } + {"model": config.model, "messages": [{"role": "user", "content": content}]} ).encode() headers = {"Content-Type": "application/json", "User-Agent": "Huismus"} if config.key: @@ -277,15 +275,21 @@ def _test_image() -> str: def test_connection(config: Config) -> None: """One small request with an image, so a model that can't read images - shows up now and not at the first receipt. ServiceError when it fails.""" - chat( - config, - [ - {"type": "text", "text": "What word is in this image? Answer with that word only."}, - image_part(_test_image()), - ], - timeout=60, - ) + shows up now and not at the first receipt. ServiceError when it fails, + with the service's own message in the log: the page only says why in + plain words.""" + try: + chat( + config, + [ + {"type": "text", "text": "What word is in this image? Answer with that word only."}, + image_part(_test_image()), + ], + timeout=60, + ) + except ServiceError as error: + logger.warning("Test van %s mislukt (%s): %s", name(config), error.reason, error.detail) + raise def failure_sentence(reason: str, config: Config) -> str: diff --git a/tests/ai_fakes.py b/tests/ai_fakes.py index d2c6811..330c76d 100644 --- a/tests/ai_fakes.py +++ b/tests/ai_fakes.py @@ -67,6 +67,11 @@ def do_POST(self): threading.Event().wait(fake.delay) if fake.key and authorization != f"Bearer {fake.key}": return self._send(401, {"error": {"message": "Invalid API key"}}) + # As newer models do (Anthropic's Claude Haiku 5.5 among + # them): sampling settings are refused. + if {"temperature", "top_p", "top_k"} & request.keys(): + message = "temperature, top_p and top_k are not supported for this model" + return self._send(400, {"error": {"message": message}}) if request.get("model") not in fake.models: message = f"The model {request.get('model')} does not exist" return self._send(404, {"error": {"message": message}}) diff --git a/tests/expenses/test_ai_service.py b/tests/expenses/test_ai_service.py index e9714f3..2841dd5 100644 --- a/tests/expenses/test_ai_service.py +++ b/tests/expenses/test_ai_service.py @@ -81,6 +81,15 @@ def test_chat_sends_model_key_and_content(ai_fake): assert ai_fake.authorizations[-1] == f"Bearer {KEY}" +def test_chat_sends_no_sampling_settings(ai_fake): + # Newer models refuse temperature, top_p and top_k with a 400 + # (Anthropic's Claude Haiku 5.5 among them), so the request leaves them + # to the service's default. + ai_service.chat(_config(ai_fake), [{"type": "text", "text": "x"}]) + request = ai_fake.requests[-1] + assert not {"temperature", "top_p", "top_k"} & request.keys() + + def test_no_key_sends_no_authorization(ai_fake): ai_fake.key = "" ai_service.chat(_config(ai_fake, key=""), [{"type": "text", "text": "x"}]) @@ -162,6 +171,14 @@ def test_test_connection_sends_one_image(ai_fake): assert images[0]["image_url"]["url"].startswith("data:image/jpeg;base64,") +def test_a_failed_test_logs_what_the_service_said(ai_fake, caplog): + ai_fake.answer((400, {"error": {"message": "temperature is not supported"}})) + with pytest.raises(ServiceError): + ai_service.test_connection(_config(ai_fake)) + assert "temperature is not supported" in caplog.text + assert KEY not in caplog.text + + def test_every_reason_has_a_sentence(): for reason in ( ai_service.REFUSED,