Skip to content

Commit a57d48f

Browse files
authored
Add OpenRouter integration (home-assistant#143098)
1 parent 8a73511 commit a57d48f

File tree

19 files changed

+836
-0
lines changed

19 files changed

+836
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ homeassistant.components.onedrive.*
377377
homeassistant.components.onewire.*
378378
homeassistant.components.onkyo.*
379379
homeassistant.components.open_meteo.*
380+
homeassistant.components.open_router.*
380381
homeassistant.components.openai_conversation.*
381382
homeassistant.components.openexchangerates.*
382383
homeassistant.components.opensky.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""The OpenRouter integration."""
2+
3+
from __future__ import annotations
4+
5+
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
6+
7+
from homeassistant.config_entries import ConfigEntry
8+
from homeassistant.const import CONF_API_KEY, Platform
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
11+
from homeassistant.helpers.httpx_client import get_async_client
12+
13+
from .const import LOGGER
14+
15+
PLATFORMS = [Platform.CONVERSATION]
16+
17+
type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI]
18+
19+
20+
async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
21+
"""Set up OpenRouter from a config entry."""
22+
client = AsyncOpenAI(
23+
base_url="https://openrouter.ai/api/v1",
24+
api_key=entry.data[CONF_API_KEY],
25+
http_client=get_async_client(hass),
26+
)
27+
28+
# Cache current platform data which gets added to each request (caching done by library)
29+
_ = await hass.async_add_executor_job(client.platform_headers)
30+
31+
try:
32+
async for _ in client.with_options(timeout=10.0).models.list():
33+
break
34+
except AuthenticationError as err:
35+
LOGGER.error("Invalid API key: %s", err)
36+
raise ConfigEntryError("Invalid API key") from err
37+
except OpenAIError as err:
38+
raise ConfigEntryNotReady(err) from err
39+
40+
entry.runtime_data = client
41+
42+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
43+
44+
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
45+
46+
return True
47+
48+
49+
async def _async_update_listener(
50+
hass: HomeAssistant, entry: OpenRouterConfigEntry
51+
) -> None:
52+
"""Handle update."""
53+
await hass.config_entries.async_reload(entry.entry_id)
54+
55+
56+
async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
57+
"""Unload OpenRouter."""
58+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Config flow for OpenRouter integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from openai import AsyncOpenAI
9+
from python_open_router import OpenRouterClient, OpenRouterError
10+
import voluptuous as vol
11+
12+
from homeassistant.config_entries import (
13+
ConfigEntry,
14+
ConfigFlow,
15+
ConfigFlowResult,
16+
ConfigSubentryFlow,
17+
SubentryFlowResult,
18+
)
19+
from homeassistant.const import CONF_API_KEY, CONF_MODEL
20+
from homeassistant.core import callback
21+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
22+
from homeassistant.helpers.httpx_client import get_async_client
23+
from homeassistant.helpers.selector import (
24+
SelectOptionDict,
25+
SelectSelector,
26+
SelectSelectorConfig,
27+
SelectSelectorMode,
28+
)
29+
30+
from .const import DOMAIN
31+
32+
_LOGGER = logging.getLogger(__name__)
33+
34+
35+
class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
36+
"""Handle a config flow for OpenRouter."""
37+
38+
VERSION = 1
39+
40+
@classmethod
41+
@callback
42+
def async_get_supported_subentry_types(
43+
cls, config_entry: ConfigEntry
44+
) -> dict[str, type[ConfigSubentryFlow]]:
45+
"""Return subentries supported by this handler."""
46+
return {"conversation": ConversationFlowHandler}
47+
48+
async def async_step_user(
49+
self, user_input: dict[str, Any] | None = None
50+
) -> ConfigFlowResult:
51+
"""Handle the initial step."""
52+
errors = {}
53+
if user_input is not None:
54+
self._async_abort_entries_match(user_input)
55+
client = OpenRouterClient(
56+
user_input[CONF_API_KEY], async_get_clientsession(self.hass)
57+
)
58+
try:
59+
await client.get_key_data()
60+
except OpenRouterError:
61+
errors["base"] = "cannot_connect"
62+
except Exception:
63+
_LOGGER.exception("Unexpected exception")
64+
errors["base"] = "unknown"
65+
else:
66+
return self.async_create_entry(
67+
title="OpenRouter",
68+
data=user_input,
69+
)
70+
return self.async_show_form(
71+
step_id="user",
72+
data_schema=vol.Schema(
73+
{
74+
vol.Required(CONF_API_KEY): str,
75+
}
76+
),
77+
errors=errors,
78+
)
79+
80+
81+
class ConversationFlowHandler(ConfigSubentryFlow):
82+
"""Handle subentry flow."""
83+
84+
def __init__(self) -> None:
85+
"""Initialize the subentry flow."""
86+
self.options: dict[str, str] = {}
87+
88+
async def async_step_user(
89+
self, user_input: dict[str, Any] | None = None
90+
) -> SubentryFlowResult:
91+
"""User flow to create a sensor subentry."""
92+
if user_input is not None:
93+
return self.async_create_entry(
94+
title=self.options[user_input[CONF_MODEL]], data=user_input
95+
)
96+
entry = self._get_entry()
97+
client = AsyncOpenAI(
98+
base_url="https://openrouter.ai/api/v1",
99+
api_key=entry.data[CONF_API_KEY],
100+
http_client=get_async_client(self.hass),
101+
)
102+
options = []
103+
async for model in client.with_options(timeout=10.0).models.list():
104+
options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined]
105+
self.options[model.id] = model.name # type: ignore[attr-defined]
106+
107+
return self.async_show_form(
108+
step_id="user",
109+
data_schema=vol.Schema(
110+
{
111+
vol.Required(CONF_MODEL): SelectSelector(
112+
SelectSelectorConfig(
113+
options=options, mode=SelectSelectorMode.DROPDOWN, sort=True
114+
),
115+
),
116+
}
117+
),
118+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Constants for the OpenRouter integration."""
2+
3+
import logging
4+
5+
DOMAIN = "open_router"
6+
LOGGER = logging.getLogger(__package__)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Conversation support for OpenRouter."""
2+
3+
from typing import Literal
4+
5+
import openai
6+
from openai.types.chat import (
7+
ChatCompletionAssistantMessageParam,
8+
ChatCompletionMessageParam,
9+
ChatCompletionSystemMessageParam,
10+
ChatCompletionUserMessageParam,
11+
)
12+
13+
from homeassistant.components import conversation
14+
from homeassistant.config_entries import ConfigSubentry
15+
from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL
16+
from homeassistant.core import HomeAssistant
17+
from homeassistant.exceptions import HomeAssistantError
18+
from homeassistant.helpers import intent
19+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
20+
21+
from . import OpenRouterConfigEntry
22+
from .const import DOMAIN, LOGGER
23+
24+
25+
async def async_setup_entry(
26+
hass: HomeAssistant,
27+
config_entry: OpenRouterConfigEntry,
28+
async_add_entities: AddConfigEntryEntitiesCallback,
29+
) -> None:
30+
"""Set up conversation entities."""
31+
for subentry_id, subentry in config_entry.subentries.items():
32+
async_add_entities(
33+
[OpenRouterConversationEntity(config_entry, subentry)],
34+
config_subentry_id=subentry_id,
35+
)
36+
37+
38+
def _convert_content_to_chat_message(
39+
content: conversation.Content,
40+
) -> ChatCompletionMessageParam | None:
41+
"""Convert any native chat message for this agent to the native format."""
42+
LOGGER.debug("_convert_content_to_chat_message=%s", content)
43+
if isinstance(content, conversation.ToolResultContent):
44+
return None
45+
46+
role: Literal["user", "assistant", "system"] = content.role
47+
if role == "system" and content.content:
48+
return ChatCompletionSystemMessageParam(role="system", content=content.content)
49+
50+
if role == "user" and content.content:
51+
return ChatCompletionUserMessageParam(role="user", content=content.content)
52+
53+
if role == "assistant":
54+
return ChatCompletionAssistantMessageParam(
55+
role="assistant", content=content.content
56+
)
57+
LOGGER.warning("Could not convert message to Completions API: %s", content)
58+
return None
59+
60+
61+
class OpenRouterConversationEntity(conversation.ConversationEntity):
62+
"""OpenRouter conversation agent."""
63+
64+
def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None:
65+
"""Initialize the agent."""
66+
self.entry = entry
67+
self.subentry = subentry
68+
self.model = subentry.data[CONF_MODEL]
69+
self._attr_name = subentry.title
70+
self._attr_unique_id = subentry.subentry_id
71+
72+
@property
73+
def supported_languages(self) -> list[str] | Literal["*"]:
74+
"""Return a list of supported languages."""
75+
return MATCH_ALL
76+
77+
async def _async_handle_message(
78+
self,
79+
user_input: conversation.ConversationInput,
80+
chat_log: conversation.ChatLog,
81+
) -> conversation.ConversationResult:
82+
"""Process a sentence."""
83+
options = self.subentry.data
84+
85+
try:
86+
await chat_log.async_provide_llm_data(
87+
user_input.as_llm_context(DOMAIN),
88+
options.get(CONF_LLM_HASS_API),
89+
None,
90+
user_input.extra_system_prompt,
91+
)
92+
except conversation.ConverseError as err:
93+
return err.as_conversation_result()
94+
95+
messages = [
96+
m
97+
for content in chat_log.content
98+
if (m := _convert_content_to_chat_message(content))
99+
]
100+
101+
client = self.entry.runtime_data
102+
103+
try:
104+
result = await client.chat.completions.create(
105+
model=self.model,
106+
messages=messages,
107+
user=chat_log.conversation_id,
108+
extra_headers={
109+
"X-Title": "Home Assistant",
110+
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
111+
},
112+
)
113+
except openai.OpenAIError as err:
114+
LOGGER.error("Error talking to API: %s", err)
115+
raise HomeAssistantError("Error talking to API") from err
116+
117+
result_message = result.choices[0].message
118+
119+
chat_log.async_add_assistant_content_without_tools(
120+
conversation.AssistantContent(
121+
agent_id=user_input.agent_id,
122+
content=result_message.content,
123+
)
124+
)
125+
126+
intent_response = intent.IntentResponse(language=user_input.language)
127+
assert type(chat_log.content[-1]) is conversation.AssistantContent
128+
intent_response.async_set_speech(chat_log.content[-1].content or "")
129+
return conversation.ConversationResult(
130+
response=intent_response,
131+
conversation_id=chat_log.conversation_id,
132+
continue_conversation=chat_log.continue_conversation,
133+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"domain": "open_router",
3+
"name": "OpenRouter",
4+
"after_dependencies": ["assist_pipeline", "intent"],
5+
"codeowners": ["@joostlek"],
6+
"config_flow": true,
7+
"dependencies": ["conversation"],
8+
"documentation": "https://www.home-assistant.io/integrations/open_router",
9+
"integration_type": "service",
10+
"iot_class": "cloud_polling",
11+
"quality_scale": "bronze",
12+
"requirements": ["openai==1.93.3", "python-open-router==0.2.0"]
13+
}

0 commit comments

Comments
 (0)