diff --git a/custom_components/openai_compatible/translations/en.json b/custom_components/openai_compatible/translations/en.json new file mode 100644 index 0000000..5b27b19 --- /dev/null +++ b/custom_components/openai_compatible/translations/en.json @@ -0,0 +1,331 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "credentials_in_url": "The base URL must not contain a username or password. Put authentication in the API key field instead.", + "invalid_auth": "Invalid authentication", + "invalid_base_url": "That does not look like a valid http(s) URL.", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API key", + "base_url": "API base URL" + }, + "data_description": { + "api_key": "Your OpenAI API key.", + "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API." + }, + "description": "Reauthentication required. Please enter your updated API key and confirm the API base URL." + }, + "user": { + "data": { + "api_key": "API key", + "base_url": "API base URL" + }, + "data_description": { + "api_key": "Your OpenAI API key.", + "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API." + }, + "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})." + } + } + }, + "config_subentries": { + "ai_task_data": { + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "Reconfiguration was successful" + }, + "entry_type": "AI task", + "error": { + "code_interpreter_minimal_reasoning": "Code interpreter is not supported with minimal reasoning effort", + "model_not_supported": "This model is not supported, please select a different model", + "web_search_minimal_reasoning": "Web search is currently not supported with minimal reasoning effort" + }, + "initiate_flow": { + "reconfigure": "Reconfigure AI task", + "user": "Add AI task" + }, + "step": { + "additional": { + "data": { + "chat_model": "Model", + "max_tokens": "Maximum tokens to return in response", + "store_responses": "Store requests and responses in OpenAI", + "temperature": "Temperature", + "top_p": "Top P" + }, + "data_description": { + "store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs" + }, + "title": "Additional settings" + }, + "init": { + "data": { + "name": "Name", + "recommended": "Recommended model settings" + } + }, + "model": { + "data": { + "code_interpreter": "Enable code interpreter tool", + "image_model": "Image generation model", + "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", + "reasoning_effort": "Reasoning effort", + "reasoning_summary": "Reasoning summary", + "search_context_size": "Search context size", + "service_tier": "Service tier", + "user_location": "Include home location", + "web_search": "Enable web search" + }, + "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", + "image_model": "The model to use when generating images", + "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", + "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "service_tier": "Controls the cost and response time", + "user_location": "Refine search results based on geography", + "web_search": "Allow the model to search the web for the latest information before generating a response" + }, + "title": "Model-specific options" + } + } + }, + "conversation": { + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "Reconfiguration was successful" + }, + "entry_type": "Conversation agent", + "error": { + "code_interpreter_minimal_reasoning": "Code interpreter is not supported with minimal reasoning effort", + "model_not_supported": "This model is not supported, please select a different model", + "web_search_minimal_reasoning": "Web search is currently not supported with minimal reasoning effort" + }, + "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", + "user": "Add conversation agent" + }, + "step": { + "additional": { + "data": { + "chat_model": "Model", + "max_tokens": "Maximum tokens to return in response", + "store_responses": "Store requests and responses in OpenAI", + "temperature": "Temperature", + "top_p": "Top P" + }, + "data_description": { + "store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs" + }, + "title": "Additional settings" + }, + "init": { + "data": { + "llm_hass_api": "Control Home Assistant", + "name": "Name", + "prompt": "Instructions", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." + } + }, + "model": { + "data": { + "code_interpreter": "Enable code interpreter tool", + "image_model": "Image generation model", + "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", + "reasoning_effort": "Reasoning effort", + "reasoning_summary": "Reasoning summary", + "search_context_size": "Search context size", + "service_tier": "Service tier", + "user_location": "Include home location", + "web_search": "Enable web search" + }, + "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", + "image_model": "The model to use when generating images", + "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", + "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "service_tier": "Controls the cost and response time", + "user_location": "Refine search results based on geography", + "web_search": "Allow the model to search the web for the latest information before generating a response" + }, + "title": "Model-specific options" + } + } + }, + "stt": { + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "Reconfiguration was successful" + }, + "entry_type": "Speech-to-text", + "initiate_flow": { + "reconfigure": "Reconfigure speech-to-text service", + "user": "Add speech-to-text service" + }, + "step": { + "init": { + "data": { + "chat_model": "Model", + "name": "Name", + "prompt": "Instructions" + }, + "data_description": { + "chat_model": "The model to use to transcribe speech.", + "prompt": "Use this prompt to improve the quality of the transcripts. Translate to the pipeline language for best results. See the documentation for more details." + } + } + } + }, + "tts": { + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "Reconfiguration was successful" + }, + "entry_type": "Text-to-speech", + "initiate_flow": { + "reconfigure": "Reconfigure text-to-speech service", + "user": "Add text-to-speech service" + }, + "step": { + "init": { + "data": { + "name": "Name", + "prompt": "Instructions", + "tts_speed": "Speed" + }, + "data_description": { + "prompt": "Control aspects of speech, including accent, emotional range, intonation, impressions, speed of speech, tone, whispering, and more", + "tts_speed": "Speed of the generated speech" + } + } + } + } + }, + "exceptions": { + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + } + }, + "issues": { + "deprecated_generate_content": { + "description": "Action 'openai_compatible.generate_content' is deprecated and will be removed in the 2026.9.0 release. Please use the 'ai_task.generate_data' action instead", + "title": "Deprecated 'generate_content' action" + }, + "deprecated_generate_image": { + "description": "Action 'openai_compatible.generate_image' is deprecated and will be removed in the 2026.9.0 release. Please use the 'ai_task.generate_image' action instead", + "title": "Deprecated 'generate_image' action" + }, + "organization_verification_required": { + "description": "Your organization must be verified to use this model. Please go to {platform_settings} and select Verify Organization. If you just verified, it can take up to 15 minutes for access to propagate.", + "title": "Organization verification required" + } + }, + "selector": { + "reasoning_effort": { + "options": { + "high": "High", + "low": "Low", + "max": "Max", + "medium": "Medium", + "minimal": "Minimal", + "none": "None", + "xhigh": "X-High" + } + }, + "reasoning_summary": { + "options": { + "auto": "Auto", + "concise": "Concise", + "detailed": "Detailed", + "off": "Off" + } + }, + "search_context_size": { + "options": { + "high": "High", + "low": "Low", + "medium": "Medium" + } + }, + "service_tier": { + "options": { + "auto": "Auto", + "default": "Standard", + "flex": "Flex", + "priority": "Priority" + } + }, + "verbosity": { + "options": { + "high": "High", + "low": "Low", + "medium": "Medium" + } + } + }, + "services": { + "generate_content": { + "description": "Sends a conversational query to ChatGPT including any attached image or PDF files (deprecated)", + "fields": { + "config_entry": { + "description": "The config entry to use for this action", + "name": "Config entry" + }, + "filenames": { + "description": "List of files to upload", + "name": "Files" + }, + "prompt": { + "description": "The prompt to send", + "name": "Prompt" + } + }, + "name": "Generate content (deprecated)" + }, + "generate_image": { + "description": "Turns a prompt into an image (deprecated)", + "fields": { + "config_entry": { + "description": "The config entry to use for this action", + "name": "Config entry" + }, + "prompt": { + "description": "The text to turn into an image", + "example": "A photo of a dog", + "name": "Prompt" + }, + "quality": { + "description": "The quality of the image that will be generated", + "name": "Quality" + }, + "size": { + "description": "The size of the image to generate", + "name": "Size" + }, + "style": { + "description": "The style of the generated image", + "name": "Style" + } + }, + "name": "Generate image (deprecated)" + } + } +} diff --git a/scripts/generate_translations.py b/scripts/generate_translations.py new file mode 100644 index 0000000..7fa621d --- /dev/null +++ b/scripts/generate_translations.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Generate translations/en.json from strings.json. + +Home Assistant core builds each integration's translations/ directory from +strings.json as a release step, resolving the `[%key:...%]` references that +strings.json uses to avoid duplicating text. A vendored copy of a core +integration therefore ships strings.json but no translations/ -- and +homeassistant.helpers.translation only ever reads +`/translations/.json`, with no fallback to strings.json. +Without this file every label, description and error in the config flow +renders untranslated in the UI. + +Re-run after syncing strings.json with upstream: + + python scripts/generate_translations.py + +`common::` references resolve against the installed homeassistant package's +own strings.json, so the text matches what core would have produced for the +same HA version. Self-references (`component::openai_compatible::...`) +resolve against strings.json itself. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import sys +from typing import Any + +DOMAIN = "openai_compatible" +COMPONENT_DIR = Path(__file__).resolve().parent.parent / "custom_components" / DOMAIN +STRINGS = COMPONENT_DIR / "strings.json" +OUTPUT = COMPONENT_DIR / "translations" / "en.json" + +RE_KEY = re.compile(r"\[%key:([^%\]]+)%\]") +MAX_PASSES = 10 + + +def core_strings() -> dict[str, Any]: + """Return the installed homeassistant package's strings.json.""" + import homeassistant + + path = Path(homeassistant.__file__).parent / "strings.json" + if not path.is_file(): + raise SystemExit(f"core strings.json not found at {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def lookup(data: dict[str, Any], parts: list[str], ref: str) -> str: + """Walk a `::`-separated reference path, or fail loudly.""" + node: Any = data + for part in parts: + if not isinstance(node, dict) or part not in node: + raise SystemExit(f"unresolvable reference: {ref} (at '{part}')") + node = node[part] + if not isinstance(node, str): + raise SystemExit(f"reference {ref} does not point at a string") + return node + + +def resolve_one(ref: str, own: dict[str, Any], core: dict[str, Any]) -> str: + """Resolve a single reference body to its text.""" + parts = ref.split("::") + if parts[0] == "common": + return lookup(core, parts, ref) + if parts[0] == "component": + if parts[1] != DOMAIN: + # Only core itself can resolve another integration's strings; this + # fork has no reference to one, and silently emitting the raw + # marker would ship a broken label. + raise SystemExit(f"reference to another integration: {ref}") + return lookup(own, parts[2:], ref) + raise SystemExit(f"unknown reference namespace: {ref}") + + +def substitute(node: Any, own: dict[str, Any], core: dict[str, Any]) -> Any: + """Replace every `[%key:...%]` marker in a nested structure.""" + if isinstance(node, dict): + return {k: substitute(v, own, core) for k, v in node.items()} + if isinstance(node, list): + return [substitute(v, own, core) for v in node] + if isinstance(node, str): + return RE_KEY.sub(lambda m: resolve_one(m.group(1), own, core), node) + return node + + +def build() -> dict[str, Any]: + """Return strings.json with all references resolved.""" + own = json.loads(STRINGS.read_text(encoding="utf-8")) + core = core_strings() + + resolved = own + for _ in range(MAX_PASSES): + # A reference can point at a value that is itself a reference, so keep + # substituting until the result stops changing. + nxt = substitute(resolved, own, core) + if nxt == resolved: + break + resolved = nxt + else: + raise SystemExit(f"references still unresolved after {MAX_PASSES} passes") + + leftover = RE_KEY.findall(json.dumps(resolved)) + if leftover: + raise SystemExit(f"unresolved references remain: {sorted(set(leftover))}") + return resolved + + +def main() -> int: + """Write translations/en.json.""" + resolved = build() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(resolved, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"wrote {OUTPUT.relative_to(Path.cwd())}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..ef6ebc4 --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,32 @@ +"""The shipped translations/en.json must exist and match strings.json. + +Home Assistant only reads translations/.json, so a stale or missing +en.json silently degrades every label in the config flow rather than failing +loudly. strings.json is the file that gets edited (and re-synced from core), +so this guards against the two drifting apart. +""" + +import json + +from scripts.generate_translations import OUTPUT, build + + +def test_en_json_exists() -> None: + """A vendored core integration ships no translations/ dir of its own.""" + assert OUTPUT.is_file(), ( + f"{OUTPUT} is missing -- run: python scripts/generate_translations.py" + ) + + +def test_en_json_matches_strings() -> None: + """en.json must be exactly what the generator produces from strings.json.""" + shipped = json.loads(OUTPUT.read_text(encoding="utf-8")) + assert shipped == build(), ( + "translations/en.json is out of sync with strings.json -- run: " + "python scripts/generate_translations.py" + ) + + +def test_no_unresolved_key_references_shipped() -> None: + """A surviving [%key:...%] marker would render literally in the UI.""" + assert "[%key:" not in OUTPUT.read_text(encoding="utf-8")