Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BUGS-BY-HAND.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
32 changes: 18 additions & 14 deletions huismus/expenses/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tests/ai_fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}})
Expand Down
17 changes: 17 additions & 0 deletions tests/expenses/test_ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}])
Expand Down Expand Up @@ -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,
Expand Down
Loading