Compare commits
13
Commits
4a347369cc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aed494e44 | ||
|
|
e54f0f0fa1 | ||
|
|
84f4bcda55 | ||
|
|
235c536cab | ||
|
|
8737e5d4ec | ||
|
|
43d04305f4 | ||
|
|
a6faa44b2f | ||
|
|
151d741a71 | ||
|
|
4dfb607780 | ||
|
|
b79164b60c | ||
|
|
4611674c6e | ||
|
|
6a599e092a | ||
|
|
030822c845 |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
# Repository instructions
|
||||
|
||||
## Project
|
||||
|
||||
This is a public Home Assistant custom integration (`openai_compatible`) for OpenAI-compatible API endpoints. It adds a configurable base URL to a fork of Home Assistant core's `openai_conversation` integration. Provider entries can have conversation, AI Task, speech-to-text, and text-to-speech subentries.
|
||||
|
||||
The README covers installation, configuration, endpoint requirements, development, and releases. `docs/provider-genericity.md` audits remaining OpenAI-specific behavior and records an open design decision; it is not a scheduled plan. The vendored core revision is recorded in `custom_components/openai_compatible/UPSTREAM_COMMIT`. `hacs.json` sets the minimum Home Assistant version (2026.9.0); `manifest.json` carries the integration version. Development and CI use Python 3.14.
|
||||
|
||||
Production Home Assistant installs this integration through HACS from this repo's GitHub releases; publishing a release and updating in HACS is how a change reaches it (owner's OK). The `hassio` repo's `custom_components/openai_compatible` is only the nightly backup's snapshot of that install; do not edit or copy code into it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `origin` is a public GitHub repository. Any push to `main` publishes the code, not only a release tag. Do not push without the owner's explicit OK.
|
||||
- Changes to a live Home Assistant installation, a configured API provider or host, or a published GitHub release/HACS download need the owner's explicit OK. Read-only inspection is fine. The documented HACS and manual installation paths can change live Home Assistant behavior.
|
||||
- Keep the fork's configurable base URL and separate `openai_compatible` domain intact when syncing upstream code. Preserve config-entry migration, provider/subentry behavior, and the four entity types; tests cover these paths.
|
||||
- Do not put secrets, tokens, passwords, or private keys in any file. Do not search for credentials. Base URLs must reject embedded credentials because URLs can appear in errors and logs; API keys belong in the config flow's key field.
|
||||
- Never list or read the UNAS or `/nfs/media`.
|
||||
|
||||
## Working rules
|
||||
|
||||
- Install test dependencies with `pip install -r requirements_test.txt` under Python 3.14+, then run `pytest tests/ -v`. `pyproject.toml` configures pytest and Ruff, but no Ruff command is listed in CI.
|
||||
- Keep `url_util.py` separate from vendored upstream files so upstream syncing remains manageable. When changing `strings.json`, regenerate `translations/en.json` with `python scripts/generate_translations.py`; the translation test checks that they match.
|
||||
- The README documents release steps. `python scripts/release.py VERSION` requires a clean, current `main`; it changes the manifest, commits, and tags. Pushing the tag publishes through `.github/workflows/release.yaml`. Do not run the release script or push without the owner's explicit OK.
|
||||
|
||||
## Documentation that must be kept current
|
||||
|
||||
- Installation, provider setup, endpoint behavior, or compatibility changes: update `README.md`.
|
||||
- Changes to remaining OpenAI-specific behavior or the capability design decision: update `docs/provider-genericity.md`.
|
||||
- Changes to repo commands, boundaries, or layout: update this file; keep `CLAUDE.md` as the import stub.
|
||||
- Minimum Home Assistant version changes: update `hacs.json` and the README.
|
||||
- Upstream syncs: keep `UPSTREAM_COMMIT` accurate. User-facing string changes: update `strings.json` and regenerate `translations/en.json`.
|
||||
- Release process or version behavior changes: update the README's Releases section and the manifest or workflow as applicable.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Run `pytest tests/ -v` for code or translation changes. CI also runs Hassfest and the HACS validation action on pushes and pull requests; check their results when available.
|
||||
- Keep behavior and docs aligned, including the translation output and upstream revision when affected. For releases, the tag version and `manifest.json` version must match; the release workflow enforces this.
|
||||
|
||||
## Layout
|
||||
|
||||
- `custom_components/openai_compatible/`: integration entry point (`__init__.py`), config flow, four entity platforms, URL/model helpers, manifest, service definitions, and UI strings/translations.
|
||||
- `tests/`: ported upstream regression tests and fork-specific tests; `tests/snapshots/` holds conversation snapshots.
|
||||
- `scripts/generate_translations.py`: builds the shipped English translation. `scripts/release.py`: prepares a version commit and annotated tag.
|
||||
- `.github/workflows/`: pytest, Hassfest/HACS validation, and tag-triggered GitHub release workflows.
|
||||
- `docs/provider-genericity.md`: provider compatibility audit and open design decision.
|
||||
|
||||
## Owner to confirm
|
||||
|
||||
- Which API provider endpoints or hosts does the production Home Assistant installation use?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code
|
||||
|
||||
Repository instructions for all agents live in [AGENTS.md](AGENTS.md). Edit them there.
|
||||
|
||||
@AGENTS.md
|
||||
@@ -4,6 +4,28 @@
|
||||
[](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml)
|
||||
[](https://hacs.xyz)
|
||||
|
||||
> **Fork of [adamjs83/ha-openai-compatible](https://github.com/adamjs83/ha-openai-compatible) v0.2.1**
|
||||
> with fixes for running it against a proxy that serves Claude models. Drop it once
|
||||
> upstream has fixed these issues:
|
||||
>
|
||||
> - [#3](https://github.com/adamjs83/ha-openai-compatible/issues/3): a tool call with an
|
||||
> empty argument string (tools without parameters) is treated as `{}` instead of
|
||||
> raising `JSONDecodeError`.
|
||||
> - [#4](https://github.com/adamjs83/ha-openai-compatible/issues/4): earlier web searches
|
||||
> are not replayed in the history; they carry no results, and the model then disowns
|
||||
> its own answers and searches again.
|
||||
> - [#5](https://github.com/adamjs83/ha-openai-compatible/issues/5): the home location
|
||||
> lookup for web search uses the subentry's model instead of `gpt-4o-mini` and asks
|
||||
> as a user message (Anthropic needs one); a failed lookup no longer blocks saving
|
||||
> the options, and empty location fields are left out of the web search tool.
|
||||
> - Requests that Home Assistant's local intents answered (`prefer_local_intents`) are
|
||||
> described to the model in a note instead of being replayed as its own calls of
|
||||
> intents like `HassGetCurrentTime`, which are not among its tools; replayed, they
|
||||
> make it apologise for using tools it does not have. Not reported upstream yet.
|
||||
>
|
||||
> HACS only installs from GitHub, so this fork is installed by copying
|
||||
> `custom_components/openai_compatible` into the Home Assistant config.
|
||||
|
||||
Home Assistant's built-in **OpenAI** integration, with one change that matters: the
|
||||
**API base URL is configurable**. Point Home Assistant's conversation agent, AI Task,
|
||||
speech-to-text and text-to-speech at any OpenAI-compatible endpoint — LiteLLM, vLLM,
|
||||
@@ -144,6 +166,16 @@ know about anyway:
|
||||
string. Options are offered based on those name patterns, and the endpoint's own error
|
||||
is what you'll see if you ask for something it can't do.
|
||||
|
||||
## Known OpenAI-specific behaviour
|
||||
|
||||
The base URL is configurable, but much of the rest is still upstream's OpenAI-shaped
|
||||
logic: some request parameters are sent to every provider, and which options the config
|
||||
flow offers is inferred from OpenAI model-name prefixes. That means options can appear
|
||||
for endpoints that cannot honour them, and be withheld from endpoints that could.
|
||||
|
||||
[`docs/provider-genericity.md`](docs/provider-genericity.md) has the full audit, what
|
||||
each item costs, and the design decision that has to be made first.
|
||||
|
||||
## Relationship to Home Assistant core
|
||||
|
||||
`custom_components/openai_compatible/` is derived from
|
||||
@@ -151,6 +183,10 @@ know about anyway:
|
||||
(commit recorded in [`UPSTREAM_COMMIT`](custom_components/openai_compatible/UPSTREAM_COMMIT)),
|
||||
with the domain renamed to `openai_compatible` and the base URL made configurable.
|
||||
|
||||
The manifest requires `openai>=2.45.0` rather than core's exact pin: Home Assistant
|
||||
installs custom-integration requirements against its own package constraints, so an
|
||||
exact pin stops the integration loading whenever core bumps `openai`.
|
||||
|
||||
Base-URL handling lives in its own module,
|
||||
[`url_util.py`](custom_components/openai_compatible/url_util.py), deliberately separate
|
||||
from the vendored files so re-syncing with core stays a clean merge.
|
||||
|
||||
@@ -294,7 +294,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
|
||||
_ = await hass.async_add_executor_job(client.platform_headers)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
|
||||
await client.models.list(timeout=10.0)
|
||||
except openai.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except openai.OpenAIError as err:
|
||||
|
||||
@@ -104,7 +104,7 @@ from .const import (
|
||||
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
|
||||
UNSUPPORTED_WEB_SEARCH_MODELS,
|
||||
)
|
||||
from .model_list import async_fetch_model_ids, model_field
|
||||
from .model_list import async_fetch_model_ids, default_model, model_field
|
||||
from .url_util import (
|
||||
CredentialsInBaseURL,
|
||||
InvalidBaseURL,
|
||||
@@ -325,14 +325,35 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
}
|
||||
)
|
||||
|
||||
# The model belongs on the first screen. Picking one is the entire
|
||||
# point of adding an endpoint, and behind the "recommended" checkbox
|
||||
# it was unreachable: ticked, this step creates the subentry and the
|
||||
# model silently stays RECOMMENDED_CHAT_MODEL -- an OpenAI name a
|
||||
# third-party provider will not serve. entity.py reads
|
||||
# CONF_CHAT_MODEL regardless of the recommended flag, so a model
|
||||
# chosen here takes effect on either path.
|
||||
model_ids = await async_fetch_model_ids(self._get_entry().runtime_data)
|
||||
step_schema[
|
||||
vol.Optional(CONF_CHAT_MODEL, default=default_model(model_ids))
|
||||
] = model_field(model_ids)
|
||||
|
||||
step_schema[
|
||||
vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False))
|
||||
] = bool
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_LLM_HASS_API) is None:
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
|
||||
# Checked here rather than on the next step, because the
|
||||
# recommended path creates the subentry from this one and would
|
||||
# otherwise skip the check entirely.
|
||||
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
|
||||
errors[CONF_CHAT_MODEL] = "model_not_supported"
|
||||
|
||||
if not errors:
|
||||
if user_input[CONF_RECOMMENDED]:
|
||||
if self._is_new:
|
||||
return self.async_create_entry(
|
||||
@@ -355,6 +376,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(step_schema), options
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_additional(
|
||||
@@ -365,14 +387,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
step_schema: VolDictType = {
|
||||
# Upstream makes this a free-text box defaulting to an OpenAI
|
||||
# model name. Offer what the provider actually serves instead.
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
): model_field(
|
||||
await async_fetch_model_ids(self._get_entry().runtime_data)
|
||||
),
|
||||
# CONF_CHAT_MODEL is asked on the init step, so that it is
|
||||
# reachable without unticking "recommended" first.
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
@@ -393,8 +409,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
if user_input is not None:
|
||||
options.update(user_input)
|
||||
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
|
||||
errors[CONF_CHAT_MODEL] = "model_not_supported"
|
||||
|
||||
if not errors:
|
||||
return await self.async_step_model()
|
||||
@@ -688,11 +702,17 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
): str,
|
||||
}
|
||||
)
|
||||
# Ask the subentry's own model: the endpoint may not serve
|
||||
# RECOMMENDED_CHAT_MODEL (an OpenAI name). A failed lookup only
|
||||
# costs the city/region hint, so it must not block saving.
|
||||
try:
|
||||
response = await client.responses.create(
|
||||
model=RECOMMENDED_CHAT_MODEL,
|
||||
model=self.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
# A user message, not a system one: endpoints in front of
|
||||
# Anthropic models reject a request without user content.
|
||||
input=[
|
||||
{
|
||||
"role": "system",
|
||||
"role": "user",
|
||||
"content": "Where are the following coordinates located: "
|
||||
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
|
||||
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
|
||||
@@ -713,6 +733,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
),
|
||||
)
|
||||
location_data = location_schema(json.loads(response.output_text) or {})
|
||||
except (openai.OpenAIError, ValueError, vol.Invalid) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not look up the home location for web search: %s", err
|
||||
)
|
||||
|
||||
if self.hass.config.country:
|
||||
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
|
||||
|
||||
@@ -22,7 +22,6 @@ from openai.types.responses import (
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseFunctionWebSearch,
|
||||
ResponseFunctionWebSearchParam,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInputFileParam,
|
||||
ResponseInputImageParam,
|
||||
@@ -181,20 +180,35 @@ def _convert_content_to_param(
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: ResponseInputParam = []
|
||||
reasoning_summary: list[str] = []
|
||||
web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {}
|
||||
# Requests that Home Assistant's local intents answered without the model
|
||||
# (prefer_local_intents) are in the chat log as calls of intents like
|
||||
# HassGetCurrentTime, which are not among the model's tools. Replayed as
|
||||
# the model's own calls, they make it apologise for using tools it does
|
||||
# not have, so they are described to it as a note instead.
|
||||
local_calls: dict[str, llm.ToolInput] = {}
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
if (
|
||||
content.tool_name == "web_search_call"
|
||||
and content.tool_call_id in web_search_calls
|
||||
):
|
||||
web_search_call = web_search_calls.pop(content.tool_call_id)
|
||||
web_search_call["status"] = content.tool_result.get( # type: ignore[typeddict-item]
|
||||
"status", "completed"
|
||||
if (tool_call := local_calls.pop(content.tool_call_id, None)) is not None:
|
||||
messages.append(
|
||||
EasyInputMessageParam(
|
||||
type="message",
|
||||
role="developer",
|
||||
content=(
|
||||
"Home Assistant's local intent recognition answered "
|
||||
"the last user request itself, without you: it ran "
|
||||
f"{tool_call.tool_name} with "
|
||||
f"{json_dumps(tool_call.tool_args)} and got "
|
||||
f"{json_dumps(content.tool_result)}. The assistant "
|
||||
"reply after this note is its wording, not yours. "
|
||||
"That intent is not one of your tools."
|
||||
),
|
||||
)
|
||||
messages.append(web_search_call)
|
||||
else:
|
||||
)
|
||||
# Earlier web searches are not replayed: the chat log only keeps
|
||||
# their status, not their results, and a search without results
|
||||
# makes the model disown the answer it based on that search.
|
||||
elif content.tool_name != "web_search_call":
|
||||
messages.append(
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
@@ -217,18 +231,11 @@ def _convert_content_to_param(
|
||||
if isinstance(content, conversation.AssistantContent):
|
||||
if content.tool_calls:
|
||||
for tool_call in content.tool_calls:
|
||||
if (
|
||||
tool_call.external
|
||||
and tool_call.tool_name == "web_search_call"
|
||||
and "action" in tool_call.tool_args
|
||||
):
|
||||
web_search_calls[tool_call.id] = ResponseFunctionWebSearchParam(
|
||||
type="web_search_call",
|
||||
id=tool_call.id,
|
||||
action=tool_call.tool_args["action"],
|
||||
status="completed",
|
||||
)
|
||||
else:
|
||||
if tool_call.external and tool_call.tool_name == "web_search_call":
|
||||
continue
|
||||
if tool_call.external and tool_call.tool_name != "code_interpreter":
|
||||
local_calls[tool_call.id] = tool_call
|
||||
continue
|
||||
messages.append(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
@@ -418,7 +425,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
llm.ToolInput(
|
||||
id=current_tool_call.call_id,
|
||||
tool_name=current_tool_call.name,
|
||||
tool_args=json.loads(current_tool_call.arguments),
|
||||
tool_args=json.loads(current_tool_call.arguments or "{}"),
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -578,12 +585,20 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
# Leave out unknown fields: some endpoints reject empty strings,
|
||||
# and the location lookup may have found no city or region.
|
||||
location = {
|
||||
key: value
|
||||
for key, option in (
|
||||
("city", CONF_WEB_SEARCH_CITY),
|
||||
("region", CONF_WEB_SEARCH_REGION),
|
||||
("country", CONF_WEB_SEARCH_COUNTRY),
|
||||
("timezone", CONF_WEB_SEARCH_TIMEZONE),
|
||||
)
|
||||
if (value := options.get(option))
|
||||
}
|
||||
web_search["user_location"] = UserLocation(
|
||||
type="approximate",
|
||||
city=options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
region=options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
type="approximate", **location
|
||||
)
|
||||
if not options.get(
|
||||
CONF_WEB_SEARCH_INLINE_CITATIONS,
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
{
|
||||
"domain": "openai_compatible",
|
||||
"name": "OpenAI Compatible",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@adamjs83"],
|
||||
"after_dependencies": [
|
||||
"assist_pipeline",
|
||||
"intent"
|
||||
],
|
||||
"codeowners": [
|
||||
"@adamjs83"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": ["conversation"],
|
||||
"dependencies": [
|
||||
"conversation"
|
||||
],
|
||||
"documentation": "https://github.com/adamjs83/ha-openai-compatible",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/adamjs83/ha-openai-compatible/issues",
|
||||
"requirements": ["openai==2.45.0"],
|
||||
"version": "1.0.0"
|
||||
"requirements": [
|
||||
"openai>=2.45.0"
|
||||
],
|
||||
"version": "0.2.1"
|
||||
}
|
||||
@@ -22,7 +22,7 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import LOGGER, RECOMMENDED_CHAT_MODEL
|
||||
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
@@ -48,6 +48,14 @@ async def async_fetch_model_ids(
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.debug("Could not list models: %s", err)
|
||||
return []
|
||||
except Exception: # noqa: BLE001 - see below
|
||||
# Deliberately broad. This runs inside a config-flow form and has a
|
||||
# working fallback (a free-text model field), so nothing it can raise
|
||||
# justifies taking the dialog down: a proxy may return a payload the
|
||||
# SDK cannot parse, and the failure modes of arbitrary
|
||||
# OpenAI-compatible servers are not enumerable in advance.
|
||||
LOGGER.debug("Unexpected error listing models", exc_info=True)
|
||||
return []
|
||||
|
||||
return sorted({model.id for model in page.data if getattr(model, "id", None)})
|
||||
|
||||
@@ -71,3 +79,17 @@ def model_field(model_ids: list[str]) -> Any:
|
||||
sort=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def default_model(model_ids: list[str]) -> str:
|
||||
"""Return the model to preselect for a new endpoint.
|
||||
|
||||
Upstream's default is RECOMMENDED_CHAT_MODEL, an OpenAI name. Offering it
|
||||
to someone configuring a third-party provider preselects a model that will
|
||||
fail on first use, so prefer it only when the provider actually serves it
|
||||
(or when discovery told us nothing) and otherwise start from what is
|
||||
advertised.
|
||||
"""
|
||||
if not model_ids or RECOMMENDED_CHAT_MODEL in model_ids:
|
||||
return RECOMMENDED_CHAT_MODEL
|
||||
return model_ids[0]
|
||||
@@ -123,12 +123,12 @@
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"store_responses": "Store requests and responses in OpenAI",
|
||||
"store_responses": "Store requests and responses on the provider",
|
||||
"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"
|
||||
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||
},
|
||||
"title": "Additional settings"
|
||||
},
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
"data": {
|
||||
"chat_model": "Model",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"store_responses": "Store requests and responses in OpenAI",
|
||||
"store_responses": "Store requests and responses on the provider",
|
||||
"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"
|
||||
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||
},
|
||||
"title": "Additional settings"
|
||||
},
|
||||
@@ -123,12 +123,12 @@
|
||||
"data": {
|
||||
"chat_model": "Model",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"store_responses": "Store requests and responses in OpenAI",
|
||||
"store_responses": "Store requests and responses on the provider",
|
||||
"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"
|
||||
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||
},
|
||||
"title": "Additional settings"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Provider genericity: audit and roadmap
|
||||
|
||||
This fork currently makes one thing configurable — the API base URL — and inherits
|
||||
everything else from `homeassistant/components/openai_conversation`. That is enough to
|
||||
reach an OpenAI-compatible endpoint, and not enough to behave correctly against one.
|
||||
|
||||
This document records what is still OpenAI-specific, what each item actually costs, and
|
||||
the design decision that has to be made before most of it can be fixed. It is an audit,
|
||||
not a plan of record: nothing here is scheduled.
|
||||
|
||||
Line references are against the tree at the time of writing; treat them as starting
|
||||
points, not gospel.
|
||||
|
||||
## Why a proxy makes this hard to notice
|
||||
|
||||
A gateway such as LiteLLM can be configured to drop parameters the target provider does
|
||||
not support (`drop_params`). With that on, an endpoint absorbs OpenAI-only parameters
|
||||
silently and requests succeed — so the payload problem below never surfaces, while
|
||||
options that do nothing look like options that work.
|
||||
|
||||
That cuts both ways, and it is the single most important thing to understand about
|
||||
testing this integration:
|
||||
|
||||
- **A dropping proxy hides finding 1.** No error is ever raised, so passing tests
|
||||
against it say nothing about portability.
|
||||
- **A dropping proxy makes finding 2 worse.** A parameter that is silently discarded
|
||||
turns a misconfigured option into a no-op rather than an error, with nothing in the UI
|
||||
or the log to indicate it had no effect.
|
||||
|
||||
A strict server (vLLM, llama.cpp, a minimal home-grown endpoint) is the honest test
|
||||
target, because it will reject what it does not implement.
|
||||
|
||||
## 1. OpenAI-only parameters on every request
|
||||
|
||||
`entity.py`, in the `ResponseCreateParamsStreaming` construction at line 515:
|
||||
|
||||
| Parameter | Sent when | Line |
|
||||
| --- | --- | --- |
|
||||
| `service_tier` | always | 520 |
|
||||
| `store` | always | 521 |
|
||||
| `prompt_cache_retention: "24h"` | always, unless the model name matches `UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS` | 563 |
|
||||
| `reasoning`, `include: ["reasoning.encrypted_content"]` | model name starts with `o` or `gpt-5` | 525-544 |
|
||||
| `text.verbosity` | model name starts with `gpt-5` | 555-557 |
|
||||
|
||||
`service_tier`, `store` and `prompt_cache_retention` are therefore sent to every
|
||||
provider, whatever it is. A server that validates its input strictly returns 400; a
|
||||
dropping proxy discards them and the request succeeds.
|
||||
|
||||
**Cost:** breaks strict servers outright. Invisible behind a dropping proxy.
|
||||
|
||||
## 2. Capability inferred from OpenAI model names
|
||||
|
||||
There are 23 `startswith(...)` model-name gates across the component
|
||||
(`config_flow.py` 11, `entity.py` 9, `__init__.py` 2, `ai_task.py` 1), plus the
|
||||
`UNSUPPORTED_*` lists in `const.py`, which enumerate OpenAI model names.
|
||||
|
||||
Against a third-party model string, every one of those checks is meaningless — and it
|
||||
fails in both directions:
|
||||
|
||||
- **Options offered that the provider cannot honour.** The `UNSUPPORTED_*` lists contain
|
||||
only OpenAI names, so a name like `claude-sonnet-4-6` never matches one and the option
|
||||
is shown. Service tier ("Controls the cost and response time") is offered for every
|
||||
such model. Code interpreter and web search are OpenAI *hosted tools*, offered
|
||||
unconditionally, and almost no compatible server implements either.
|
||||
- **Options withheld that the provider does support.** Reasoning effort, reasoning
|
||||
summary and verbosity are only offered when the name starts with `o` or `gpt-5`. Point
|
||||
an endpoint at a reasoning-capable model whose name does not look like OpenAI's and
|
||||
there is no way to set a reasoning effort at all — the field never renders. A dropping
|
||||
proxy cannot compensate for this one, because the parameter is never sent.
|
||||
|
||||
**Cost:** the config UI misrepresents the endpoint in both directions. This is the
|
||||
finding to fix first: it is what a user sees, and a dropping proxy makes it silent
|
||||
rather than loud.
|
||||
|
||||
## 3. OpenAI branding in entities and errors
|
||||
|
||||
- `manufacturer="OpenAI"` on every device (`entity.py:492`)
|
||||
- `DEFAULT_CONVERSATION_NAME`, `DEFAULT_AI_TASK_NAME`, `DEFAULT_STT_NAME`,
|
||||
`DEFAULT_TTS_NAME` in `const.py` — "OpenAI Conversation", "OpenAI AI Task", …
|
||||
- user-facing error text: `OpenAI response incomplete` (459), `OpenAI response failed`
|
||||
(473), `OpenAI response error` (475), `Error talking to OpenAI` (719-720)
|
||||
|
||||
**Cost:** cosmetic, but the errors are the ones a user reads while debugging their own
|
||||
provider, which makes them actively misleading.
|
||||
|
||||
## 4. Stale upstream notices
|
||||
|
||||
- The `deprecated_generate_content` and `deprecated_generate_image` repair issues state
|
||||
the actions will be removed "in the 2026.9.0 release". That is Home Assistant core's
|
||||
schedule for its own integration, it has already passed, and this fork has made no
|
||||
such commitment.
|
||||
- `organization_verification_required` is an OpenAI-platform-specific repair that
|
||||
directs the user to OpenAI's settings page — meaningless for any other provider.
|
||||
|
||||
**Cost:** informational, but wrong, and repair issues are prominent in the UI.
|
||||
|
||||
## The open design decision
|
||||
|
||||
The OpenAI API has no capability discovery: `GET /v1/models` returns ids, not feature
|
||||
support. So "which options should this endpoint offer" cannot be answered by asking the
|
||||
provider, and something has to stand in for it. The candidates:
|
||||
|
||||
1. **A per-entry capability toggle** — "this endpoint supports OpenAI-specific
|
||||
features", defaulted on when the host is `api.openai.com`. Explicit, one decision per
|
||||
provider, and wrong only if the user sets it wrong.
|
||||
2. **Host detection alone** — infer from the base URL. No user burden; wrong for Azure
|
||||
or OpenAI reached through a proxy, which is a normal setup here.
|
||||
3. **Per-endpoint feature checkboxes** — let each endpoint declare what to send. Most
|
||||
precise, most tedious, and it asks the user to know their provider's API surface.
|
||||
|
||||
The shape that was recommended, and not agreed: a portable payload by default, with
|
||||
OpenAI extras behind option 1. Deciding this is the prerequisite for findings 1 and 2;
|
||||
findings 3 and 4 are independent and can be done at any time.
|
||||
@@ -1,6 +1,7 @@
|
||||
# Requires Python 3.14+ (Home Assistant 2026.9 floor). Not installable on Python 3.13.
|
||||
pytest-homeassistant-custom-component>=0.13.317
|
||||
openai==2.45.0
|
||||
# Core 2026.10's pin; the manifest only sets a floor.
|
||||
openai==3.10.0
|
||||
|
||||
# The manifest declares dependencies: ["conversation"], and the ported tests exercise
|
||||
# stt/tts too; a full HA install resolves each core component's own `requirements` at
|
||||
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
"""Fixtures for openai_compatible tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -154,13 +154,19 @@ async def mock_config_entry_with_reasoning_model(
|
||||
@pytest.fixture
|
||||
async def mock_init_component(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Initialize integration."""
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Initialize integration.
|
||||
|
||||
The patch stays active for the whole test: setup awaits GET /models, and a
|
||||
test that changes a subentry reloads the entry, which runs setup again.
|
||||
"""
|
||||
with patch(
|
||||
"openai.resources.models.AsyncModels.list",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
+54
-3
@@ -2,6 +2,11 @@
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
@@ -26,7 +31,7 @@ async def test_client_built_with_base_url(hass: HomeAssistant) -> None:
|
||||
"custom_components.openai_compatible.openai.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.platform_headers = lambda: {}
|
||||
mock_client.return_value.with_options.return_value.models.list = AsyncMock()
|
||||
mock_client.return_value.models.list = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -47,7 +52,7 @@ async def test_api_key_optional(hass: HomeAssistant) -> None:
|
||||
"custom_components.openai_compatible.openai.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.platform_headers = lambda: {}
|
||||
mock_client.return_value.with_options.return_value.models.list = AsyncMock()
|
||||
mock_client.return_value.models.list = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -73,10 +78,56 @@ async def test_migration_backfills_missing_base_url(hass: HomeAssistant) -> None
|
||||
"custom_components.openai_compatible.openai.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.platform_headers = lambda: {}
|
||||
mock_client.return_value.with_options.return_value.models.list = AsyncMock()
|
||||
mock_client.return_value.models.list = AsyncMock()
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_BASE_URL] == DEFAULT_BASE_URL
|
||||
assert entry.minor_version == 8
|
||||
assert mock_client.call_args.kwargs["base_url"] == DEFAULT_BASE_URL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "state"),
|
||||
[
|
||||
(
|
||||
openai.AuthenticationError(
|
||||
"bad key",
|
||||
response=httpx.Response(401, request=httpx.Request("GET", "http://x")),
|
||||
body=None,
|
||||
),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
(
|
||||
openai.APIConnectionError(request=httpx.Request("GET", "http://x")),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_setup_checks_connection(
|
||||
hass: HomeAssistant, error: Exception, state: ConfigEntryState
|
||||
) -> None:
|
||||
"""Setup must actually call GET /models and act on its failure.
|
||||
|
||||
AsyncModels.list returns an awaitable paginator; handing the bound method to
|
||||
the executor returned it unawaited, so no request was made and a bad key or
|
||||
unreachable endpoint still set up.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={"api_key": "sk-test", CONF_BASE_URL: "http://192.0.2.10:4000/v1"},
|
||||
version=2,
|
||||
minor_version=8,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.openai_compatible.openai.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.platform_headers = lambda: {}
|
||||
mock_client.return_value.models.list = AsyncMock(side_effect=error)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_client.return_value.models.list.assert_awaited_once_with(timeout=10.0)
|
||||
assert entry.state is state
|
||||
+105
-36
@@ -177,6 +177,9 @@ async def test_creating_conversation_subentry(
|
||||
|
||||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||||
# The init step now asks for the model, so the created subentry records
|
||||
# one explicitly instead of falling back to the default at request time.
|
||||
processed_options[CONF_CHAT_MODEL] = RECOMMENDED_CHAT_MODEL
|
||||
|
||||
assert result2["data"] == processed_options
|
||||
|
||||
@@ -239,23 +242,17 @@ async def test_subentry_unsupported_model(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "o1-mini",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "additional"
|
||||
|
||||
# Configure additional step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: "o1-mini",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
# The model is asked for, and so validated, on the init step -- the flow
|
||||
# stays there and reports the error against the field.
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "init"
|
||||
assert subentry_flow["errors"] == {"chat_model": "model_not_supported"}
|
||||
|
||||
|
||||
@@ -295,6 +292,7 @@ async def test_subentry_reasoning_effort_list(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: model,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -305,9 +303,7 @@ async def test_subentry_reasoning_effort_list(
|
||||
# Configure additional step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: model,
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
@@ -349,6 +345,7 @@ async def test_subentry_reasoning_summary_visibility(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: model,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -359,9 +356,7 @@ async def test_subentry_reasoning_summary_visibility(
|
||||
# Configure additional step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: model,
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
@@ -398,6 +393,7 @@ async def test_subentry_reasoning_summary_options(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: model,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -407,9 +403,7 @@ async def test_subentry_reasoning_summary_options(
|
||||
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: model,
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
@@ -446,6 +440,7 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "o3",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -454,7 +449,7 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
|
||||
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{CONF_CHAT_MODEL: "o3"},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
|
||||
@@ -512,6 +507,7 @@ async def test_subentry_service_tier_list(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: model,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -522,9 +518,7 @@ async def test_subentry_service_tier_list(
|
||||
# Configure additional step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: model,
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
@@ -558,6 +552,7 @@ async def test_subentry_unsupported_reasoning_effort(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-5",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
@@ -568,9 +563,7 @@ async def test_subentry_unsupported_reasoning_effort(
|
||||
# Configure additional step
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: "gpt-5",
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
@@ -706,11 +699,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "o1-pro",
|
||||
CONF_PROMPT: "Speak like a pro",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_CHAT_MODEL: "o1-pro",
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: 10000,
|
||||
},
|
||||
@@ -740,11 +733,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.3,
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||
},
|
||||
@@ -797,11 +790,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_PROMPT: "Speak like super Mario",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.8,
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
CONF_STORE_RESPONSES: True,
|
||||
@@ -852,11 +845,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-5.6",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.8,
|
||||
CONF_CHAT_MODEL: "gpt-5.6",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
},
|
||||
@@ -975,11 +968,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "o3-mini",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.8,
|
||||
CONF_CHAT_MODEL: "o3-mini",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
},
|
||||
@@ -1020,11 +1013,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.8,
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
},
|
||||
@@ -1075,11 +1068,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||||
(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-5-pro",
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
},
|
||||
{
|
||||
CONF_TEMPERATURE: 0.8,
|
||||
CONF_CHAT_MODEL: "gpt-5-pro",
|
||||
CONF_TOP_P: 0.9,
|
||||
CONF_MAX_TOKENS: 1000,
|
||||
},
|
||||
@@ -1155,7 +1148,13 @@ async def test_subentry_switching(
|
||||
|
||||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||
assert subentry.data == expected_options
|
||||
# The init step always asks for a model, so every subentry records one.
|
||||
# Cases that do not name one get the default, which is what the flow
|
||||
# submits for them; cases that do still win, being unpacked second.
|
||||
assert subentry.data == {
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
**expected_options,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("store_responses", [False, True])
|
||||
@@ -1183,6 +1182,7 @@ async def test_subentry_web_search_user_location(
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
},
|
||||
)
|
||||
assert subentry_flow["type"] is FlowResultType.FORM
|
||||
@@ -1193,7 +1193,6 @@ async def test_subentry_web_search_user_location(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||
CONF_STORE_RESPONSES: store_responses,
|
||||
@@ -1275,6 +1274,75 @@ async def test_subentry_web_search_user_location(
|
||||
}
|
||||
|
||||
|
||||
async def test_subentry_web_search_user_location_lookup_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test the location lookup uses the subentry's model and may fail.
|
||||
|
||||
An endpoint that only serves other providers' models rejects the OpenAI
|
||||
default; saving must still succeed with country and time zone only.
|
||||
"""
|
||||
subentry = next(
|
||||
sub
|
||||
for sub in mock_config_entry.subentries.values()
|
||||
if sub.subentry_type == "conversation"
|
||||
)
|
||||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||||
hass, subentry.subentry_id
|
||||
)
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_CHAT_MODEL: "claude-sonnet-5-5",
|
||||
},
|
||||
)
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||
CONF_STORE_RESPONSES: False,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert subentry_flow["step_id"] == "model"
|
||||
|
||||
hass.config.country = "DE"
|
||||
hass.config.time_zone = "Europe/Berlin"
|
||||
hass.states.async_set("zone.home", "0", {"latitude": 50.9, "longitude": 12.1})
|
||||
with patch(
|
||||
"openai.resources.responses.AsyncResponses.create",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=BadRequestError(
|
||||
response=httpx.Response(status_code=400, request=httpx.Request("POST", "")),
|
||||
body=None,
|
||||
message="unknown provider for model",
|
||||
),
|
||||
) as mock_create:
|
||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||
subentry_flow["flow_id"],
|
||||
{
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_create.call_args.kwargs["model"] == "claude-sonnet-5-5"
|
||||
assert mock_create.call_args.kwargs["input"][0]["role"] == "user"
|
||||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||
assert CONF_WEB_SEARCH_CITY not in subentry.data
|
||||
assert CONF_WEB_SEARCH_REGION not in subentry.data
|
||||
assert subentry.data[CONF_WEB_SEARCH_COUNTRY] == "DE"
|
||||
assert subentry.data[CONF_WEB_SEARCH_TIMEZONE] == "Europe/Berlin"
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -1307,6 +1375,7 @@ async def test_creating_ai_task_subentry(
|
||||
assert result2.get("title") == "Custom AI Task"
|
||||
assert result2.get("data") == {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||
}
|
||||
|
||||
assert (
|
||||
@@ -1354,6 +1423,7 @@ async def test_creating_ai_task_subentry_additional(
|
||||
{
|
||||
"name": "Advanced AI Task",
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1364,7 +1434,6 @@ async def test_creating_ai_task_subentry_additional(
|
||||
result3 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: "gpt-4o",
|
||||
CONF_MAX_TOKENS: 200,
|
||||
CONF_STORE_RESPONSES: True,
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
|
||||
@@ -231,19 +231,6 @@ async def test_conversation_agent(
|
||||
@pytest.mark.parametrize(
|
||||
("description", "messages"),
|
||||
[
|
||||
(
|
||||
"Test function call started with missing arguments",
|
||||
(
|
||||
*create_function_tool_call_item(
|
||||
id="fc_1",
|
||||
arguments=[],
|
||||
call_id="call_call_1",
|
||||
name="test_tool",
|
||||
output_index=0,
|
||||
),
|
||||
*create_message_item(id="msg_A", text="Cool", output_index=1),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Test invalid JSON",
|
||||
(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for the fork's chat log conversion fixes in entity.py."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
from custom_components.openai_compatible.const import (
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
)
|
||||
from custom_components.openai_compatible.entity import (
|
||||
_convert_content_to_param,
|
||||
_transform_stream,
|
||||
)
|
||||
|
||||
from . import create_function_tool_call_item, create_message_item
|
||||
|
||||
|
||||
async def _stream(events):
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
|
||||
async def test_tool_call_without_arguments() -> None:
|
||||
"""Test an empty argument string means a call without parameters.
|
||||
|
||||
Some OpenAI-compatible endpoints send "" instead of "{}" for tools that
|
||||
take no parameters, e.g. the timer status intent.
|
||||
"""
|
||||
events = create_function_tool_call_item(
|
||||
id="fc_1",
|
||||
arguments=[],
|
||||
call_id="call_1",
|
||||
name="HassTimerStatus",
|
||||
output_index=0,
|
||||
)
|
||||
|
||||
deltas = [delta async for delta in _transform_stream(MagicMock(), _stream(events))]
|
||||
|
||||
tool_calls = [call for delta in deltas for call in delta.get("tool_calls", [])]
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0].tool_name == "HassTimerStatus"
|
||||
assert tool_calls[0].tool_args == {}
|
||||
|
||||
|
||||
def test_web_search_not_replayed() -> None:
|
||||
"""Test earlier web searches are left out of the history.
|
||||
|
||||
The chat log keeps only a search's status, not its results. Replaying the
|
||||
bare search makes the model disown the answer it based on that search.
|
||||
"""
|
||||
content = [
|
||||
conversation.UserContent(content="How did the match go?"),
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.test",
|
||||
tool_calls=[
|
||||
llm.ToolInput(
|
||||
id="ws_1",
|
||||
tool_name="web_search_call",
|
||||
tool_args={"action": {"type": "search", "query": "match"}},
|
||||
external=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
conversation.ToolResultContent(
|
||||
agent_id="conversation.test",
|
||||
tool_call_id="ws_1",
|
||||
tool_name="web_search_call",
|
||||
result=llm.ToolResult(data={"status": "completed"}),
|
||||
),
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.test",
|
||||
content="It ended 2 to 2.",
|
||||
tool_calls=[
|
||||
llm.ToolInput(id="call_1", tool_name="GetLiveContext", tool_args={})
|
||||
],
|
||||
),
|
||||
conversation.ToolResultContent(
|
||||
agent_id="conversation.test",
|
||||
tool_call_id="call_1",
|
||||
tool_name="GetLiveContext",
|
||||
result=llm.ToolResult(data={"success": True}),
|
||||
),
|
||||
]
|
||||
|
||||
messages = _convert_content_to_param(content)
|
||||
|
||||
assert [message["type"] for message in messages] == [
|
||||
"message",
|
||||
"message",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
]
|
||||
assert messages[1]["content"] == "It ended 2 to 2."
|
||||
assert messages[2]["call_id"] == "call_1"
|
||||
|
||||
|
||||
async def test_web_search_location_leaves_out_empty_fields(
|
||||
hass, mock_config_entry, mock_init_component, mock_create_stream
|
||||
) -> None:
|
||||
"""Test unknown location fields are left out of the web search tool.
|
||||
|
||||
A failed location lookup stores no city or region; endpoints in front of
|
||||
Anthropic models reject empty strings there.
|
||||
"""
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
subentry,
|
||||
data={
|
||||
**subentry.data,
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
CONF_WEB_SEARCH_COUNTRY: "DE",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "Europe/Berlin",
|
||||
},
|
||||
)
|
||||
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
||||
mock_create_stream.return_value = [
|
||||
create_message_item(id="msg_A", text="Hi", output_index=0)
|
||||
]
|
||||
|
||||
await conversation.async_converse(
|
||||
hass, "hello", None, Context(), agent_id="conversation.openai_conversation"
|
||||
)
|
||||
|
||||
tools = mock_create_stream.mock_calls[0][2]["tools"]
|
||||
web_search = next(tool for tool in tools if tool["type"] == "web_search")
|
||||
assert web_search["user_location"] == {
|
||||
"type": "approximate",
|
||||
"country": "DE",
|
||||
"timezone": "Europe/Berlin",
|
||||
}
|
||||
|
||||
|
||||
def test_local_intent_replayed_as_note() -> None:
|
||||
"""Test a request a local intent answered is described, not replayed as a call.
|
||||
|
||||
With prefer_local_intents, the chat log holds intents like
|
||||
HassGetCurrentTime as external tool calls. They are not the model's tools,
|
||||
and replayed as its own calls they make it apologise for using them.
|
||||
"""
|
||||
content = [
|
||||
conversation.UserContent(content="What time is it?"),
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.test",
|
||||
tool_calls=[
|
||||
llm.ToolInput(
|
||||
id="intent_1",
|
||||
tool_name="HassGetCurrentTime",
|
||||
tool_args={},
|
||||
external=True,
|
||||
)
|
||||
],
|
||||
),
|
||||
conversation.ToolResultContent(
|
||||
agent_id="conversation.test",
|
||||
tool_call_id="intent_1",
|
||||
tool_name="HassGetCurrentTime",
|
||||
result=llm.ToolResult(data={"speech": {"plain": {"speech": "10:29"}}}),
|
||||
),
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.test", content="It is 10:29"
|
||||
),
|
||||
conversation.UserContent(content="What is your name?"),
|
||||
]
|
||||
|
||||
messages = _convert_content_to_param(content)
|
||||
|
||||
assert [message["type"] for message in messages] == ["message"] * 4
|
||||
assert [message["role"] for message in messages] == [
|
||||
"user",
|
||||
"developer",
|
||||
"assistant",
|
||||
"user",
|
||||
]
|
||||
note = messages[1]["content"]
|
||||
assert "HassGetCurrentTime" in note
|
||||
assert "10:29" in note
|
||||
assert "not one of your tools" in note
|
||||
@@ -73,6 +73,20 @@ async def test_fetch_survives_an_endpoint_that_will_not_list(error: Exception) -
|
||||
assert await async_fetch_model_ids(_client(error=error)) == []
|
||||
|
||||
|
||||
async def test_fetch_survives_a_non_openai_error() -> None:
|
||||
"""Discovery must never break the form it runs inside.
|
||||
|
||||
A proxy can return a payload the SDK cannot parse, or a client can be
|
||||
wired in a way that raises something other than an OpenAIError; either way
|
||||
the dropdown should degrade to a text box, not take the dialog down.
|
||||
"""
|
||||
client = MagicMock()
|
||||
client.with_options.return_value = client
|
||||
client.models.list = MagicMock(return_value="not awaitable")
|
||||
|
||||
assert await async_fetch_model_ids(client) == []
|
||||
|
||||
|
||||
async def test_field_is_a_dropdown_that_still_accepts_a_typed_name() -> None:
|
||||
"""A proxy may serve a model it does not advertise, so allow free text."""
|
||||
field = model_field(["claude-sonnet", "gpt-4o-mini"])
|
||||
|
||||
@@ -47,8 +47,8 @@ async def _model_step(
|
||||
) -> dict:
|
||||
"""Return the form that asks for the model, for any endpoint type.
|
||||
|
||||
stt and tts are single-step flows that ask on init; conversation and
|
||||
ai_task_data ask on the step after it.
|
||||
All four types ask on the first screen, so that picking a model never
|
||||
depends on noticing the "recommended" checkbox first.
|
||||
"""
|
||||
with patch(DISCOVERY, return_value=models):
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
@@ -57,16 +57,6 @@ async def _model_step(
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
if subentry_type in ("stt", "tts"):
|
||||
return result
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_NAME: "An endpoint", CONF_RECOMMENDED: False},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "additional"
|
||||
return result
|
||||
|
||||
|
||||
@@ -133,17 +123,14 @@ async def test_a_non_openai_model_name_survives_the_flow(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
# The model is chosen on the very first screen, alongside the name.
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_NAME: "Sonnet via LiteLLM", CONF_RECOMMENDED: False},
|
||||
)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {CONF_CHAT_MODEL: "claude-sonnet-4-6"}
|
||||
)
|
||||
# remaining steps take their defaults
|
||||
while result["type"] is FlowResultType.FORM:
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
{
|
||||
CONF_NAME: "Sonnet via LiteLLM",
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-6",
|
||||
CONF_RECOMMENDED: True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
Reference in new issue
Block a user