Skip to content

Commit a06f4b6

Browse files
authored
Anthropic model selection from list (home-assistant#156261)
1 parent 275670a commit a06f4b6

File tree

3 files changed

+201
-2
lines changed

3 files changed

+201
-2
lines changed

homeassistant/components/anthropic/config_flow.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from functools import partial
66
import json
77
import logging
8+
import re
89
from typing import Any
910

1011
import anthropic
@@ -283,7 +284,11 @@ async def async_step_advanced(
283284
vol.Optional(
284285
CONF_CHAT_MODEL,
285286
default=RECOMMENDED_CHAT_MODEL,
286-
): str,
287+
): SelectSelector(
288+
SelectSelectorConfig(
289+
options=await self._get_model_list(), custom_value=True
290+
)
291+
),
287292
vol.Optional(
288293
CONF_MAX_TOKENS,
289294
default=RECOMMENDED_MAX_TOKENS,
@@ -394,6 +399,39 @@ async def async_step_model(
394399
last_step=True,
395400
)
396401

402+
async def _get_model_list(self) -> list[SelectOptionDict]:
403+
"""Get list of available models."""
404+
try:
405+
client = await self.hass.async_add_executor_job(
406+
partial(
407+
anthropic.AsyncAnthropic,
408+
api_key=self._get_entry().data[CONF_API_KEY],
409+
)
410+
)
411+
models = (await client.models.list()).data
412+
except anthropic.AnthropicError:
413+
models = []
414+
_LOGGER.debug("Available models: %s", models)
415+
model_options: list[SelectOptionDict] = []
416+
short_form = re.compile(r"[^\d]-\d$")
417+
for model_info in models:
418+
# Resolve alias from versioned model name:
419+
model_alias = (
420+
model_info.id[:-9]
421+
if model_info.id
422+
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
423+
else model_info.id
424+
)
425+
if short_form.search(model_alias):
426+
model_alias += "-0"
427+
model_options.append(
428+
SelectOptionDict(
429+
label=model_info.display_name,
430+
value=model_alias,
431+
)
432+
)
433+
return model_options
434+
397435
async def _get_location_data(self) -> dict[str, str]:
398436
"""Get approximate location data of the user."""
399437
location_data: dict[str, str] = {}

tests/components/anthropic/conftest.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests helpers."""
22

33
from collections.abc import AsyncGenerator, Generator, Iterable
4+
import datetime
45
from unittest.mock import AsyncMock, patch
56

7+
from anthropic.pagination import AsyncPage
68
from anthropic.types import (
79
Message,
810
MessageDeltaUsage,
11+
ModelInfo,
912
RawContentBlockStartEvent,
1013
RawMessageDeltaEvent,
1114
RawMessageStartEvent,
@@ -123,7 +126,72 @@ async def mock_init_component(
123126
hass: HomeAssistant, mock_config_entry: MockConfigEntry
124127
) -> AsyncGenerator[None]:
125128
"""Initialize integration."""
126-
with patch("anthropic.resources.models.AsyncModels.retrieve"):
129+
model_list = AsyncPage(
130+
data=[
131+
ModelInfo(
132+
id="claude-haiku-4-5-20251001",
133+
created_at=datetime.datetime(2025, 10, 15, 0, 0, tzinfo=datetime.UTC),
134+
display_name="Claude Haiku 4.5",
135+
type="model",
136+
),
137+
ModelInfo(
138+
id="claude-sonnet-4-5-20250929",
139+
created_at=datetime.datetime(2025, 9, 29, 0, 0, tzinfo=datetime.UTC),
140+
display_name="Claude Sonnet 4.5",
141+
type="model",
142+
),
143+
ModelInfo(
144+
id="claude-opus-4-1-20250805",
145+
created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC),
146+
display_name="Claude Opus 4.1",
147+
type="model",
148+
),
149+
ModelInfo(
150+
id="claude-opus-4-20250514",
151+
created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC),
152+
display_name="Claude Opus 4",
153+
type="model",
154+
),
155+
ModelInfo(
156+
id="claude-sonnet-4-20250514",
157+
created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC),
158+
display_name="Claude Sonnet 4",
159+
type="model",
160+
),
161+
ModelInfo(
162+
id="claude-3-7-sonnet-20250219",
163+
created_at=datetime.datetime(2025, 2, 24, 0, 0, tzinfo=datetime.UTC),
164+
display_name="Claude Sonnet 3.7",
165+
type="model",
166+
),
167+
ModelInfo(
168+
id="claude-3-5-haiku-20241022",
169+
created_at=datetime.datetime(2024, 10, 22, 0, 0, tzinfo=datetime.UTC),
170+
display_name="Claude Haiku 3.5",
171+
type="model",
172+
),
173+
ModelInfo(
174+
id="claude-3-haiku-20240307",
175+
created_at=datetime.datetime(2024, 3, 7, 0, 0, tzinfo=datetime.UTC),
176+
display_name="Claude Haiku 3",
177+
type="model",
178+
),
179+
ModelInfo(
180+
id="claude-3-opus-20240229",
181+
created_at=datetime.datetime(2024, 2, 29, 0, 0, tzinfo=datetime.UTC),
182+
display_name="Claude Opus 3",
183+
type="model",
184+
),
185+
]
186+
)
187+
with (
188+
patch("anthropic.resources.models.AsyncModels.retrieve"),
189+
patch(
190+
"anthropic.resources.models.AsyncModels.list",
191+
new_callable=AsyncMock,
192+
return_value=model_list,
193+
),
194+
):
127195
assert await async_setup_component(hass, "anthropic", {})
128196
await hass.async_block_till_done()
129197
yield

tests/components/anthropic/test_config_flow.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,99 @@ async def test_subentry_web_search_user_location(
339339
}
340340

341341

342+
async def test_model_list(
343+
hass: HomeAssistant, mock_config_entry, mock_init_component
344+
) -> None:
345+
"""Test fetching and processing the list of models."""
346+
subentry = next(iter(mock_config_entry.subentries.values()))
347+
options_flow = await mock_config_entry.start_subentry_reconfigure_flow(
348+
hass, subentry.subentry_id
349+
)
350+
351+
# Configure initial step
352+
options = await hass.config_entries.subentries.async_configure(
353+
options_flow["flow_id"],
354+
{
355+
"prompt": "You are a helpful assistant",
356+
"recommended": False,
357+
},
358+
)
359+
assert options["type"] == FlowResultType.FORM
360+
assert options["step_id"] == "advanced"
361+
assert options["data_schema"].schema["chat_model"].config["options"] == [
362+
{
363+
"label": "Claude Haiku 4.5",
364+
"value": "claude-haiku-4-5",
365+
},
366+
{
367+
"label": "Claude Sonnet 4.5",
368+
"value": "claude-sonnet-4-5",
369+
},
370+
{
371+
"label": "Claude Opus 4.1",
372+
"value": "claude-opus-4-1",
373+
},
374+
{
375+
"label": "Claude Opus 4",
376+
"value": "claude-opus-4-0",
377+
},
378+
{
379+
"label": "Claude Sonnet 4",
380+
"value": "claude-sonnet-4-0",
381+
},
382+
{
383+
"label": "Claude Sonnet 3.7",
384+
"value": "claude-3-7-sonnet",
385+
},
386+
{
387+
"label": "Claude Haiku 3.5",
388+
"value": "claude-3-5-haiku",
389+
},
390+
{
391+
"label": "Claude Haiku 3",
392+
"value": "claude-3-haiku-20240307",
393+
},
394+
{
395+
"label": "Claude Opus 3",
396+
"value": "claude-3-opus-20240229",
397+
},
398+
]
399+
400+
401+
async def test_model_list_error(
402+
hass: HomeAssistant, mock_config_entry, mock_init_component
403+
) -> None:
404+
"""Test exception handling during fetching the list of models."""
405+
subentry = next(iter(mock_config_entry.subentries.values()))
406+
options_flow = await mock_config_entry.start_subentry_reconfigure_flow(
407+
hass, subentry.subentry_id
408+
)
409+
410+
# Configure initial step
411+
with patch(
412+
"homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list",
413+
new_callable=AsyncMock,
414+
side_effect=InternalServerError(
415+
message=None,
416+
response=Response(
417+
status_code=500,
418+
request=Request(method="POST", url=URL()),
419+
),
420+
body=None,
421+
),
422+
):
423+
options = await hass.config_entries.subentries.async_configure(
424+
options_flow["flow_id"],
425+
{
426+
"prompt": "You are a helpful assistant",
427+
"recommended": False,
428+
},
429+
)
430+
assert options["type"] == FlowResultType.FORM
431+
assert options["step_id"] == "advanced"
432+
assert options["data_schema"].schema["chat_model"].config["options"] == []
433+
434+
342435
@pytest.mark.parametrize(
343436
("current_options", "new_options", "expected_options"),
344437
[

0 commit comments

Comments
 (0)