diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 359094b..e37818d 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__) @@ -72,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.", } @@ -154,14 +157,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 +165,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 +194,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() @@ -267,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, @@ -316,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 a684f01..99302b2 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -15,8 +15,9 @@ 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 +from jevclient import ChoiceAnswer, NoulAnswer, Usage from custom_components.jev.const import ( CONF_ALLOW_WHOLE_HOME, @@ -26,6 +27,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 @@ -197,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): @@ -341,7 +344,45 @@ 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): + # 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 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): + # 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 = replace( + build_response(**answer_set()), usage=Usage(input_tokens=1371, output_tokens=42) + ) + usage = house.runtime_data.usage + + 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) + record.assert_called_once_with(1371, sent) async def test_a_rejected_key_is_said_out_loud(hass, house, mock_client):