Skip to content

Commit 6464568

Browse files
committed
feat: add configurable Anthropic-compatible LLM support
1 parent c403971 commit 6464568

8 files changed

Lines changed: 201 additions & 14 deletions

File tree

custom_components/ai_hub/config_flow.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
AI_HUB_IMAGE_MODELS,
4040
CONF_CHAT_MODEL,
4141
CONF_CHAT_URL,
42+
CONF_LLM_PROVIDER,
4243
CONF_CUSTOM_API_KEY,
4344
CONF_FORCE_TRANSLATION,
4445
CONF_IMAGE_MODEL,
@@ -69,6 +70,7 @@
6970
RECOMMENDED_AI_TASK_OPTIONS,
7071
RECOMMENDED_AI_TASK_TEMPERATURE,
7172
RECOMMENDED_CHAT_MODEL,
73+
RECOMMENDED_LLM_PROVIDER,
7274
RECOMMENDED_CONVERSATION_OPTIONS,
7375
RECOMMENDED_IMAGE_MODEL,
7476
RECOMMENDED_MAX_HISTORY_MESSAGES,
@@ -379,6 +381,16 @@ async def ai_hub_config_option_schema(
379381
default=options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
380382
description={"suggested_value": options.get(CONF_PROMPT)},
381383
): TemplateSelector(),
384+
vol.Optional(
385+
CONF_LLM_PROVIDER,
386+
default=options.get(CONF_LLM_PROVIDER, RECOMMENDED_LLM_PROVIDER),
387+
description={"suggested_value": options.get(CONF_LLM_PROVIDER)},
388+
): SelectSelector(
389+
SelectSelectorConfig(
390+
options=["openai_compatible", "anthropic_compatible"],
391+
mode=SelectSelectorMode.DROPDOWN,
392+
)
393+
),
382394
vol.Optional(
383395
CONF_CHAT_MODEL,
384396
default=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),

custom_components/ai_hub/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ def get_localized_name(hass: HomeAssistant, zh_name: str, en_name: str) -> str:
156156
# Model Configuration
157157
CONF_CHAT_MODEL: Final = "chat_model"
158158
CONF_CHAT_URL: Final = "chat_url"
159+
CONF_LLM_PROVIDER: Final = "llm_provider"
159160
CONF_IMAGE_MODEL: Final = "image_model"
160161
CONF_IMAGE_URL: Final = "image_url"
161162
CONF_STT_MODEL: Final = "model"
@@ -211,6 +212,7 @@ def get_localized_name(hass: HomeAssistant, zh_name: str, en_name: str) -> str:
211212
}
212213

213214
RECOMMENDED_CHAT_MODEL: Final = RECOMMENDED["chat_model"]
215+
RECOMMENDED_LLM_PROVIDER: Final = "openai_compatible"
214216
RECOMMENDED_TEMPERATURE: Final = RECOMMENDED["temperature"]
215217
RECOMMENDED_TOP_P: Final = RECOMMENDED["top_p"]
216218
RECOMMENDED_TOP_K: Final = RECOMMENDED["top_k"]
@@ -393,6 +395,7 @@ def get_localized_name(hass: HomeAssistant, zh_name: str, en_name: str) -> str:
393395
CONF_RECOMMENDED: True,
394396
CONF_LLM_HASS_API: LLM_API_ASSIST,
395397
CONF_PROMPT: DEFAULT_INSTRUCTIONS_PROMPT,
398+
CONF_LLM_PROVIDER: RECOMMENDED_LLM_PROVIDER,
396399
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
397400
CONF_CHAT_URL: AI_HUB_CHAT_URL,
398401
CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE,

custom_components/ai_hub/entity.py

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
from homeassistant.util import ulid
2020
from voluptuous_openapi import convert
2121

