Skip to content

Commit ebbdc31

Browse files
authored
Merge pull request #9 from knoop7/main
Enhance streaming with first-chunk timeout and auto-retry logic
2 parents 6baad2d + 4b41c7a commit ebbdc31

4 files changed

Lines changed: 80 additions & 56 deletions

File tree

custom_components/ai_hub/__init__.py

Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -109,59 +109,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: AIHubConfigEntry) -> boo
109109
"""Set up AI Hub from a config entry."""
110110

111111
# Get API key (may be None if not provided)
112+
# No startup validation - each entity validates on actual use
112113
api_key = get_configured_api_key(entry)
113114

114-
# Validate API key by testing API connection only if provided
115-
if api_key and api_key.strip():
116-
try:
117-
from .config_flow_validation import validate_input
118-
119-
await validate_input(hass, {CONF_API_KEY: api_key})
120-
except aiohttp.ClientError as err:
121-
_LOGGER.error("Failed to connect to API: %s", err)
122-
raise ConfigEntryNotReady(f"Failed to connect: {err}") from err
123-
except ValueError as err:
124-
reason = str(err)
125-
if reason == "invalid_auth":
126-
raise ConfigEntryAuthFailed("Invalid API key") from err
127-
if reason == "cannot_connect":
128-
raise ConfigEntryNotReady("API test failed") from err
129-
if reason.startswith("cannot_connect:"):
130-
detail = reason.split(":", 1)[1].strip()
131-
raise ConfigEntryNotReady(f"API test failed: {detail}") from err
132-
_LOGGER.error("API validation failed: %s", err)
133-
raise ConfigEntryNotReady(f"API validation failed: {err}") from err
134-
except ConfigEntryAuthFailed:
135-
raise
136-
except Exception as err:
137-
_LOGGER.error("API validation failed: %s", err)
138-
raise ConfigEntryNotReady(f"API validation failed: {err}") from err
139-
140115
# Initialize runtime data in hass.data
141116
ai_hub_data = get_or_create_ai_hub_data(hass)
142117
ai_hub_data.api_key = api_key
143118

144119
# Store in entry.runtime_data
145120
entry.runtime_data = api_key
146121

147-
# Sync auto-generated intent lists before loading local intent config
148-
from .intents.loader import async_sync_intent_lists
149-
await async_sync_intent_lists(hass)
150-
151-
# Set up intent handlers
152-
from .intents import async_setup_intents
153-
await async_setup_intents(hass)
154-
155-
# Set up services
156-
from .services import async_setup_services
157-
await async_setup_services(hass, entry)
158-
159-
# Forward setup to platforms last. If any initialization above fails and Home
160-
# Assistant retries the config entry, delaying platform setup prevents the
161-
# entity component from seeing the same entry as already configured.
162-
_LOGGER.debug("Setting up AI Hub platforms: %s", PLATFORMS)
163-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
164-
_LOGGER.debug("Platforms setup completed")
122+
# Each step is independent - one failure does not block others
123+
try:
124+
from .intents.loader import async_sync_intent_lists
125+
await async_sync_intent_lists(hass)
126+
except Exception as err:
127+
_LOGGER.warning("Intent list sync failed (non-fatal): %s", err)
128+
129+
try:
130+
from .intents import async_setup_intents
131+
await async_setup_intents(hass)
132+
except Exception as err:
133+
_LOGGER.warning("Intent handlers setup failed (non-fatal): %s", err)
134+
135+
try:
136+
from .services import async_setup_services
137+
await async_setup_services(hass, entry)
138+
except Exception as err:
139+
_LOGGER.warning("Services setup failed (non-fatal): %s", err)
140+
141+
# Forward setup to platforms individually - one failure should not block others
142+
for platform in PLATFORMS:
143+
try:
144+
await hass.config_entries.async_forward_entry_setups(entry, [platform])
145+
except Exception as err:
146+
_LOGGER.warning("Platform %s setup failed (others continue): %s", platform, err)
165147

166148
# Listen for options updates
167149
entry.async_on_unload(entry.add_update_listener(async_update_options))
@@ -182,9 +164,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: AIHubConfigEntry) -> bo
182164
if config_entry.entry_id != entry.entry_id
183165
]
184166

185-
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
186-
if not unload_ok:
187-
return False
167+
all_ok = True
168+
for platform in PLATFORMS:
169+
try:
170+
if not await hass.config_entries.async_unload_platforms(entry, [platform]):
171+
_LOGGER.warning("Failed to unload platform %s", platform)
172+
all_ok = False
173+
except Exception as err:
174+
_LOGGER.warning("Error unloading platform %s: %s", platform, err)
175+
all_ok = False
188176

189177
from .services import async_unload_services
190178
await async_unload_services(hass, entry.entry_id)
@@ -195,7 +183,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: AIHubConfigEntry) -> bo
195183
ai_hub_data.cleanup()
196184
hass.data.pop(DOMAIN, None)
197185

198-
return True
186+
return all_ok
199187

200188

201189
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

custom_components/ai_hub/entity.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from homeassistant.helpers.entity import Entity
1717
from homeassistant.util import ulid
1818
from .providers import LLMMessage, create_provider
19+
from .http import FirstChunkTimeoutError
1920
from .consts import (
2021
CONF_CHAT_MODEL,
2122
CONF_CHAT_URL,
@@ -397,20 +398,33 @@ async def _async_run_provider_stream(
397398
provider: Any,
398399
llm_messages: list[LLMMessage],
399400
tools: list[dict[str, Any]] | None = None,
401+
_retry_count: int = 0,
400402
) -> None:
401403
"""Run a provider in streaming mode for text-only turns."""
402404
_LOGGER.debug(
403-
"Invoking provider=%s stream=%s tools=%d",
405+
"Invoking provider=%s stream=%s tools=%d retry=%d",
404406
provider_name,
405407
True,
406408
len(tools or []),
409+
_retry_count,
407410
)
408411
has_output = False
409-
async for _ in chat_log.async_add_delta_content_stream(
410-
self.entity_id,
411-
self._transform_provider_stream(provider.complete_stream(llm_messages, tools=tools)),
412-
):
413-
has_output = True
412+
try:
413+
async for _ in chat_log.async_add_delta_content_stream(
414+
self.entity_id,
415+
self._transform_provider_stream(provider.complete_stream(llm_messages, tools=tools)),
416+
):
417+
has_output = True
418+
except FirstChunkTimeoutError:
419+
if _retry_count < 1:
420+
_LOGGER.warning(
421+
"Provider %s first chunk timeout, retrying...",
422+
provider_name,
423+
)
424+
return await self._async_run_provider_stream(
425+
chat_log, provider_name, provider, llm_messages, tools, _retry_count + 1
426+
)
427+
raise
414428

415429
if not has_output:
416430
_LOGGER.warning(

custom_components/ai_hub/http.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ async def async_post_json(
3939
return await response.json()
4040

4141

42+
class FirstChunkTimeoutError(TimeoutError):
43+
"""Raised when the first chunk is not received within the timeout."""
44+
45+
4246
async def async_stream_response_text(
4347
url: str,
4448
*,
@@ -47,15 +51,33 @@ async def async_stream_response_text(
4751
ssl: bool | None,
4852
timeout: float,
4953
error_label: str,
54+
first_chunk_timeout: float = 15.0,
5055
) -> AsyncGenerator[str, None]:
5156
"""POST JSON and yield decoded response chunks."""
57+
import asyncio
58+
5259
async with aiohttp.ClientSession(timeout=client_timeout(timeout)) as session:
5360
async with session.post(url, json=payload, headers=headers, ssl=ssl) as response:
5461
if response.status != 200:
5562
error_text = await response.text()
5663
raise RuntimeError(f"{error_label}: {error_text}")
5764

58-
async for chunk in response.content:
65+
content_iter = response.content.__aiter__()
66+
try:
67+
first_chunk = await asyncio.wait_for(
68+
content_iter.__anext__(), timeout=first_chunk_timeout
69+
)
70+
except asyncio.TimeoutError as err:
71+
raise FirstChunkTimeoutError(
72+
f"No response from server within {first_chunk_timeout}s"
73+
) from err
74+
except StopAsyncIteration:
75+
return
76+
77+
if first_chunk:
78+
yield first_chunk.decode("utf-8", errors="ignore")
79+
80+
async for chunk in content_iter:
5981
if chunk:
6082
yield chunk.decode("utf-8", errors="ignore")
6183

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.04.12"
12+
"version": "v2026.04.13"
1313
}

0 commit comments

Comments
 (0)