diff --git a/custom_components/openai_compatible/__init__.py b/custom_components/openai_compatible/__init__.py index 2ff1088..cb67c46 100644 --- a/custom_components/openai_compatible/__init__.py +++ b/custom_components/openai_compatible/__init__.py @@ -39,6 +39,7 @@ from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( + CONF_BASE_URL, CONF_CHAT_MODEL, CONF_FILENAMES, CONF_MAX_TOKENS, @@ -282,6 +283,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo """Set up OpenAI Conversation from a config entry.""" client = openai.AsyncOpenAI( api_key=entry.data[CONF_API_KEY], + base_url=entry.data[CONF_BASE_URL], http_client=get_async_client(hass), ) diff --git a/custom_components/openai_compatible/config_flow.py b/custom_components/openai_compatible/config_flow.py index be0c913..5df3fcc 100644 --- a/custom_components/openai_compatible/config_flow.py +++ b/custom_components/openai_compatible/config_flow.py @@ -44,6 +44,7 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.typing import VolDictType from .const import ( + CONF_BASE_URL, CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, @@ -67,6 +68,7 @@ from .const import ( CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, DEFAULT_AI_TASK_NAME, + DEFAULT_BASE_URL, DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, DEFAULT_STT_PROMPT, @@ -106,6 +108,7 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { + vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, vol.Required(CONF_API_KEY): str, } ) @@ -117,7 +120,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ client = openai.AsyncOpenAI( - api_key=data[CONF_API_KEY], http_client=get_async_client(hass) + api_key=data[CONF_API_KEY], + base_url=data[CONF_BASE_URL], + http_client=get_async_client(hass), ) await client.models.list(timeout=10.0) @@ -646,6 +651,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): if zone_home is not None: client = openai.AsyncOpenAI( api_key=self._get_entry().data[CONF_API_KEY], + base_url=self._get_entry().data[CONF_BASE_URL], http_client=get_async_client(self.hass), ) location_schema = vol.Schema( diff --git a/custom_components/openai_compatible/const.py b/custom_components/openai_compatible/const.py index 2ed2fec..000d88c 100644 --- a/custom_components/openai_compatible/const.py +++ b/custom_components/openai_compatible/const.py @@ -9,6 +9,9 @@ from homeassistant.helpers import llm DOMAIN = "openai_compatible" LOGGER: logging.Logger = logging.getLogger(__package__) +CONF_BASE_URL = "base_url" +DEFAULT_BASE_URL = "https://api.openai.com/v1" + DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_STT_NAME = "OpenAI STT" diff --git a/custom_components/openai_compatible/strings.json b/custom_components/openai_compatible/strings.json index b538ecd..ab30ee9 100644 --- a/custom_components/openai_compatible/strings.json +++ b/custom_components/openai_compatible/strings.json @@ -21,10 +21,12 @@ }, "user": { "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "base_url": "API base URL" }, "data_description": { - "api_key": "Your OpenAI API key." + "api_key": "Your OpenAI API key.", + "base_url": "The OpenAI-compatible endpoint, e.g. http://192.0.2.10:4000/v1 for LiteLLM. Must support the Responses API." }, "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})." } diff --git a/tests/conftest.py b/tests/conftest.py index 7b045e4..05b6b65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,8 +22,10 @@ from openai.types.responses.response import IncompleteDetails import pytest from custom_components.openai_compatible.const import ( + CONF_BASE_URL, CONF_CHAT_MODEL, DEFAULT_AI_TASK_NAME, + DEFAULT_BASE_URL, DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, DEFAULT_TTS_NAME, @@ -65,6 +67,7 @@ def mock_config_entry( domain=DOMAIN, data={ "api_key": "bla", + CONF_BASE_URL: DEFAULT_BASE_URL, }, version=2, minor_version=7, diff --git a/tests/test_base_url.py b/tests/test_base_url.py new file mode 100644 index 0000000..420b9e1 --- /dev/null +++ b/tests/test_base_url.py @@ -0,0 +1,27 @@ +"""base_url must reach the OpenAI client, not just the config entry.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.openai_compatible.const import CONF_BASE_URL, DOMAIN + + +async def test_client_built_with_base_url(hass: HomeAssistant) -> None: + """async_setup_entry must pass base_url to AsyncOpenAI.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "sk-test", CONF_BASE_URL: "http://192.0.2.10:4000/v1"}, + ) + 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.with_options.return_value.models.list = AsyncMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_client.call_args.kwargs["base_url"] == "http://192.0.2.10:4000/v1" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 19f5d47..31965ca 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -12,6 +12,7 @@ from custom_components.openai_compatible.config_flow import ( RECOMMENDED_CONVERSATION_OPTIONS, ) from custom_components.openai_compatible.const import ( + CONF_BASE_URL, CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, @@ -35,6 +36,7 @@ from custom_components.openai_compatible.const import ( CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, DEFAULT_AI_TASK_NAME, + DEFAULT_BASE_URL, DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, DEFAULT_TTS_NAME, @@ -90,6 +92,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["data"] == { "api_key": "bla", + "base_url": DEFAULT_BASE_URL, } assert result2["options"] == {} assert result2["subentries"] == [ @@ -127,7 +130,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: """Test we abort on duplicate config entry.""" MockConfigEntry( domain=DOMAIN, - data={CONF_API_KEY: "bla"}, + data={CONF_API_KEY: "bla", CONF_BASE_URL: DEFAULT_BASE_URL}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init(