Compare commits

...
13 Commits
Author SHA1 Message Date
pc-heiniandClaude Opus 5.5 6aed494e44 fix: describe local intent answers to the model instead of replaying them
validate / hassfest (push) Failing after 1m2s
validate / hacs (push) Failing after 1m11s
test / pytest (push) Successful in 1m36s
With prefer_local_intents, Home Assistant puts requests its local intents
answered into the chat log as external calls of intents like
HassGetCurrentTime. The model does not have these tools, and replayed as
its own calls they make it apologise for using tools it does not have and
doubt answers it never gave. They are now passed as a developer note with
the intent, its arguments and its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:35:26 +02:00
pc-heiniandClaude Opus 5.5 e54f0f0fa1 docs: list the location lookup fixes in the fork note
validate / hassfest (push) Failing after 10s
validate / hacs (push) Failing after 18s
test / pytest (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:52:47 +02:00
pc-heiniandClaude Opus 5.5 84f4bcda55 fix: make the home location lookup work against Claude
validate / hassfest (push) Failing after 11s
validate / hacs (push) Failing after 23s
test / pytest (push) Successful in 46s
- Send the location question as a user message. A request with only a
  system message has no user content, which endpoints in front of Anthropic
  models reject ("text content blocks must be non-empty").
- Leave unknown fields out of the web search user_location. After a failed
  lookup there is no city or region, and the empty strings made every
  request fail ("user_location.city: String should have at least 1
  character").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:52:14 +02:00
pc-heiniandClaude Opus 5.5 235c536cab fix: make the integration work against a Claude proxy
validate / hassfest (push) Failing after 32s
validate / hacs (push) Failing after 1m50s
test / pytest (push) Successful in 3m56s
- Treat an empty tool argument string as no parameters instead of raising
  JSONDecodeError; the proxy sends "" for tools like the timer status
  (upstream issue #3).
- Do not replay earlier web searches in the history. The chat log keeps only
  their status, and a search without results makes the model disown the
  answers it based on it and search again (upstream issue #4).
- Look up the home location for web search with the subentry's model instead
  of gpt-4o-mini, and save the options without city/region if the lookup
  fails (upstream issue #5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:38:01 +02:00
Adam SmithandClaude Opus 5.5 8737e5d4ec docs: production installs via HACS, not a hassio copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUd68TsT5QZ68Dtfw2uWBU
2026-10-11 07:15:53 -04:00
Adam Smith 43d04305f4 chore: release v0.2.1 2026-10-11 07:08:18 -04:00
Adam SmithandClaude Opus 5.5 a6faa44b2f fix: actually check the endpoint during setup
async_setup_entry handed client.with_options(timeout=10.0).models.list to the
executor. On AsyncOpenAI that returns an awaitable paginator, so it came back
unawaited, no request was made, and a bad key or an unreachable endpoint still
set up. It now awaits client.models.list(timeout=10.0), matching core's
openai_conversation, so a 401 raises ConfigEntryAuthFailed and a connection
error ConfigEntryNotReady, as the README already says.

The shared mock_init_component fixture now patches models.list with an
AsyncMock and keeps the patch for the whole test, because tests that change a
subentry reload the entry and run setup again.

Reported by @MartinNuc in #1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUd68TsT5QZ68Dtfw2uWBU
2026-10-11 06:45:52 -04:00
Martin NucandClaude Opus 5.5 151d741a71 Accept any openai>=2.45.0 so the integration loads on HA 2026.10
Home Assistant 2026.10 pins openai==3.10.0 in its package constraints, so the
manifest's exact openai==2.45.0 can no longer be installed and setup fails:

  Setup failed for custom integration 'openai_compatible': Requirements for
  openai_compatible not found: ['openai==2.45.0'].

The requirement is now a floor. Each Home Assistant release then installs the
openai it pins: 2.45.0 on 2026.9, 3.10.0 on 2026.10.

The vendored code needs no change for 3.x. The only breaking change in openai
3.0 is HTTPX2 as the default client, and passing Home Assistant's legacy httpx
client is still supported at runtime, which is how core's own integration
handles it. Every openai import in the integration resolves on 3.10.0, and the
suite passes against it on the 2026.10.0 test harness (170 passed).
requirements_test.txt now pins 3.10.0 so CI tests what current Home Assistant
ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123XQr8dh5RnFLnECHPVNrd
2026-10-11 06:29:20 -04:00
Adam SmithandClaude Sonnet 5.5 4dfb607780 docs: add AGENTS.md and CLAUDE.md stub
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QD6ZdZLjzfTh6kVZpg1xJ
2026-09-30 16:19:51 -04:00
Adam SmithandClaude Opus 5 b79164b60c docs: record the provider-genericity audit
Writes up what is still OpenAI-specific in this fork, since the base URL
being configurable is what makes it reach a compatible endpoint, not what
makes it behave correctly against one. Four findings, each with what it
actually costs:

1. service_tier, store and prompt_cache_retention are sent on every
   request whatever the provider is (entity.py:515-563); reasoning,
   include and text.verbosity are gated on OpenAI name prefixes.
2. 23 startswith() model-name gates plus the UNSUPPORTED_* lists infer
   capability from OpenAI naming, which fails in both directions: service
   tier, code interpreter and web search are offered to providers that
   cannot honour them, while reasoning effort/summary and verbosity are
   withheld from any model whose name does not look like OpenAI's.
3. manufacturer="OpenAI" and the "Error talking to OpenAI" error text.
4. Repair issues citing core's 2026.9.0 removal schedule, which has
   passed and was never this fork's commitment, and an OpenAI-only
   organization-verification repair.

Two things worth having written down beyond the list itself:

- A proxy set to drop incompatible parameters hides finding 1 entirely
  and makes finding 2 worse, because a dropped parameter turns a
  misconfigured option into a silent no-op rather than an error. Testing
  only against such a proxy therefore says nothing about portability, and
  a strict server is the honest target. This reorders the priorities:
  finding 2 first, since it is what the user sees and the failure is
  silent; finding 1 matters for setups without a normalising proxy.
- The prerequisite decision is recorded rather than assumed. There is no
  capability discovery in the OpenAI API -- GET /v1/models returns ids,
  not feature support -- so the three candidates (per-entry capability
  toggle, host detection, per-endpoint checkboxes) are laid out with
  their trade-offs and explicitly marked unsettled.

No behaviour change; the README links it from a short "known
OpenAI-specific behaviour" section so it is findable from the front page.

Local: 170 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxJYWgGJtCw13qUHZtG8ki
2026-09-17 23:32:21 -04:00
Adam Smith 4611674c6e chore: release v0.2.0 2026-09-17 23:18:07 -04:00
Adam SmithandClaude Opus 5 6a599e092a feat: ask for the model on the first screen of the endpoint flow
The model field was one screen too late. Reported directly: "this is where
I would have expected to see the model dropdown", on the AI Task init step
that showed only Name and the recommended checkbox.

- CONF_CHAT_MODEL moves from async_step_additional to async_step_init for
  conversation and ai_task_data, so all four endpoint types now ask on the
  first screen. stt and tts already did.
- UNSUPPORTED_MODELS is validated there too, since the recommended path
  creates the subentry from that step and would otherwise skip the check.
- model_list.default_model() picks what to preselect: RECOMMENDED_CHAT_MODEL
  only when the provider actually serves it (or discovery returned nothing),
  otherwise the first advertised model. Offering a stranger's gpt-4o-mini to
  a LiteLLM user preselects a model that fails on first use.

This is the trade I got wrong last commit. I kept the field on the second
step to avoid touching 71 ported tests, having judged them unfixable
mechanically. They were not: the 71 were ~13 functions under parametrize,
and test_subentry_switching's data is already a list of per-step dicts, so
moving a model between steps is moving a key between two dicts. Two scripted
passes moved 14 of them; 5 needed reasoning about (an error that now surfaces
on init rather than the next step, and four cases whose expected data gains
the now-always-recorded model).

async_fetch_model_ids now also catches non-OpenAIError exceptions. Found via
a ported test whose client is a MagicMock: the TypeError from awaiting it
escaped and broke the whole form. A real proxy returning a payload the SDK
cannot parse would do the same, and this runs inside a dialog with a working
free-text fallback, so nothing it raises justifies taking the form down.

Also fixes a string that was simply false on a third-party provider, again
reported from the UI: "Store requests and responses in OpenAI" / "stored by
OpenAI and visible in your OpenAI dashboard logs". It now describes the
Responses API store flag and says support varies. Swept the other 43
OpenAI-mentioning strings while there -- the rest are [%key:...%]
self-references or this fork's own action names, and no other option text
makes a claim about who stores the data.

Local: 170 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxJYWgGJtCw13qUHZtG8ki
2026-09-17 23:18:02 -04:00
Adam Smith 030822c845 chore: release v0.1.0 2026-09-17 23:08:57 -04:00
19 changed files with 750 additions and 178 deletions

No files matched your search

+49
View File
@@ -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?
+5
View File
@@ -0,0 +1,5 @@
# Claude Code
Repository instructions for all agents live in [AGENTS.md](AGENTS.md). Edit them there.
@AGENTS.md
+36
View File
@@ -4,6 +4,28 @@
[![validate](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml/badge.svg)](https://github.com/adamjs83/ha-openai-compatible/actions/workflows/validate.yaml)
[![HACS: custom](https://img.shields.io/badge/HACS-custom-41BDF5.svg)](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,36 +325,58 @@ 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)
if user_input[CONF_RECOMMENDED]:
if self._is_new:
return self.async_create_entry(
title=user_input.pop(CONF_NAME),
# 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(
title=user_input.pop(CONF_NAME),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
options.update(user_input)
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
options.pop(CONF_LLM_HASS_API)
return await self.async_step_additional()
options.update(user_input)
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
options.pop(CONF_LLM_HASS_API)
return await self.async_step_additional()
return self.async_show_form(
step_id="init",
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,31 +702,41 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
): str,
}
)
response = await client.responses.create(
model=RECOMMENDED_CHAT_MODEL,
input=[
{
"role": "system",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
}
],
text={
"format": {
"type": "json_schema",
"name": "approximate_location",
"description": "Approximate location data of the user "
"for refined web search results",
"schema": to_openapi(location_schema),
"strict": False,
}
},
store=self.options.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
)
location_data = location_schema(json.loads(response.output_text) or {})
# 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=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": "user",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[EntityStateAttribute.LATITUDE]},"
f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?",
}
],
text={
"format": {
"type": "json_schema",
"name": "approximate_location",
"description": "Approximate location data of the user "
"for refined web search results",
"schema": to_openapi(location_schema),
"strict": False,
}
},
store=self.options.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
)
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
+51 -36
View File
@@ -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,26 +231,19 @@ 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:
messages.append(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json_dumps(tool_call.tool_args),
call_id=tool_call.id,
)
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",
name=tool_call.tool_name,
arguments=json_dumps(tool_call.tool_args),
call_id=tool_call.id,
)
)
if content.thinking_content:
reasoning_summary.append(content.thinking_content)
@@ -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"
},
+113
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-13
View File
@@ -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",
(
+184
View File
@@ -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
+14
View File
@@ -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"])
+11 -24
View File
@@ -47,27 +47,17 @@ 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(
(entry.entry_id, subentry_type),
context={"source": config_entries.SOURCE_USER},
)
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
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
return result
@pytest.mark.parametrize(
@@ -133,18 +123,15 @@ 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},
{
CONF_NAME: "Sonnet via LiteLLM",
CONF_CHAT_MODEL: "claude-sonnet-4-6",
CONF_RECOMMENDED: True,
},
)
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"], {}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY