From 2291a002f0ed29a743ca333f20ec0687d3ec4e47 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Wed, 23 Sep 2026 21:53:49 +0200 Subject: [PATCH 1/3] A voice command is refused before it is sent if it would pass the daily budget The voice agent only checked whether the budget was already spent, so the last command of the day went through in full and could end it over budget. It now estimates the command from its request size, as contexts and AI Task do, holds the estimate while the call runs, and reports the size to the tracker so the estimate learns from voice requests too. --- custom_components/jev/conversation.py | 27 ++++++++++++++------- tests/test_conversation.py | 35 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 359094b..07cc3a2 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -53,6 +53,7 @@ from .coordinator import JevRuntimeData from .entity import build_device_info from .interpret import build_questions, interpret +from .payload import payload_bytes from .snapshot import async_snapshot _LOGGER = logging.getLogger(__name__) @@ -154,14 +155,7 @@ async def _async_handle_message( ) -> conversation.ConversationResult: runtime: JevRuntimeData = self._entry.runtime_data - # The budget covers voice as well as sensors, because a satellite that - # mishears a wake word all night is exactly the runaway it exists to stop. runtime.usage.roll_over(dt_util.now().date()) - if runtime.usage.would_exceed(): - return await self._fall_back( - user_input, "the daily token budget is spent", "budget_spent" - ) - snapshot = async_snapshot(self.hass, MAX_CONVERSATION_ENTITIES) if not snapshot.entities: return await self._fall_back(user_input, "no entities are exposed to Assist") @@ -169,8 +163,23 @@ async def _async_handle_message( questions = build_questions(user_input.text, snapshot, MAX_CONVERSATION_ENTITIES) state = snapshot.as_state() | {"command": user_input.text} + # The budget covers voice as well as sensors, because a satellite that + # mishears a wake word all night is exactly the runaway it exists to stop. + # The check is on this command's estimate, the same as a context's, so the + # last command of the day cannot take the total past the budget. + request_bytes = payload_bytes(state, questions, runtime.model) + estimate = runtime.usage.estimate_tokens(request_bytes) + if runtime.usage.would_exceed_with(estimate): + return await self._fall_back( + user_input, + f"the daily token budget has {runtime.usage.remaining()} tokens left " + f"and this command needs about {estimate}", + "budget_spent", + ) + try: - response = await runtime.client.ask(state, questions) + with runtime.usage.reservation(estimate): + response = await runtime.client.ask(state, questions) except JevAuthError as err: _LOGGER.error("TypeSafe rejected the API key: %s", err) self._entry.async_start_reauth(self.hass) @@ -183,7 +192,7 @@ async def _async_handle_message( user_input, f"TypeSafe did not answer: {err}", "unavailable" ) - runtime.usage.record(response.usage.input_tokens) + runtime.usage.record(response.usage.input_tokens, request_bytes) runtime.model_version = response.model or runtime.model_version runtime.usage.notify() diff --git a/tests/test_conversation.py b/tests/test_conversation.py index a684f01..16a3e64 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -26,6 +26,7 @@ ) from custom_components.jev.conversation import _render_state_answer from custom_components.jev.interpret import find_brightness +from custom_components.jev.payload import payload_bytes from .conftest import PROBE_TOKENS, build_response @@ -344,6 +345,40 @@ async def test_a_spent_budget_stops_voice_too(hass, house, mock_client): assert "budget is spent" in result.response.speech["plain"]["speech"] +async def test_a_command_that_would_pass_the_budget_is_not_sent(hass, house, mock_client): + # One token short of the budget. would_exceed() says there is room, and on + # 1.15 the command went through and ended the day about 1,000 tokens over. + hass.config_entries.async_update_entry(house, options={"daily_token_budget": 1000}) + await hass.async_block_till_done() + house.runtime_data.usage.input_tokens = 999 + mock_client.ask.reset_mock() + + calls = [] + hass.services.async_register("light", "turn_on", lambda call: calls.append(call)) + result = await converse(hass, "kitchen light on") + await hass.async_block_till_done() + + assert mock_client.ask.await_count == 0 + assert calls == [] + assert "budget is spent" in result.response.speech["plain"]["speech"] + + +async def test_a_voice_command_teaches_the_estimate(hass, house, mock_client): + # The estimate reads bytes per token from the last call it could measure. A + # voice command that did not report its size left the estimate on whatever + # the last context taught it, which is a different shape of request. + mock_client.ask.return_value = build_response(**answer_set()) + usage = house.runtime_data.usage + usage.bytes_per_token = 1.0 + + await converse(hass, "kitchen light on") + await hass.async_block_till_done() + + sent_state, sent_questions = mock_client.ask.await_args.args + sent = payload_bytes(sent_state, sent_questions, house.runtime_data.model) + assert usage.bytes_per_token == sent / 321 + + async def test_a_rejected_key_is_said_out_loud(hass, house, mock_client): from jevclient import JevAuthError From 5214f97eba23662a9a4940805715d38ad3c49a9e Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:19:02 +0200 Subject: [PATCH 2/3] The voice refusal for the budget is true when tokens remain, and is an error The check refuses a command whose estimate does not fit, which happens with tokens left. The line said the budget "is spent". It now says not enough is left, in all 13 languages. With no fallback agent, the reply was an action_done response. The Assist dialog and a satellite read that as a command that went through. It is now an error, no_intent_match for a sentence not understood and failed_to_handle for the budget, a rejected key and no answer, as the default agent answers. --- custom_components/jev/conversation.py | 21 +++++++++++++++---- custom_components/jev/strings.json | 2 +- custom_components/jev/translations/cs.json | 2 +- custom_components/jev/translations/da.json | 2 +- custom_components/jev/translations/de.json | 2 +- custom_components/jev/translations/en.json | 2 +- custom_components/jev/translations/es.json | 2 +- custom_components/jev/translations/fr.json | 2 +- custom_components/jev/translations/it.json | 2 +- custom_components/jev/translations/nl.json | 2 +- custom_components/jev/translations/pl.json | 2 +- custom_components/jev/translations/pt-BR.json | 2 +- custom_components/jev/translations/ru.json | 2 +- custom_components/jev/translations/sv.json | 2 +- .../jev/translations/zh-Hans.json | 2 +- tests/test_conversation.py | 8 +++++-- 16 files changed, 37 insertions(+), 20 deletions(-) diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 07cc3a2..e37818d 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -73,7 +73,9 @@ "already_on": "{name} is already on.", "already_off": "{name} is already off.", "query_not_found": "I could not find that.", - "budget_spent": "The daily token budget is spent, so I cannot do that today.", + "budget_spent": ( + "Not enough of the daily token budget is left for that, so I cannot do it today." + ), "auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.", "unavailable": "TypeSafe did not answer. Try again in a moment.", } @@ -276,7 +278,14 @@ async def _fall_back( agent = self._fallback_agent _LOGGER.debug("falling back to %s because %s", agent or "nobody", why) if agent is None: - return await self._speak(user_input, line) + # An error, as the default agent answers one. A satellite and the Assist + # dialog treat an action_done reply as a command that went through. + code = ( + ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH + if line == "not_understood" + else ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE + ) + return await self._speak(user_input, line, error=code) result = await conversation.async_converse( self.hass, user_input.text, @@ -325,13 +334,17 @@ async def _speak( self, user_input: conversation.ConversationInput, key: str, + error: ha_intent.IntentResponseErrorCode | None = None, **placeholders: str, ) -> conversation.ConversationResult: """Say one of our own lines, with its placeholders filled in.""" language = user_input.language or self.hass.config.language - text = (await self._lines(language))[key] + text = (await self._lines(language))[key].format(**placeholders) response = ha_intent.IntentResponse(language=user_input.language) - response.async_set_speech(text.format(**placeholders)) + if error is None: + response.async_set_speech(text) + else: + response.async_set_error(error, text) return conversation.ConversationResult( response=response, conversation_id=user_input.conversation_id ) diff --git a/custom_components/jev/strings.json b/custom_components/jev/strings.json index 54cf0a1..1502dc0 100644 --- a/custom_components/jev/strings.json +++ b/custom_components/jev/strings.json @@ -364,7 +364,7 @@ "preview_mismatched": "The API answered, but not to the question that was asked.", "preview_template_error": "The template does not render: {reason}", "query_not_found": "I could not find that.", - "budget_spent": "The daily token budget is spent, so I cannot do that today.", + "budget_spent": "Not enough of the daily token budget is left for that, so I cannot do it today.", "auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.", "unavailable": "TypeSafe did not answer. Try again in a moment.", "preview_over_budget": "The daily token budget is spent, so there is no trial answer. The question still saves.", diff --git a/custom_components/jev/translations/cs.json b/custom_components/jev/translations/cs.json index dd03fe5..b9261e9 100644 --- a/custom_components/jev/translations/cs.json +++ b/custom_components/jev/translations/cs.json @@ -364,7 +364,7 @@ "preview_mismatched": "API odpovědělo, ale ne na otázku, která byla položena.", "preview_template_error": "Šablona se nevykreslí: {reason}", "query_not_found": "To jsem nenašel.", - "budget_spent": "Denní rozpočet tokenů je vyčerpán, takže to dnes udělat nemohu.", + "budget_spent": "Z denního rozpočtu tokenů na to nezbývá dost, takže to dnes udělat nemohu.", "auth_failed": "TypeSafe odmítl klíč API. Zkontrolujte ho v nastavení Jev.", "unavailable": "TypeSafe neodpověděl. Zkuste to za chvíli znovu.", "preview_over_budget": "Denní rozpočet tokenů je vyčerpán, takže zkušební odpověď není. Otázka se přesto uloží.", diff --git a/custom_components/jev/translations/da.json b/custom_components/jev/translations/da.json index bbdea05..162a8fb 100644 --- a/custom_components/jev/translations/da.json +++ b/custom_components/jev/translations/da.json @@ -364,7 +364,7 @@ "preview_mismatched": "API'en svarede, men ikke på det spørgsmål, der blev stillet.", "preview_template_error": "Skabelonen kan ikke gengives: {reason}", "query_not_found": "Det kunne jeg ikke finde.", - "budget_spent": "Det daglige tokenbudget er brugt, så det kan jeg ikke gøre i dag.", + "budget_spent": "Der er ikke nok tilbage af det daglige tokenbudget til det, så det kan jeg ikke gøre i dag.", "auth_failed": "TypeSafe afviste API-nøglen. Tjek den i indstillingerne for Jev.", "unavailable": "TypeSafe svarede ikke. Prøv igen om et øjeblik.", "preview_over_budget": "Det daglige token-budget er brugt op, så der er intet prøvesvar. Spørgsmålet bliver gemt alligevel.", diff --git a/custom_components/jev/translations/de.json b/custom_components/jev/translations/de.json index 89c7dbc..69fbe6c 100644 --- a/custom_components/jev/translations/de.json +++ b/custom_components/jev/translations/de.json @@ -364,7 +364,7 @@ "preview_mismatched": "Die API hat geantwortet, aber nicht auf die gestellte Frage.", "preview_template_error": "Die Vorlage lässt sich nicht rendern: {reason}", "query_not_found": "Das konnte ich nicht finden.", - "budget_spent": "Das tägliche Token-Budget ist aufgebraucht, deshalb kann ich das heute nicht tun.", + "budget_spent": "Vom täglichen Token-Budget ist dafür nicht mehr genug übrig, deshalb kann ich das heute nicht tun.", "auth_failed": "TypeSafe hat den API-Schlüssel abgelehnt. Prüfe ihn in den Jev-Einstellungen.", "unavailable": "TypeSafe hat nicht geantwortet. Versuche es gleich noch einmal.", "preview_over_budget": "Das Tagesbudget für Tokens ist aufgebraucht, es gibt also keine Probeantwort. Die Frage wird trotzdem gespeichert.", diff --git a/custom_components/jev/translations/en.json b/custom_components/jev/translations/en.json index 54cf0a1..1502dc0 100644 --- a/custom_components/jev/translations/en.json +++ b/custom_components/jev/translations/en.json @@ -364,7 +364,7 @@ "preview_mismatched": "The API answered, but not to the question that was asked.", "preview_template_error": "The template does not render: {reason}", "query_not_found": "I could not find that.", - "budget_spent": "The daily token budget is spent, so I cannot do that today.", + "budget_spent": "Not enough of the daily token budget is left for that, so I cannot do it today.", "auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.", "unavailable": "TypeSafe did not answer. Try again in a moment.", "preview_over_budget": "The daily token budget is spent, so there is no trial answer. The question still saves.", diff --git a/custom_components/jev/translations/es.json b/custom_components/jev/translations/es.json index a3e9da7..731deeb 100644 --- a/custom_components/jev/translations/es.json +++ b/custom_components/jev/translations/es.json @@ -364,7 +364,7 @@ "preview_mismatched": "La API ha respondido, pero no a la pregunta que se le hizo.", "preview_template_error": "La plantilla no se procesa: {reason}", "query_not_found": "No he podido encontrarlo.", - "budget_spent": "El presupuesto diario de tokens se ha agotado, así que hoy no puedo hacerlo.", + "budget_spent": "No queda suficiente presupuesto diario de tokens para eso, así que hoy no puedo hacerlo.", "auth_failed": "TypeSafe rechazó la clave de API. Revísala en los ajustes de Jev.", "unavailable": "TypeSafe no respondió. Inténtalo de nuevo en un momento.", "preview_over_budget": "El presupuesto diario de tokens está agotado, así que no hay respuesta de prueba. La pregunta sí se guarda.", diff --git a/custom_components/jev/translations/fr.json b/custom_components/jev/translations/fr.json index d05d12e..96b7c78 100644 --- a/custom_components/jev/translations/fr.json +++ b/custom_components/jev/translations/fr.json @@ -364,7 +364,7 @@ "preview_mismatched": "L'API a répondu, mais pas à la question qui a été posée.", "preview_template_error": "Le rendu du modèle échoue : {reason}", "query_not_found": "Je n'ai pas trouvé cela.", - "budget_spent": "Le budget quotidien de jetons est épuisé, je ne peux donc pas le faire aujourd'hui.", + "budget_spent": "Il ne reste pas assez de budget quotidien de jetons pour cela, je ne peux donc pas le faire aujourd'hui.", "auth_failed": "TypeSafe a refusé la clé API. Vérifiez-la dans les paramètres de Jev.", "unavailable": "TypeSafe n'a pas répondu. Réessayez dans un instant.", "preview_over_budget": "Le budget quotidien de jetons est épuisé, il n'y a donc pas de réponse d'essai. La question est quand même enregistrée.", diff --git a/custom_components/jev/translations/it.json b/custom_components/jev/translations/it.json index 9d88f65..dbbc17d 100644 --- a/custom_components/jev/translations/it.json +++ b/custom_components/jev/translations/it.json @@ -364,7 +364,7 @@ "preview_mismatched": "L'API ha risposto, ma non alla domanda che è stata posta.", "preview_template_error": "Il template non si elabora: {reason}", "query_not_found": "Non sono riuscito a trovarlo.", - "budget_spent": "Il budget giornaliero di token è esaurito, quindi oggi non posso farlo.", + "budget_spent": "Non resta abbastanza budget giornaliero di token per questo, quindi oggi non posso farlo.", "auth_failed": "TypeSafe ha rifiutato la chiave API. Controllala nelle impostazioni di Jev.", "unavailable": "TypeSafe non ha risposto. Riprova tra un momento.", "preview_over_budget": "Il budget giornaliero di token è esaurito, quindi non c'è una risposta di prova. La domanda viene comunque salvata.", diff --git a/custom_components/jev/translations/nl.json b/custom_components/jev/translations/nl.json index 48220fd..3a937ab 100644 --- a/custom_components/jev/translations/nl.json +++ b/custom_components/jev/translations/nl.json @@ -364,7 +364,7 @@ "preview_mismatched": "De API antwoordde, maar niet op de vraag die gesteld is.", "preview_template_error": "De template rendert niet: {reason}", "query_not_found": "Dat kon ik niet vinden.", - "budget_spent": "Het dagelijkse tokenbudget is op, dus dat kan ik vandaag niet doen.", + "budget_spent": "Er is niet genoeg over van het dagelijkse tokenbudget, dus dat kan ik vandaag niet doen.", "auth_failed": "TypeSafe heeft de API-sleutel geweigerd. Controleer hem in de instellingen van Jev.", "unavailable": "TypeSafe gaf geen antwoord. Probeer het zo nog eens.", "preview_over_budget": "Het daglimiet voor tokens is op, dus er is geen proefantwoord. De vraag wordt wel opgeslagen.", diff --git a/custom_components/jev/translations/pl.json b/custom_components/jev/translations/pl.json index a5765eb..2ad1ed1 100644 --- a/custom_components/jev/translations/pl.json +++ b/custom_components/jev/translations/pl.json @@ -364,7 +364,7 @@ "preview_mismatched": "API odpowiedziało, ale nie na zadane pytanie.", "preview_template_error": "Szablon się nie renderuje: {reason}", "query_not_found": "Nie udało mi się tego znaleźć.", - "budget_spent": "Dzienny budżet tokenów się wyczerpał, więc dziś nie mogę tego zrobić.", + "budget_spent": "W dziennym budżecie tokenów nie zostało na to dość, więc dziś nie mogę tego zrobić.", "auth_failed": "TypeSafe odrzucił klucz API. Sprawdź go w ustawieniach Jev.", "unavailable": "TypeSafe nie odpowiedział. Spróbuj ponownie za chwilę.", "preview_over_budget": "Dzienny limit tokenów jest wyczerpany, więc nie ma próbnej odpowiedzi. Pytanie i tak zostanie zapisane.", diff --git a/custom_components/jev/translations/pt-BR.json b/custom_components/jev/translations/pt-BR.json index 22b1c26..dab4a50 100644 --- a/custom_components/jev/translations/pt-BR.json +++ b/custom_components/jev/translations/pt-BR.json @@ -364,7 +364,7 @@ "preview_mismatched": "A API respondeu, mas não à pergunta que foi feita.", "preview_template_error": "O template não é renderizado: {reason}", "query_not_found": "Não consegui encontrar isso.", - "budget_spent": "O orçamento diário de tokens acabou, então não posso fazer isso hoje.", + "budget_spent": "Não resta orçamento diário de tokens suficiente para isso, então não posso fazer isso hoje.", "auth_failed": "A TypeSafe rejeitou a chave de API. Verifique-a nas configurações do Jev.", "unavailable": "A TypeSafe não respondeu. Tente novamente em instantes.", "preview_over_budget": "O orçamento diário de tokens acabou, então não há resposta de teste. A pergunta ainda é salva.", diff --git a/custom_components/jev/translations/ru.json b/custom_components/jev/translations/ru.json index 1f50471..9742937 100644 --- a/custom_components/jev/translations/ru.json +++ b/custom_components/jev/translations/ru.json @@ -364,7 +364,7 @@ "preview_mismatched": "API ответил, но не на тот вопрос, который был задан.", "preview_template_error": "Шаблон не обрабатывается: {reason}", "query_not_found": "Мне не удалось это найти.", - "budget_spent": "Дневной бюджет токенов исчерпан, поэтому сегодня я не могу это сделать.", + "budget_spent": "В дневном бюджете токенов на это не хватает, поэтому сегодня я не могу это сделать.", "auth_failed": "TypeSafe отклонил ключ API. Проверьте его в настройках Jev.", "unavailable": "TypeSafe не ответил. Попробуйте ещё раз чуть позже.", "preview_over_budget": "Дневной бюджет токенов исчерпан, поэтому пробного ответа нет. Вопрос всё равно сохраняется.", diff --git a/custom_components/jev/translations/sv.json b/custom_components/jev/translations/sv.json index d49d5b2..5f41075 100644 --- a/custom_components/jev/translations/sv.json +++ b/custom_components/jev/translations/sv.json @@ -364,7 +364,7 @@ "preview_mismatched": "API:et svarade, men inte på frågan som ställdes.", "preview_template_error": "Mallen renderas inte: {reason}", "query_not_found": "Det kunde jag inte hitta.", - "budget_spent": "Den dagliga tokenbudgeten är slut, så det kan jag inte göra i dag.", + "budget_spent": "Det finns inte tillräckligt kvar av den dagliga tokenbudgeten för det, så det kan jag inte göra i dag.", "auth_failed": "TypeSafe avvisade API-nyckeln. Kontrollera den i inställningarna för Jev.", "unavailable": "TypeSafe svarade inte. Försök igen om en stund.", "preview_over_budget": "Den dagliga budgeten för token är slut, så det finns inget provsvar. Frågan sparas ändå.", diff --git a/custom_components/jev/translations/zh-Hans.json b/custom_components/jev/translations/zh-Hans.json index 85f813c..1490080 100644 --- a/custom_components/jev/translations/zh-Hans.json +++ b/custom_components/jev/translations/zh-Hans.json @@ -364,7 +364,7 @@ "preview_mismatched": "API 作出了回答,但回答的不是所提的那个问题。", "preview_template_error": "模板无法渲染:{reason}", "query_not_found": "我找不到那个。", - "budget_spent": "今天的令牌预算已用完,所以今天无法执行。", + "budget_spent": "今天剩余的令牌预算不足,所以无法执行。", "auth_failed": "TypeSafe 拒绝了 API 密钥。请在 Jev 设置中检查。", "unavailable": "TypeSafe 没有响应。请稍后再试。", "preview_over_budget": "每日令牌预算已用完,因此没有试答结果。问题仍会保存。", diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 16a3e64..18304fd 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -15,6 +15,7 @@ from homeassistant.core import Context, ServiceCall from homeassistant.helpers import area_registry as ar from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import intent as ha_intent from homeassistant.setup import async_setup_component from jevclient import ChoiceAnswer, NoulAnswer @@ -198,6 +199,7 @@ async def test_a_compound_command_acts_on_nothing(hass, house, mock_client): assert calls == [] assert "did not understand" in result.response.speech["plain"]["speech"] + assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH async def test_low_confidence_acts_on_nothing(hass, house, mock_client): @@ -342,7 +344,8 @@ async def test_a_spent_budget_stops_voice_too(hass, house, mock_client): assert mock_client.ask.await_count == 0 assert calls == [] - assert "budget is spent" in result.response.speech["plain"]["speech"] + assert "budget is left" in result.response.speech["plain"]["speech"] + assert result.response.response_type is ha_intent.IntentResponseType.ERROR async def test_a_command_that_would_pass_the_budget_is_not_sent(hass, house, mock_client): @@ -360,7 +363,8 @@ async def test_a_command_that_would_pass_the_budget_is_not_sent(hass, house, moc assert mock_client.ask.await_count == 0 assert calls == [] - assert "budget is spent" in result.response.speech["plain"]["speech"] + assert "budget is left" in result.response.speech["plain"]["speech"] + assert result.response.response_type is ha_intent.IntentResponseType.ERROR async def test_a_voice_command_teaches_the_estimate(hass, house, mock_client): From 43aa5e1a3cfd2387c0e4c001b8cb472ef425d98f Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:37:19 +0200 Subject: [PATCH 3/3] Check that a voice command records its size, not the ratio it gives --- tests/test_conversation.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 18304fd..99302b2 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -17,7 +17,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers import intent as ha_intent from homeassistant.setup import async_setup_component -from jevclient import ChoiceAnswer, NoulAnswer +from jevclient import ChoiceAnswer, NoulAnswer, Usage from custom_components.jev.const import ( CONF_ALLOW_WHOLE_HOME, @@ -371,16 +371,18 @@ async def test_a_voice_command_teaches_the_estimate(hass, house, mock_client): # The estimate reads bytes per token from the last call it could measure. A # voice command that did not report its size left the estimate on whatever # the last context taught it, which is a different shape of request. - mock_client.ask.return_value = build_response(**answer_set()) + mock_client.ask.return_value = replace( + build_response(**answer_set()), usage=Usage(input_tokens=1371, output_tokens=42) + ) usage = house.runtime_data.usage - usage.bytes_per_token = 1.0 - await converse(hass, "kitchen light on") - await hass.async_block_till_done() + with patch.object(usage, "record", wraps=usage.record) as record: + await converse(hass, "kitchen light on") + await hass.async_block_till_done() sent_state, sent_questions = mock_client.ask.await_args.args sent = payload_bytes(sent_state, sent_questions, house.runtime_data.model) - assert usage.bytes_per_token == sent / 321 + record.assert_called_once_with(1371, sent) async def test_a_rejected_key_is_said_out_loud(hass, house, mock_client):