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,