22+
from .providers import LLMMessage, create_provider
2223
from .const import (
2324
AI_HUB_CHAT_URL,
2425
CONF_CHAT_MODEL,
2526
CONF_CHAT_URL,
27+
CONF_LLM_PROVIDER,
2628
CONF_CUSTOM_API_KEY,
2729
CONF_MAX_HISTORY_MESSAGES,
2830
CONF_MAX_TOKENS,
@@ -76,6 +78,17 @@ def _get_request_ssl_setting(api_url: str, default_url: str) -> bool | None:
7678
return None
7779

7880

81+
def _get_provider_name(api_url: str, configured_provider: str | None = None) -> str:
82+
"""Select provider implementation from URL."""
83+
if configured_provider in {"openai_compatible", "anthropic_compatible"}:
84+
return configured_provider
85+
86+
parsed = urlparse(api_url)
87+
if "anthropic" in parsed.path.lower() or parsed.netloc == "api.anthropic.com":
88+
return "anthropic_compatible"
89+
return "openai_compatible"
90+
91+
7992
class _AIHubEntityMixin:
8093
"""Mixin class providing common initialization logic for AI Hub entities.
8194
@@ -283,15 +296,6 @@ async def _async_handle_chat_log(
283296
model_name = self.default_model
284297
_LOGGER.warning("Model name was invalid, using default: %s", model_name)
285298

286-
request_params = {
287-
"model": model_name,
288-
"messages": messages,
289-
"stream": True,
290-
}
291-
292-
if tools:
293-
request_params["tools"] = tools
294-
295299
# Validate all message contents before sending
296300
for i, msg in enumerate(messages):
297301
msg_content = msg.get("content")
@@ -311,6 +315,17 @@ async def _async_handle_chat_log(
311315
api_url = AI_HUB_CHAT_URL
312316
_LOGGER.warning("API URL was invalid, using default: %s", api_url)
313317

318+
provider_name = _get_provider_name(api_url, options.get(CONF_LLM_PROVIDER))
319+
320+
request_params = {
321+
"model": model_name,
322+
"messages": messages,
323+
"stream": True,
324+
}
325+
326+
if tools:
327+
request_params["tools"] = tools
328+
314329
try:
315330
# Validate API key before making request
316331
if not self._api_key:
@@ -322,11 +337,46 @@ async def _async_handle_chat_log(
322337
self._api_key = str(self._api_key)
323338

324339
_LOGGER.debug(
325-
"API Request: model=%s, messages_count=%d",
340+
"API Request: provider=%s, model=%s, messages_count=%d",
341+
provider_name,
326342
model_name,
327343
len(messages)
328344
)
329345

346+
llm_messages = [
347+
LLMMessage(
348+
role=msg["role"],
349+
content=msg.get("content", ""),
350+
tool_calls=msg.get("tool_calls"),
351+
tool_call_id=msg.get("tool_call_id"),
352+
)
353+
for msg in messages
354+
]
355+
356+
provider = create_provider(
357+
provider_name,
358+
{
359+
"api_key": self._api_key,
360+
"model": model_name,
361+
"base_url": api_url,
362+
"temperature": model_config.get("temperature", RECOMMENDED_TEMPERATURE),
363+
"max_tokens": model_config.get("max_tokens", RECOMMENDED_MAX_TOKENS),
364+
},
365+
)
366+
367+
if provider_name == "anthropic_compatible" and provider is not None:
368+
response = await provider.complete(llm_messages, tools=tools)
369+
tool_calls = self._convert_provider_tool_calls(response.tool_calls)
370+
assistant_content = conversation.AssistantContent(
371+
agent_id=self.entity_id,
372+
content=response.content or None,
373+
tool_calls=tool_calls or None,
374+
native=response.raw_response,
375+
)
376+
async for _ in chat_log.async_add_assistant_content(assistant_content):
377+
pass
378+
return
379+
330380
# Call AI Hub API with streaming via HTTP
331381
headers = {
332382
"Authorization": f"Bearer {self._api_key}",
@@ -363,6 +413,37 @@ async def _async_handle_chat_log(
363413
_LOGGER.error("Error calling AI Hub API: %s", err)
364414
raise HomeAssistantError(ERROR_GETTING_RESPONSE) from err
365415

416+
def _convert_provider_tool_calls(
417+
self,
418+
tool_calls: list[dict[str, Any]] | None,
419+
) -> list[llm.ToolInput]:
420+
"""Convert provider tool calls to Home Assistant ToolInput objects."""
421+
if not tool_calls:
422+
return []
423+
424+
converted: list[llm.ToolInput] = []
425+
for tool_call in tool_calls:
426+
try:
427+
function_data = tool_call.get("function", {})
428+
arguments = function_data.get("arguments", {})
429+
if isinstance(arguments, str):
430+
arguments = json.loads(arguments) if arguments else {}
431+
if not isinstance(arguments, dict):
432+
arguments = {"value": arguments}
433+
434+
tool_id = tool_call.get("id") or ulid.ulid_now()
435+
converted.append(
436+
llm.ToolInput(
437+
id=tool_id,
438+
tool_name=function_data.get("name", "tool"),
439+
tool_args=arguments,
440+
)
441+
)
442+
except Exception as err:
443+
_LOGGER.warning("Failed to convert provider tool call: %s", err)
444+
445+
return converted
446+
366447
async def _async_convert_chat_log_to_messages(
367448
self, chat_log: conversation.ChatLog
368449
) -> list[dict[str, Any]]:

custom_components/ai_hub/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "cloud_polling",
1010
"issue_tracker": "https://github.com/ha-china/ai_hub/issues",
1111
"requirements": ["edge-tts==7.2.7", "aiofiles", "aiohttp"],
12-
"version": "v2026.3.6"
12+
"version": "v2026.04.0"
1313
}

custom_components/ai_hub/providers/anthropic_compatible.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,22 @@ def _get_headers(self) -> dict[str, str]:
5050
}
5151
if self.config.api_key:
5252
headers["x-api-key"] = self.config.api_key
53+
headers["Authorization"] = f"Bearer {self.config.api_key}"
5354
return headers
5455

5556
def _get_api_url(self) -> str:
56-
return self.config.base_url or _DEFAULT_API_URL
57+
url = self.config.base_url or _DEFAULT_API_URL
58+
if not url:
59+
return _DEFAULT_API_URL
60+
61+
normalized = url.rstrip("/")
62+
if normalized.endswith("/v1/messages"):
63+
return normalized
64+
if normalized.endswith("/messages"):
65+
return normalized
66+
if normalized.endswith("/v1"):
67+
return f"{normalized}/messages"
68+
return f"{normalized}/v1/messages"
5769

5870
def _convert_content_blocks(self, content: str | list[dict[str, Any]]) -> str | list[dict[str, Any]]:
5971
if isinstance(content, str):
@@ -140,6 +152,74 @@ def _convert_tools(self, tools: list[dict[str, Any]] | None) -> list[dict[str, A
140152
def _extract_text(self, content_blocks: list[dict[str, Any]]) -> str:
141153
return "".join(block.get("text", "") for block in content_blocks if block.get("type") == "text")
142154

155+
def _extract_response_text(self, data: dict[str, Any]) -> str:
156+
"""Extract text from Anthropic-compatible responses.
157+
158+
Some third-party compatible endpoints do not return the exact official
159+
Anthropic payload shape, so we accept a few common variants.
160+
"""
161+
content = data.get("content", [])
162+
if isinstance(content, str):
163+
return content
164+
if isinstance(content, list):
165+
text = self._extract_text(content)
166+
if text:
167+
return text
168+
169+
if isinstance(data.get("output_text"), str):
170+
return data["output_text"]
171+
if isinstance(data.get("text"), str):
172+
return data["text"]
173+
174+
message = data.get("message")
175+
if isinstance(message, dict):
176+
if isinstance(message.get("content"), str):
177+
return message["content"]
178+
if isinstance(message.get("content"), list):
179+
text = self._extract_text(message["content"])
180+
if text:
181+
return text
182+
183+
choices = data.get("choices")
184+
if isinstance(choices, list) and choices:
185+
first = choices[0]
186+
if isinstance(first, dict):
187+
if isinstance(first.get("text"), str):
188+
return first["text"]
189+
message = first.get("message")
190+
if isinstance(message, dict) and isinstance(message.get("content"), str):
191+
return message["content"]
192+
193+
return ""
194+
195+
def _extract_response_tool_calls(self, data: dict[str, Any]) -> list[dict[str, Any]] | None:
196+
"""Extract tool calls from Anthropic-compatible responses."""
197+
content = data.get("content", [])
198+
if isinstance(content, list):
199+
tool_calls = self._extract_tool_calls(content)
200+
if tool_calls:
201+
return tool_calls
202+
203+
message = data.get("message")
204+
if isinstance(message, dict):
205+
content = message.get("content")
206+
if isinstance(content, list):
207+
tool_calls = self._extract_tool_calls(content)
208+
if tool_calls:
209+
return tool_calls
210+
211+
choices = data.get("choices")
212+
if isinstance(choices, list) and choices:
213+
first = choices[0]
214+
if isinstance(first, dict):
215+
message = first.get("message")
216+
if isinstance(message, dict):
217+
tool_calls = message.get("tool_calls")
218+
if isinstance(tool_calls, list):
219+
return tool_calls
220+
221+
return None
222+
143223
def _extract_tool_calls(self, content_blocks: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
144224
tool_calls = []
145225
for block in content_blocks:
@@ -212,9 +292,14 @@ async def complete(
212292
"output_tokens": usage.get("output_tokens", 0),
213293
}
214294

295+
text_content = self._extract_response_text(data)
296+
tool_calls = self._extract_response_tool_calls(data)
297+
if not text_content and not tool_calls:
298+
_LOGGER.debug("Anthropic-compatible empty response payload: %s", data)
299+
215300
return LLMResponse(
216-
content=self._extract_text(content_blocks),
217-
tool_calls=self._extract_tool_calls(content_blocks),
301+
content=text_content,
302+
tool_calls=tool_calls,
218303
usage=usage,
219304
model=data.get("model"),
220305
finish_reason=data.get("stop_reason"),

custom_components/ai_hub/strings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"name": "Name",
4747
"recommended": "Recommended Mode",
4848
"prompt": "Prompt Template",
49+
"llm_provider": "LLM Provider",
4950
"chat_model": "Chat Model",
5051
"temperature": "Temperature",
5152
"top_p": "Top P",
@@ -56,6 +57,7 @@
5657
"data_description": {
5758
"recommended": "Use recommended settings",
5859
"prompt": "Custom system prompt",
60+
"llm_provider": "Select protocol type for the custom LLM endpoint",
5961
"temperature": "Control creativity (0-2)",
6062
"top_p": "Sampling parameter (0-1)",
6163
"max_tokens": "Maximum response length",

custom_components/ai_hub/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"name": "Name",
4545
"recommended": "Recommended Mode",
4646
"prompt": "Prompt Template",
47+
"llm_provider": "LLM Provider",
4748
"chat_model": "Chat Model",
4849
"chat_url": "Chat API URL",
4950
"custom_api_key": "Custom API Key",
@@ -56,6 +57,7 @@
5657
"data_description": {
5758
"recommended": "Use recommended settings",
5859
"prompt": "Custom system prompt",
60+
"llm_provider": "Select protocol type for the custom LLM endpoint",
5961
"chat_model": "Select or enter custom model name",
6062
"chat_url": "Complete chat API URL (if API fails, try appending /chat/completions)",
6163
"custom_api_key": "Custom API key (leave empty to use main key)",

custom_components/ai_hub/translations/zh-Hans.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"name": "名称",
4545
"recommended": "推荐模式",
4646
"prompt": "提示词模板",
47+
"llm_provider": "LLM 协议",
4748
"chat_model": "聊天模型",
4849
"chat_url": "对话API地址",
4950
"custom_api_key": "自定义API密钥",
@@ -56,6 +57,7 @@
5657
"data_description": {
5758
"recommended": "使用推荐配置",
5859
"prompt": "自定义系统提示词",
60+
"llm_provider": "选择自定义 LLM 接口使用的协议类型",
5961
"chat_model": "选择或输入自定义模型名称",
6062
"chat_url": "完整的对话API地址(如API不通,请在地址后添加 /chat/completions)",
6163
"custom_api_key": "自定义API密钥(留空使用主密钥)",

0 commit comments

Comments
 (0)