Compare commits

...
436 Commits
Author SHA1 Message Date
SMKRV 03cd40de29 docs: Add Quick Start section (issue #13) 2026-07-21 10:21:36 +03:00
SMKRV 33d4a190b9 chore: Ignore handoff snapshots 2026-07-13 00:44:56 +03:00
SMKRV 990134bde5 docs: Fix sensor attribute examples, refresh model lineup, strip filler from README 2026-07-13 00:40:45 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3cd8d3076a chore: bump the actions group with 2 updates (#12)
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `softprops/action-gh-release` from 2 to 3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 02:15:43 +03:00
SMKRV 65dbb5dfc0 fix: Fail-closed pinned resolver, close session on HA stop, drop dead workflow step
- _PinnedResolver raises on unpinned hosts instead of falling back to
  live DNS: nothing legitimate resolves other hosts on a pinned session
  (redirects are off), so fail closed
- dedicated session is also closed on EVENT_HOMEASSISTANT_CLOSE: core
  stop does not unload entries, and the raw ClientSession has no HA
  auto-cleanup, which left 'Unclosed client session' logs at shutdown
- hassfest.yaml: remove the version-print step, hassfest runs inside
  the action's docker image and is never on the runner PATH
2026-07-07 01:49:03 +03:00
SMKRV fc59f584a3 fix: Services survive config entry reload, get_history returns a dict response
- service registration extracted to idempotent _async_register_services,
  called from both async_setup and async_setup_entry: unloading the last
  entry unregisters services, and a reload (every options change) never
  re-ran async_setup, leaving the integration without services
- get_history handler wraps the list in {"history": [...]}: HA rejects
  non-dict action responses, so every return_response call failed with a
  server error since the service gained SupportsResponse (v2.4.x) - the
  path never worked, no consumer could depend on the old shape
- README: get_history example shows response_variable usage and the
  actual limit clamp semantics

Both found by live smoke test in HA 2026.7 (Docker), not by static review
2026-07-07 01:38:27 +03:00
SMKRV bcfe69b5f7 fix: Pinned session TypeError, dead default models, get_history compat, i18n sync
- create_pinned_session built directly on aiohttp: the HA helper injects
  its own connector and raised TypeError on every setup and config flow
- session lifecycle: closed in APIClient.shutdown, on failed setup, and
  in config flow validators; allow_redirects=False everywhere so 3xx
  cannot route past the pinned resolver
- defaults: gemini-2.0-flash (shut down 2026-06-01) -> gemini-3.5-flash,
  deepseek-chat (retires 2026-07-24) -> deepseek-v4-flash
- DeepSeek V4 disable_thinking via thinking request parameter
- Gemini 3.x disable_thinking via thinking_level (MINIMAL, LOW for Pro)
- get_history limit: no forced default/max in schema, storage layer clamps
- translations: 6 locales caught up with strings.json, 2 orphan keys dropped
- README: manual install path matches zip layout, model sections updated
2026-07-07 01:23:01 +03:00
SMKRV 9d4d2d5cd1 fix: Strip angle brackets from disable_thinking labels for hassfest translations check 2026-07-07 01:05:57 +03:00
SMKRV 6e635f7e2f fix: v2.5.1 security/ML/reliability patch
Security:
- H1 DNS rebinding TOCTOU: validate_endpoint returns (endpoint, resolved_ips);
  create_pinned_session builds an aiohttp session with a custom resolver that
  returns only the pre-validated IPs, closing the re-resolve gap.
- H2/M3 Shared session cookie pollution: integration no longer uses HA shared
  clientsession; isolated session with DummyCookieJar prevents cross-integration
  cookie leaks.
- Cloud metadata / link-local block added to allow_local_network mode to
  prevent IMDS exfiltration on cloud VMs.
- L3 hard cap extended to history.async_get_history (not only service schema).

ML/LLM correctness:
- _is_openai_reasoning_model uses regex with gpt-5-chat* blacklist and handles
  OpenRouter-style openai/ prefix. Future o5/gpt-6 auto-recognized.
- reasoning_effort=minimal for gpt-5 family, low for o-series.
- Gemini 2.5 Pro gets thinking_budget=128 (Pro rejects 0, flash accepts 0).
- Anthropic extracts first type=text block instead of hardcoded content[0].
- /no_think dedup uses word-boundary regex instead of substring.
- DeepSeek-reasoner detected: skips /no_think; preserves reasoning_content.

Reliability:
- Exception chaining added to Gemini-block re-raises.
- Top-level imports for json/re/hashlib (no more lazy stdlib imports).
- allow_local_network logged at INFO, not WARNING on every setup.

Code style:
- PEP 604 type hints across all .py files (dict/list/| None).

No breaking changes for users. Internal API: validate_endpoint return type
changed from str to tuple[str, list[str]].
2026-04-17 01:58:16 +03:00
SMKRV e8b9b911ef chore: Extend .gitignore with AI tooling directories
Add .claude/, .cursor/, .cursorrules, .windsurfrules, AGENTS.md, GEMINI.md
as defensive gitignore entries. None of these files currently exist in the
repo, but this prevents accidental leaks of AI-assistant local state if
such files appear in a future workspace.
2026-04-17 01:36:44 +03:00
SMKRV 5af733b1b0 feat: v2.5.0 - disable_thinking, reasoning models, MIT license, security hardening
Features:
- disable_thinking toggle (issue #11) with per-provider semantics:
  OpenAI classic gets /no_think soft-switch, OpenAI reasoning gets
  reasoning_effort=low, DeepSeek gets both, Anthropic no-op (thinking
  opt-in), Gemini 2.5+ gets thinking_budget=0
- OpenAI reasoning model support (o1/o3/o4-mini/gpt-5 family):
  max_completion_tokens, developer role, reasoning_effort
- Per-request disable_thinking override in ask_question service
- Provider-specific temperature clip (Anthropic 0-1, others 0-2)

Security:
- Require API key re-entry on provider change (OptionsFlow)
- Validate Anthropic json_schema before system-prompt concatenation
- Symlink protection in history file operations
- Hardened secret redaction regexes (sk-*, AIza*, x-api-key)
- get_history service: hard cap on limit (default 10, max 100)

Reliability:
- Retry on 502/503/504 in addition to 429/timeout
- Honor Retry-After header on 429
- Exception chaining (raise ... from err)
- _strip_think_blocks handles nested/dangling tags
- normalize_name sha256 fallback for empty-collapse inputs

UI/housekeeping:
- api_key uses TextSelector(type=PASSWORD)
- DeviceInfo entry_type=SERVICE
- Services unregister on last entry unload
- Dependabot for GitHub Actions
- persist-credentials=false in checkout steps
- manifest requirements pinned with upper bounds
- Ignore docs/plans/ (working artifacts)

License: PolyForm Noncommercial 1.0.0 -> MIT

No breaking changes: service response shape, sensor attributes,
entity IDs and on-disk formats unchanged.
2026-04-17 01:30:57 +03:00
SMKRV 1e2ff81d07 feat: Add allow_local_network option for self-hosted LLM proxies
Add per-instance boolean option to allow private IP endpoints and HTTP
scheme for self-hosted LLM proxies (LiteLLM, Ollama, vLLM, etc.).

- New CONF_ALLOW_LOCAL_NETWORK config option (default: false)
- When enabled: allows RFC1918 private IPs and HTTP endpoints
- When disabled: full SSRF protection preserved (HTTPS + public IPs only)
- Multicast and unspecified addresses blocked regardless of setting
- Warning logged when local network mode is active
- Checkbox added to ConfigFlow and OptionsFlow UI
- Translations for all 8 languages

Closes #9
2026-03-23 12:18:58 +03:00
SMKRV 47c731c9ee docs: Update structure.md to reflect current file layout
Fix root path to custom_components/ha_text_ai/, add new modules
(history.py, metrics.py, providers.py, utils.py, strings.json).
2026-03-12 14:54:18 +03:00
SMKRV 4e3b4cfdb7 fix: Resolve production issues — history dir creation and sensor attributes > 16KB
History:
- Add defensive os.makedirs in _sync_test_directory_write to ensure
  directory exists before write test (fixes ERRNO 2 on fresh install)

Sensor attributes (fixes Recorder 16KB limit violation):
- Reduce conversation_history preview: 3 entries × 256 chars (was 5 × 4096)
- Reduce last response/question truncation to 2048 chars (was 4096)
- Reduce system_prompt in attributes to 512 chars (was 4096)
- Full data remains accessible via ha_text_ai.get_history service
2026-03-12 12:43:03 +03:00
SMKRV c45828953d chore: Change license from CC BY-NC-SA 4.0 to PolyForm Noncommercial 1.0.0
Replace Creative Commons Attribution-NonCommercial-ShareAlike 4.0
with PolyForm Noncommercial License 1.0.0 across all files:
LICENSE, README badge/footer, and all Python module headers.
2026-03-12 12:13:54 +03:00
SMKRV ad36352fe8 docs: Update README with current model names, remove outdated YAML config section
- Update recommended models to current versions (Claude 4.6, GPT-5, Gemini 3.1, DeepSeek-V3)
- Add Google Gemini to features list
- Remove YAML configuration section (integration is config_entry_only)
- Remove deleted Api status attribute from documentation
- Update conversation_history display count (1 → 5)
- Fix FAQ with current model names and token limits
- Remove internal spec file
2026-03-12 12:05:43 +03:00
SMKRV 618ad34ccc chore: Add CLAUDE.md to .gitignore 2026-03-12 02:36:11 +03:00
SMKRV 0a06fbeca6 chore: Add Claude working directories to .gitignore 2026-03-12 02:34:11 +03:00
SMKRV 772a614a20 chore: Add .gitignore for pycache, venvs, IDE files, archives 2026-03-12 02:32:42 +03:00
SMKRV c8be545655 fix: Prevent shallow copy mutation in async_get_history with include_metadata
When include_metadata=True, the metadata dict was added directly to the
original history entry objects (shallow copy of list, same dict refs).
Now creates per-entry dict copies before enriching with metadata.
2026-03-12 02:24:52 +03:00
SMKRV a8a91972ba fix: Comprehensive v2.4.0 quality pass — 24 review findings + review agent fixes
Phase A — Critical:
- Remove dual timeout stacking in coordinator._send_to_api
- Add Gemini-specific asyncio.timeout (sync SDK via to_thread)
- Store full text in history for context; cap per-field at 32KB on disk
- Fix instance lookup to match by normalized_name
- Add Bearer/sk-/x-api-key credential sanitization patterns

Phase B — Dead code removal:
- Merge async_ask_question/async_process_question into single method
- Remove dead is_anthropic flag from coordinator and __init__
- Remove unused DEFAULT_TIMEOUT and API_TIMEOUT constants
- Remove redundant _create_history_dir calls
- Consolidate async_check_api to use provider registry

Phase C — Config flow correctness:
- Truncate name before uniqueness check (prevent post-truncation collisions)
- Add async_set_unique_id + _abort_if_unique_id_configured
- Extract shared _build_parameter_schema for ConfigFlow/OptionsFlow dedup

Phase D — UX improvements:
- Optimize history write: serialize from memory, single file write
- Show last 5 history entries in sensor attributes (was 1)
- Return actual error type in ask_question service response
- Add dedicated api_key_required error for provider/endpoint changes
- Pass config_entry to DataUpdateCoordinator (HA 2024.8+)

Phase E — Cleanup:
- Extract _apply_structured_output for OpenAI/DeepSeek dedup
- Reduce ABSOLUTE_MAX_HISTORY_SIZE to 200 with Final annotation
- Remove dead translation keys (queued, invalid_characters)
- Migrate to _attr_has_entity_name = True
- Add from __future__ import annotations to all modules
- Remove redundant api_status sensor attribute
- Add missing translation keys (last_model, last_timestamp, etc.)

Review agent fixes:
- Add archive file cleanup (max 3 archives) to prevent disk exhaustion
- Per-entry storage cap (32KB per field) for history on disk
2026-03-12 02:24:09 +03:00
SMKRV 9cdeb9f417 fix: HA 2024.11+ compatibility and deprecation fixes
CRITICAL:
- Remove OptionsFlowHandler.__init__ (deprecated HA 2024.11+)
- Replace FlowResult with ConfigFlowResult (deprecated HA 2024.4+)

Compatibility:
- Reorder async_unload_entry: unload platforms before coordinator cleanup
- Clean up hass.data[DOMAIN] when last entry removed
- Remove aiohttp from manifest requirements (provided by HA core)
- Replace blocking file I/O in const.py with hardcoded VERSION
- Remove unused os, json, logging imports from const.py
- Fix f-string logger calls in api_client.py to use %s formatting
2026-03-12 02:06:21 +03:00
SMKRV 31560a8835 fix: Round 2 review findings — async SSRF, error sanitization, constants
Security:
- Make validate_endpoint async with hass.async_add_executor_job for DNS resolution
- Use _RestrictedIPError instead of fragile string matching for IP check flow
- Add is_multicast/is_unspecified to SSRF IP restriction checks
- Remove resolved private IP from error messages (generic message)
- Remove raw endpoint from __init__.py error log
- Sanitize error messages in metrics: strip URLs, API keys, Gemini key patterns
- Truncate API error response bodies before logging (512 chars)
- Use generic error messages in Gemini exception handlers (no str(e) interpolation)
- Pass api_key as explicit APIClient constructor parameter (not from header)
- Add defensive validation: Gemini provider requires api_key at construction

Code quality:
- Add constants: DEFAULT_INSTANCE_NAME, MIN/MAX_CONTEXT_MESSAGES, MIN/MAX_HISTORY_SIZE
- Replace all hardcoded schema ranges with named constants
- Move datetime import to module level in history.py
- Remove unused full_history/full_history_available keys
- Chain socket.gaierror properly with raise...from
2026-03-12 01:57:49 +03:00
SMKRV c54bfcff3b fix: Coordinator split, Gemini chat context, review findings fixes
- Extract HistoryManager (history.py) and MetricsManager (metrics.py) from coordinator
- Fix Gemini chat: use client.chats.create(history=...) instead of sequential send_message
- Fix float("inf") in metrics breaking json.dump persistence
- Fix _get_current_state checking wrong error key ("error" vs "error_message")
- Fix API key re-entry requirement when endpoint/provider changes in options flow
- Make CONF_API_KEY optional in options schema (stored key used as fallback)
- Add DNS rebinding protection via socket.getaddrinfo in validate_endpoint
- Remove mass assignment vulnerability in config_flow._create_entry
- Add description_placeholders to all error re-show paths
- Fix history migration overwriting existing JSON data
- Return list copy from get_limited_history to prevent reference leaks
- Add history_info to _get_safe_initial_state for data shape consistency
- Add defensive None check in _get_sanitized_last_response
- Move dt_util import to module level in config_flow
- Remove dead fallback branches in coordinator.last_response property
- Fix sensor min_latency display after float("inf") removal
- Fix history directory permissions from 0o777 to 0o755
- Deduplicate schema definitions via _build_provider_schema()
- Use centralized build_auth_headers from providers.py
2026-03-12 01:50:17 +03:00
SMKRV 63c5c7c51e fix: Address code review findings for v2.4.0
- Fix 404 accepted as valid API response in config_flow validation (both ConfigFlow and OptionsFlow)
- Sanitize catch-all exception in config_flow (str(e) → "unknown" error key)
- Add exception chaining (from err) to ConfigEntryNotReady raise
- Replace datetime.now() with dt_util.utcnow() for HA timezone convention
- Add defensive raise after retry loop in api_client._make_request
- Remove unused CONF_API_KEY/CONF_NAME imports from const.py
- Restore google-genai dependency in manifest (required for Gemini provider)
2026-03-12 01:26:31 +03:00
SMKRV 998103ce73 fix: Comprehensive v2.4.0 security, stability, and quality improvements
Security:
- Add SSRF protection via HTTPS-only endpoint validation with private IP blocking
- Mask API keys in all log output via safe_log_data()
- Sanitize error responses to prevent internal detail leakage
- Remove API key pre-population in config flow forms
- Harden service schemas with input length limits and type coercion

Bug fixes:
- Fix temperature=0 rejected due to Python falsy value bug (0 or default)
- Fix fire-and-forget race condition in coordinator init (5 async_create_task → awaited async_initialize)
- Fix rate limit state not resetting after successful API calls
- Fix ConnectionError incorrectly setting is_rate_limited=True
- Fix _rotate_history calling async method via sync executor
- Fix shutil.move blocking event loop in history migration
- Fix async_shutdown removing wrong key from hass.data
- Replace os.rename with shutil.move for cross-device safety

Improvements:
- Add asyncio.Lock for request serialization
- Replace async_timeout with stdlib asyncio.timeout (Python 3.12+)
- Use SupportsResponse.OPTIONAL for ask_question and get_history services
- Use Platform.SENSOR enum instead of string literal
- Add strings.json for HA translation framework
- Update DEFAULT_ANTHROPIC_MODEL to claude-sonnet-4-6
- Retry only transient errors (429, timeout) in API client

Cleanup:
- Remove dead code: unused schemas, imports, sync_write_history, check_memory, check_connection
- Remove icon copying code from async_setup
- Remove unused dependencies from manifest (anthropic, openai, certifi, async-timeout)
- Remove empty manifest arrays (bluetooth, mqtt, ssdp, usb, zeroconf)
- Fix duplicate JSON keys in 5 translation files
- Fix Serbian translation: "Прекључено" → "Искључено" for disconnected state

Bump version to 2.4.0
2026-03-12 01:24:01 +03:00
SMKRV ce0a75f219 refactor: Phase 0 — extract utils.py and providers.py, centralize provider dispatch
- Create utils.py: normalize_name, get_file_hash, safe_log_data
- Create providers.py: PROVIDER_REGISTRY with get_default_endpoint,
  get_default_model, build_auth_headers
- Add DEFAULT_ANTHROPIC_MODEL constant (was incorrectly using gpt-4o-mini)
- Replace all inline dispatch tables in config_flow.py and __init__.py
- Fix circular import: coordinator.py now imports from utils, not config_flow
- Fix NameError in error paths: replace bare constant refs with provider functions
- Raise ValueError for unknown providers instead of silent OpenAI fallback
2026-03-12 00:55:59 +03:00
SMKRV e7c8b22fde docs: Add v2.4.0 comprehensive fix plan spec
Full refactoring plan covering 48 issues from 4 parallel reviews
(code, security, architecture, UI/UX). 6 phases + final review + tests.
2026-03-12 00:50:02 +03:00
SMKRV 43cbac2d04 feat: Add ability to edit provider, API key, and endpoint in existing integrations
- Extended OptionsFlowHandler with two-step configuration flow
- Step 1: Select provider (OpenAI, Anthropic, DeepSeek, Gemini)
- Step 2: Configure API key, endpoint, model, and other settings
- Auto-reload integration on options change
- When switching providers, show appropriate default endpoint and model
- Updated translations for all 8 languages
2025-12-30 17:22:48 +03:00
SMKRV 986c78dd90 fix: Fix OptionsFlowHandler for HA 2024.1+ compatibility
- Remove __init__ method that was passing config_entry as argument
- OptionsFlow now automatically receives config_entry from base class
- Fixes '500 Internal Server Error' when editing existing integrations
2025-12-30 17:10:06 +03:00
SMKRV 0859c35aec fix: Change release workflow to trigger on release created event 2025-12-30 16:56:27 +03:00
SMKRV 922fefbd43 chore: Bump version to 2.3.0 2025-12-30 16:54:23 +03:00
SMKRV a5ac100b06 feat: Add structured output support with JSON schema validation
- Introduced `structured_output` and `json_schema` parameters to enhance API responses.
- Updated service schemas and API client methods to handle structured output.
- Added translations for new parameters in multiple languages.
- Updated documentation to reflect changes in service capabilities.

Closes #9
2025-12-30 16:44:01 +03:00
smkrvandGitHub ef579af7c1 Create release.yml 2025-12-30 16:27:46 +03:00
SMKRV 3097106e93 feat: Add configurable API timeout setting
- Add CONF_API_TIMEOUT configuration option (5-600 seconds, default 30)
- Update config_flow.py with api_timeout field in provider form and options flow
- Update api_client.py to use configurable timeout instead of hardcoded value
- Update coordinator.py to use api_timeout for async_process_message
- Update __init__.py to read and pass api_timeout from config
- Merge entry.data with entry.options for proper options flow support
- Add translations for api_timeout in all 8 language files (en, ru, de, es, it, hi, sr, zh)
- Bump version to 2.2.0

Closes #8
2025-12-22 00:07:17 +03:00
smkrvandGitHub 35073960b8 Delete ha-text-ai.code-workspace 2025-09-03 00:56:56 +03:00
SMKRV 8d0e0b5e44 docs: update README with context_messages parameter
- Added context_messages parameter to Platform Configuration table
- Fixed duplicate parameter entry in configuration docs
- Parameter allows 1-20 previous messages in context (default: 5)
2025-09-02 23:50:20 +03:00
SMKRV e91c3701c5 Fix: Resolve get_history service parameter handling issue
- Fixed async_get_history method to accept limit parameter and other filtering options
- Updated service schema to support all parameters from services.yaml
- Added support for start_date, include_metadata, and sort_order parameters
- Version bump to 2.1.9
2025-09-02 23:27:34 +03:00
smkrv 7f62101b3e Update HACS minimum HA version to align with README requirement (2024.12.0) 2025-09-02 23:15:01 +03:00
smkrvandGitHub 3729c3736f Update hassfest.yaml 2025-09-02 09:36:46 +03:00
smkrvandGitHub f5ce5e459a Update hassfest.yaml
fix: https://github.com/smkrv/ha-text-ai/security/code-scanning/2
2025-09-02 09:24:35 +03:00
smkrvGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
4064486b1e Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-02 09:18:15 +03:00
SMKRV 185778dbd0 docs: Update AI models to latest versions
- Update OpenAI models to GPT-5 and GPT-5 mini
- Update Anthropic Claude models to 4.1, 4.0 series
- Update DeepSeek models to V3.1 and R1
- Update Google Gemini models to 2.5 and 2.0 series
- Modernize model descriptions and capabilities
2025-09-02 02:19:37 +03:00
SMKRV 83a255dee0 docs: Update README.md - actualize documentation
- Simplify HACS installation instructions
- Update recommended models section (remove year from title)
- Update Claude model names to current format (claude-3.5-sonnet, claude-3.5-haiku)
- Add missing parameters to get_history service documentation
- Remove non-configurable history_file_size parameter from table
- Add missing context_messages parameter to configuration table
- Update all model references in examples to use current naming
2025-09-02 02:11:34 +03:00
SMKRV 6b66dd6a4d docs: Update README with latest configuration defaults and Gemini models
- Update default model from gpt-4o to gpt-4o-mini
- Update default temperature from 0.7 to 0.1
- Update default max_history_size from 100 to 50
- Add gemini-2.0-flash as latest recommended Gemini model
- Fix logo image link to use main branch instead of specific commit
- Update configuration parameters table with current defaults
2025-09-02 02:06:58 +03:00
SMKRV bd82f23120 docs: Update HACS badge from Custom to Default 2025-09-02 01:56:24 +03:00
SMKRV eee9754033 fix: Fix JSON syntax errors in translation files
- Fixed missing closing brace in es.json selector.api_provider.options
- Fixed missing closing brace in de.json selector.api_provider.options
- All other translation files (hi.json, it.json, sr.json, zh.json) have correct syntax
- Ensures proper JSON validation and prevents parsing errors
2025-09-02 01:26:49 +03:00
SMKRV 517b1f11ae fix: Remove invalid response schema from services.yaml
Home Assistant's hassfest validation does not support 'response' section in services.yaml.
The response_variable functionality still works through supports_response=True flag in service registration.

Fixes hassfest validation error: extra keys not allowed @ data['ask_question']['response']
2025-09-02 01:22:20 +03:00
SMKRV ed8f19bfa9 fix: Add support for response_variable in ask_question service
- Added response schema definition in services.yaml for ask_question service
- Set supports_response=True flag when registering the service
- Fixed JSON syntax error in English translation file
- Added comprehensive documentation with examples for response_variable usage
- Users can now capture AI responses directly in variables without sensor delays

Resolves issue where scripts failed with 'Script does not support response_variable' error
2025-09-02 01:19:44 +03:00
SMKRV 7e3daf611b fix: Remove target requirements from services to fix mandatory device/area/entity selection issue
- Removed target blocks from all services in services.yaml
- Services now work as global services without requiring device/area/entity selection
- Users can call services directly with only required parameters
- Fixes issue #2 where services incorrectly required target selection after v2.1.8 update
2025-09-01 23:40:26 +03:00
SMKRV 37919be70f fix: Resolve hassfest validation errors in services.yaml
- Remove invalid response schema from ask_question service
- Add required target configuration for all services
- Ensure compliance with Home Assistant service schema requirements
2025-09-01 17:20:40 +03:00
SMKRV e427254584 feat: Implement response variables support and comprehensive production audit (v2.1.8)
🚀 Major Features:
- Add response variables support to ask_question service
- Eliminate 255-character limitation for AI responses
- Enable direct data access in automations without sensors
- Prevent race conditions in parallel automations

🔧 Production Code Audit & Fixes:
- Enhanced resource management with context managers in api_client.py
- Fixed critical race conditions with asyncio.Semaphore implementation
- Improved file operations with atomic writes and corruption handling
- Enhanced error handling and logging security (removed sensitive data)
- Fixed _check_memory_available method placement in coordinator.py

🌐 Translation Updates (8 languages):
- Updated all translation files with response variables information
- Enhanced service descriptions in: en, ru, de, es, it, hi, sr, zh
- Added information about direct response capability
- Maintained consistency across all language files

📚 Documentation Enhancements:
- Added comprehensive Response Variables section to README
- Created advanced automation examples with response_variable usage
- Added migration guide from sensors to response variables
- Enhanced service documentation with response data structure
- Added practical examples for multi-step AI workflows

🔄 Service Improvements:
- Enhanced ask_question service to return structured response data
- Added comprehensive response schema in services.yaml
- Improved error handling with success/failure indicators
- Added metadata support (tokens, model, timestamp)

�� Version & Manifest:
- Bumped version to 2.1.8
- Maintained compatibility with existing integrations
- Updated service documentation

This release addresses GitHub issue #2 and significantly improves the integration's
production readiness while adding powerful new response variable functionality.
2025-09-01 17:14:23 +03:00
SMKRV 76c5629fa0 refactor(google-gemini): rewrite integration using google-genai 1.16.0
Completely rewrote the Google Gemini integration logic based on google-genai 1.16.0 to fix issue #6.
Key changes:
- Updated to the latest google-genai library
- Made API endpoint abstract while retaining option for custom endpoint configuration
- Refactored logic and classes exclusively within Google Gemini implementation
- All changes are limited to Google Gemini integration refactoring with no impact on other functionality.
2025-05-21 01:27:47 +03:00
SMKRV 7958bd010b refactor(google-gemini): rewrite integration using google-genai 1.16.0
Completely rewrote the Google Gemini integration logic based on google-genai 1.16.0 to fix issue #6.
Key changes:
- Updated to the latest google-genai library
- Made API endpoint abstract while retaining option for custom endpoint configuration
- Refactored logic and classes exclusively within Google Gemini implementation
- All changes are limited to Google Gemini integration refactoring with no impact on other functionality.
2025-05-21 01:26:42 +03:00
SMKRV 8cd876195a Bump to version 2.1.6 2025-05-20 01:50:06 +03:00
SMKRV 376753e001 fix: correct field naming in Gemini API requests from camelCase to snake_case and improve message handling 2025-05-20 01:42:38 +03:00
SMKRV b6e73e847d fix(api_client): correct Google Gemini API integration
- Change JSON field names from camelCase to snake_case as required by Gemini API
  (generation_config, max_output_tokens, system_instruction)
- Improve message handling to ensure proper role alternation (user/model)
- Add safety checks for empty contents and ensure first message is always from user
- Implement robust error handling and response parsing
- Handle edge cases where candidatesTokenCount might be returned as a list

Fixes #6
2025-05-20 01:16:41 +03:00
SMKRV 440c734214 Bump release version to v2.1.4 2025-05-19 23:20:27 +03:00
SMKRV 73788373cd Release v2.1.3 2025-05-19 23:12:55 +03:00
SMKRV 4bfc96019b fix: DEFAULT_GEMINI_ENDPOINT 2025-05-19 15:53:43 +03:00
SMKRV 2138fc7654 fix: DEFAULT_GEMINI_ENDPOINT 2025-05-19 15:36:58 +03:00
SMKRV 95bd2ebb41 Add support for Google Gemini (thanks to @Azzedde) #5 2025-05-19 15:10:19 +03:00
smkrvandGitHub cad0fd7031 Merge pull request #5 from Azzedde/main
Add Gemini API provider support to HA Text AI integration by @Azzedde
2025-05-19 14:44:06 +03:00
Azzedde c003b258f6 Add Gemini API provider support to HA Text AI integration 2025-05-18 13:23:55 +02:00
SMKRV 65a10c77f4 ~ 2025-01-30 01:15:13 +03:00
SMKRV e1463828c9 ~ 2025-01-30 01:14:24 +03:00
SMKRV 5ebb9c9c66 fix: max_tokens value 2025-01-29 18:04:13 +03:00
SMKRV f17c631a79 fix: max_tokens value 2025-01-29 18:02:42 +03:00
SMKRV 0e06794384 refactor(docs): shields & community links updated 2025-01-29 03:05:47 +03:00
SMKRV d8a924909b refactor(docs): shields & community links updated 2025-01-29 03:05:11 +03:00
SMKRV 29f1659a02 refactor(docs): shields & community links updated 2025-01-29 03:04:39 +03:00
SMKRV 5b7905de80 refactor(docs): shields & community links updated 2025-01-29 03:03:48 +03:00
SMKRV cf9ac6dcea refactor(docs): shields & community links updated 2025-01-29 01:08:45 +03:00
SMKRV 568eb3e16c refactor(docs): shields updated 2025-01-29 00:58:27 +03:00
SMKRV 53fb150389 refactor(docs): shields updated 2025-01-29 00:58:09 +03:00
SMKRV acbb53d2af refactor(docs): shields updated 2025-01-29 00:57:27 +03:00
SMKRV e19db29441 refactor(docs): DeepSeek Integration 2025-01-28 16:25:59 +03:00
SMKRV bfd64d1122 Release v2.1.1: Token Handling Improvement and DeepSeek Support
- Completely reworked token handling mechanism
- Removed custom token calculation logic
- Direct max_tokens passing to LLM APIs
- Added support for DeepSeek provider
- Integrated deepseek-chat and deepseek-reasoner models

Thanks to @estiens for reporting token handling issues and providing valuable feedback (https://github.com/smkrv/ha-text-ai/issues/1).
2025-01-28 15:54:48 +03:00
SMKRV 82e1f0c4f9 Release v2.1.0 2024-12-13 00:06:08 +03:00
SMKRV 5c16eee6e4 fix: Read version from manifest.json 2024-12-12 16:03:15 +03:00
SMKRV e988d445a4 - Fixed version reading from manifest.json implementation 2024-12-11 22:01:01 +03:00
SMKRV f9bfb9ab7f fix: correct sw_version syntax in device_info
- Fixed version reading from manifest.json implementation
2024-12-11 21:57:27 +03:00
SMKRV 92dd1bc110 ~ 2024-12-11 00:00:12 +03:00
SMKRV 17d547325a refactor(docs): updated README services examples with more detailed configuration 2024-12-10 23:33:32 +03:00
SMKRV b8cb70217c refactor(docs): updated README shields 2024-12-10 23:27:49 +03:00
SMKRV 530d04f25d refactor(docs): updated README shields 2024-12-10 23:27:18 +03:00
SMKRV b71083b9bf bump to version 2.0.9 2024-12-10 17:25:47 +03:00
SMKRV f9f7d10f7f refactor(docs): updated README images 2024-12-10 17:21:41 +03:00
SMKRV 9f7cb20621 refactor(docs): updated README images 2024-12-10 17:19:52 +03:00
SMKRV 6fc3b23365 refactor(docs): updated README images 2024-12-10 17:18:55 +03:00
SMKRV 15c717fcb0 fix: Display only last Q&A in sensor state to prevent data truncation
- Show only the latest question and answer in sensor state
- Keep full conversation history in attributes
- Fix truncation issues in Home Assistant UI
- Maintain backwards compatibility
- No configuration changes required
2024-12-10 17:02:48 +03:00
SMKRV be06fddce1 fix: Display only last Q&A in sensor state to prevent data truncation
- Show only the latest question and answer in sensor state
- Keep full conversation history in attributes
- Fix truncation issues in Home Assistant UI
- Maintain backwards compatibility
- No configuration changes required
2024-12-10 16:19:17 +03:00
SMKRV 5f0bd861a7 docs(readme): Included note that the integration has been submitted to HACS store and is currently pending review in pull request: [pull request #2896](hacs/default#2896). 2024-12-10 00:00:18 +03:00
SMKRV 428aee46c8 docs(readme): Included note that the integration has been submitted to HACS store and is currently pending review in pull request: [pull request #2896](hacs/default#2896). 2024-12-09 23:59:51 +03:00
SMKRV 561bcf0b1d docs(Code of Conduct): Add Code of Conduct to promote community guidelines
- Implement Contributor Covenant Code of Conduct v1.4
- Establish clear expectations for community interactions
- Define standards of acceptable and unacceptable behavior
- Provide framework for reporting and addressing issues
- Emphasize inclusivity and respect for all contributors
2024-12-09 16:52:44 +03:00
SMKRV 2b1e42c665 docs(readme): Included note that the integration has been submitted to HACS store and is currently pending review in pull request: [pull request #2893](https://github.com/hacs/default/pull/2893). 2024-12-09 15:29:53 +03:00
SMKRV 2d68f29ab5 bump to version 2.0.8 2024-12-09 00:41:28 +03:00
SMKRV 19d4a93bca bump to version 2.0.8 2024-12-09 00:41:08 +03:00
SMKRV fb75c5f44e bump to version 2.0.9 2024-12-09 00:39:28 +03:00
SMKRV 52af987985 bump to version 2.0.9 2024-12-09 00:38:59 +03:00
SMKRV 162a30acdd bump to version 2.0.8-beta 2024-12-07 00:48:17 +03:00
SMKRV 898a6fc638 bump to version 2.0.8-beta 2024-12-07 00:43:53 +03:00
SMKRV 38e2362be4 bump to version 2.0.8-beta 2024-12-06 16:51:32 +03:00
SMKRV cea912d0b5 bump to version 2.0.8-beta 2024-12-06 16:51:09 +03:00
SMKRV 5ec00040c2 bump to version 2.0.8-beta 2024-12-06 16:50:26 +03:00
SMKRV 8d28fc4a0d bump to version 2.0.8-beta 2024-12-06 16:46:11 +03:00
SMKRV 7cc6587724 bump to version 2.0.8-beta 2024-12-06 16:35:26 +03:00
SMKRV bacf76e0a9 bump to version 2.0.8-beta 2024-12-06 16:33:46 +03:00
SMKRV f86c7bfd57 refactor(docs): relocate README images from misc/ to assets/ 2024-12-06 16:13:27 +03:00
SMKRV 2aaf340575 refactor(docs): relocate README images from misc/ to assets/ 2024-12-06 16:11:13 +03:00
SMKRV bae11ba85c refactor(docs): relocate README images from misc/ to assets/ 2024-12-06 16:10:04 +03:00
SMKRV 9eb7d8912c fix: sensor history attribute calculation 2024-12-06 12:26:56 +03:00
SMKRV d29535245f Release v2.0.7-beta 2024-12-06 12:00:23 +03:00
SMKRV ca3ae982b0 feat(performance): Optimize system resources and token estimation
- Improve JSON history file processing
- Add memory and disk space validation
- Enhance parallel request handling
- Refine token counting heuristics
2024-12-06 03:14:52 +03:00
SMKRV 0c4399b46c feat(performance): Optimize system resources and token estimation
- Improve JSON history file processing
- Add memory and disk space validation
- Enhance parallel request handling
- Refine token counting heuristics
2024-12-06 02:53:41 +03:00
SMKRV c13ef1921d feat(performance): Optimize system resources and token estimation
- Improve JSON history file processing
- Add memory and disk space validation
- Enhance parallel request handling
- Refine token counting heuristics
2024-12-06 02:51:06 +03:00
SMKRV 0fb9acfa8f docs:(README) 2024-12-05 02:00:24 +03:00
SMKRV 2ac4389e1a docs:(README) 2024-12-05 01:55:31 +03:00
SMKRV de194e425c docs:(README) 2024-12-05 01:54:09 +03:00
SMKRV 95f3d6506e docs:(README) 2024-12-05 01:49:26 +03:00
SMKRV 3e877243b9 docs:(README) 2024-12-05 01:46:28 +03:00
SMKRV fd795c9e90 docs:(readme) 2024-12-05 00:36:08 +03:00
SMKRV 5f5dc041b9 docs: structure 2024-12-04 23:32:05 +03:00
SMKRV 3d3885d43d Update README.md 2024-12-04 23:28:14 +03:00
smkrvandGitHub b059744716 Update README.md 2024-12-04 23:23:46 +03:00
SMKRV f2a41aaa2c docs: logo 2024-12-04 20:13:08 +03:00
SMKRV 1fcf751d0d docs: logo 2024-12-04 19:24:30 +03:00
SMKRV a45c9407fd docs: logo 2024-12-04 19:19:40 +03:00
SMKRV 21fdf108cf docs: logo 2024-12-04 19:17:09 +03:00
SMKRV c7ec2b3ea4 docs: logo 2024-12-04 19:13:57 +03:00
SMKRV 6c4da6cea5 docs: logo 2024-12-04 19:12:44 +03:00
SMKRV 3029c5dd26 docs: logo 2024-12-04 19:12:21 +03:00
SMKRV 3e97028094 docs: logo 2024-12-04 19:11:54 +03:00
SMKRV 1194c87134 docs: logo 2024-12-04 17:51:40 +03:00
SMKRV e03b5315c2 logo update 2024-12-04 17:51:02 +03:00
SMKRV fad887492f docs: logo 2024-12-04 17:48:04 +03:00
SMKRV f26df29937 docs: logo 2024-12-04 17:47:12 +03:00
SMKRV 69b54a08ba logo update 2024-12-04 17:42:32 +03:00
SMKRV 7ce77eb18e docs: logo 2024-12-04 17:36:18 +03:00
SMKRV 1480669dc8 icons update 2024-12-04 17:34:19 +03:00
SMKRV f397bbffe7 Release 2.0.5-beta 2024-12-03 18:29:24 +03:00
SMKRV 5709bce1ae docs(license): Switch to CC BY-NC-SA 4.0 2024-11-29 17:13:08 +03:00
SMKRV d549a36c3a docs(license): Switch to CC BY-NC-SA 4.0 2024-11-29 17:11:06 +03:00
SMKRV e9ba480e95 docs: Update to beta 2024-11-29 16:57:32 +03:00
SMKRV 8ac7154399 docs: Update to beta 2024-11-29 16:54:41 +03:00
SMKRV f25f2db885 Release v2.0.4-beta 2024-11-29 16:45:43 +03:00
SMKRV 621732ae0a feat(localization): Expand multilingual support
- Added translations for:
  * Chinese (zh)
  * Serbian (sr)
  * Italian (it)
  * Hindi (hi)
  * Spanish (es)

- Fixed minor bugs
- Improved language coverage
2024-11-29 16:44:21 +03:00
SMKRV ed85c659be docs(license): Switch to CC BY-NC-SA 4.0 2024-11-29 16:38:41 +03:00
SMKRV f6dcd1c382 docs(license): Switch to CC BY-NC-SA 4.0 2024-11-29 16:27:11 +03:00
SMKRV 37c572fd98 docs(license): Switch to CC BY-NC-SA 4.0 2024-11-29 16:27:04 +03:00
SMKRV 1ff709d05c docs: screenshots 2024-11-29 01:27:01 +03:00
SMKRV 83726feae1 Screenshots added 2024-11-29 01:24:57 +03:00
SMKRV f24c87cc51 Screenshots 2024-11-29 01:23:43 +03:00
SMKRV 1c40968a94 misc 2024-11-29 00:57:39 +03:00
SMKRV c37ec7c1dd Release v2.0.3-beta 2024-11-29 00:54:18 +03:00
SMKRV ff3e600302 Misc 2024-11-29 00:21:21 +03:00
SMKRV 3d064f5b9d Release v2.0.3-beta 2024-11-29 00:15:27 +03:00
SMKRV ae8f8bda03 Release v2.0.2-beta 2024-11-29 00:01:27 +03:00
SMKRV 2dbae19c41 Release v2.0.2-beta 2024-11-28 23:59:02 +03:00
SMKRV d3ef31f551 Release v2.0.2-beta 2024-11-28 23:27:39 +03:00
SMKRV ff0c0369e8 YAML configuration explained 2024-11-27 18:03:30 +03:00
SMKRV b0dafe081b YAML configuration explained 2024-11-27 16:58:00 +03:00
SMKRV a88b5d01c1 Vesion updated 2024-11-27 16:52:49 +03:00
SMKRV e7d5e62671 YAML configuration explained 2024-11-27 16:43:20 +03:00
SMKRV 5f41b9489d YAML configuration explained 2024-11-27 16:34:55 +03:00
SMKRV 958e241e0e YAML configuration explained 2024-11-27 16:30:58 +03:00
smkrvandGitHub 4b3efc0b6f Update README_RU.md 2024-11-27 01:36:11 +03:00
smkrvandGitHub 1592fc2371 Delete socia_logo.png 2024-11-26 23:50:38 +03:00
smkrvandGitHub d5a6613428 Update README.md 2024-11-26 18:00:45 +03:00
SMKRV 9324473c9e Banner chaged 2024-11-26 17:49:08 +03:00
SMKRV 456e797cca Banner changed 2024-11-26 17:47:49 +03:00
SMKRV f960b9f9b4 Misc 2024-11-26 17:47:17 +03:00
SMKRV 8449b73423 Banner changed 2024-11-26 17:46:51 +03:00
SMKRV f19ba9aac9 Banner changed 2024-11-26 17:45:59 +03:00
SMKRV 524849f6a9 Logo 2024-11-26 17:44:13 +03:00
SMKRV 0a64a9abe0 Misc 2024-11-26 16:52:35 +03:00
SMKRV adb4127f5a Markdown changes 2024-11-26 16:38:56 +03:00
SMKRV f463593180 Markdown changes 2024-11-26 16:35:49 +03:00
SMKRV e53f257977 Markdown changes 2024-11-26 16:31:44 +03:00
SMKRV 87199e856a markdown changes 2024-11-26 16:27:50 +03:00
SMKRV bcf7cfbf76 Markdown changes 2024-11-26 16:25:22 +03:00
SMKRV 9289e1388f Markdown changes 2024-11-26 16:23:35 +03:00
SMKRV 84db9b7bb8 Markdown changes 2024-11-26 16:22:39 +03:00
SMKRV bd1098a181 Markdown changes 2024-11-26 16:22:27 +03:00
SMKRV 6fd3db0063 Markdown changes 2024-11-26 16:21:56 +03:00
SMKRV bf52217cd5 Markdown changes 2024-11-26 16:21:45 +03:00
SMKRV b0cf5b3c61 Markdown changes 2024-11-26 16:21:22 +03:00
SMKRV 347c1675ca Markdown changes 2024-11-26 16:20:07 +03:00
SMKRV 961ae2d34f Markdown changes 2024-11-26 16:19:45 +03:00
SMKRV 14827c3adc Markdown changes 2024-11-26 15:45:19 +03:00
SMKRV 4a88453abc Markdown changes 2024-11-26 15:44:47 +03:00
SMKRV bc33d38f5e Markdown changes 2024-11-26 15:42:52 +03:00
SMKRV 657a5ede86 Markdown changes 2024-11-26 15:41:46 +03:00
SMKRV dd046fe3e8 Markdown changes 2024-11-26 15:40:02 +03:00
SMKRV 7391dbf5b1 Markdown changes 2024-11-26 15:37:06 +03:00
SMKRV a0ccb86bd4 Markdown changes 2024-11-26 15:36:06 +03:00
SMKRV c0eb5b165d Vesrion changed 2024-11-26 15:31:34 +03:00
SMKRV 9812babc7e Русский перевод README 2024-11-26 15:31:06 +03:00
SMKRV c61c4570b0 Русский перевод README 2024-11-26 15:30:07 +03:00
SMKRV d618feffff docs(readme): Enhance attribute descriptions with detailed English comments
- Add comprehensive explanations for HA Text AI sensor attributes
- Improve readability of README.md documentation
- Provide context and usage details for each sensor attribute
- Translate comments to English with technical clarity

Changes include:
* Detailed descriptions for Model and Provider Information
* Expanded System Status attribute explanations
* Clarified Performance Metrics comments
* Added context for Conversation and Token Usage
* Improved Last Interaction Details descriptions
* Enhanced System Health attribute documentation
2024-11-26 15:01:40 +03:00
SMKRV 0fdc3c93d3 Release v2.0.0-alpha 2024-11-26 14:02:37 +03:00
SMKRV d6e76f7805 Validate HACS 2024-11-26 13:16:10 +03:00
SMKRV b05afe1085 Release v2.0.0-alpha 2024-11-26 02:08:55 +03:00
SMKRV 2a4911f5f8 Release v2.0.0-alpha 2024-11-26 02:04:23 +03:00
SMKRV 208074d845 Release v2.0.0-alpha 2024-11-26 01:20:08 +03:00
SMKRV 616ff2c3fe 💡 Support the Project 2024-11-26 00:14:26 +03:00
SMKRV 987c939956 Support the Project 2024-11-26 00:12:43 +03:00
SMKRV 1615cc744e Support the Project 2024-11-26 00:12:10 +03:00
SMKRV 2d793a4b25 Support the Project 2024-11-26 00:11:55 +03:00
SMKRV aeeb4d5504 Support the Project 2024-11-26 00:09:46 +03:00
SMKRV daec801073 Misc 2024-11-25 23:50:33 +03:00
SMKRV e4916a7b7c Misc 2024-11-25 23:49:42 +03:00
SMKRV a08abd76e3 Misc 2024-11-25 23:48:55 +03:00
SMKRV a2be709608 Misc 2024-11-25 23:48:16 +03:00
SMKRV 69002ef926 Misc 2024-11-25 23:45:28 +03:00
SMKRV 9ade5a7194 misc 2024-11-25 23:43:05 +03:00
SMKRV 9b3f4f605b Sensor Attributes 2024-11-25 17:59:17 +03:00
SMKRV b862968d01 Release v2.0.0-alpha 2024-11-25 17:31:42 +03:00
SMKRV 29f3ae5592 Release v2.0.0 2024-11-25 17:10:38 +03:00
SMKRV 107d2a64fc Release v2.0.0 2024-11-25 17:09:39 +03:00
SMKRV e24bb884ef Release v2.0.0 2024-11-25 17:08:23 +03:00
SMKRV 107a2ef962 Release v2.0.0 2024-11-25 17:04:53 +03:00
SMKRV 9d58f2cf1e Release v2.0.0 2024-11-25 17:04:18 +03:00
SMKRV 29f6860fe1 Release v2.0.0 2024-11-25 17:03:29 +03:00
SMKRV fa89026e05 Release v2.0.0 2024-11-25 17:00:29 +03:00
SMKRV 9968452c46 Release v2.0.0 2024-11-25 16:57:14 +03:00
SMKRV 9665634013 Release v2.0.0 2024-11-25 16:55:12 +03:00
SMKRV 76d10ba8fb Release v2.0.0 2024-11-25 16:54:42 +03:00
SMKRV e9ea10203e Release v2.0.0 2024-11-25 16:54:07 +03:00
SMKRV 92a4c2da02 Release v2.0.0 2024-11-25 16:53:38 +03:00
SMKRV b6d8eb98f6 Release v2.0.0 2024-11-25 16:52:20 +03:00
SMKRV b6b01bccd7 Release v2.0.0 2024-11-25 16:51:42 +03:00
SMKRV ace2339b4f Release v2.0.0 2024-11-25 16:46:27 +03:00
SMKRV 166c1f9c9c Release v2.0.0 2024-11-25 16:45:43 +03:00
SMKRV e4039a08bc Release v2.0.0 2024-11-25 16:45:05 +03:00
SMKRV 1da5b5941d Release v2.0.0 2024-11-25 16:44:14 +03:00
SMKRV 6683f12c80 Release v2.0.0 2024-11-25 16:42:51 +03:00
SMKRV 2277f48e46 Release v2.0.0 2024-11-25 16:40:55 +03:00
SMKRV d206bde15a Release v2.0.0 2024-11-25 16:37:57 +03:00
SMKRV af16d03915 Release v2.0.0 2024-11-25 16:34:36 +03:00
SMKRV bf26cd3cfb Release v2.0.0 2024-11-25 16:25:58 +03:00
SMKRV 094062773a Release v2.0.0 2024-11-25 16:18:54 +03:00
SMKRV 4cd95813bc Release v2.0.0 2024-11-25 15:51:08 +03:00
SMKRV c2064f0b64 Release v2.0.0 2024-11-25 15:42:04 +03:00
SMKRV fafd927610 Release v2.0.0 2024-11-25 14:59:44 +03:00
SMKRV 7f46380054 Release v2.0.0 2024-11-25 02:05:13 +03:00
SMKRV 351a8b18dd Release v2.0.0 2024-11-25 02:03:29 +03:00
SMKRV 39833b333f Release v2.0.0 2024-11-25 01:52:37 +03:00
SMKRV 888a41375b Release v2.0.0 2024-11-25 01:43:06 +03:00
SMKRV 823abb22e4 Release v2.0.0 2024-11-25 01:20:44 +03:00
SMKRV a3c88309b4 Release v2.0.0 2024-11-25 01:10:32 +03:00
SMKRV beebc7e194 Release v2.0.0 2024-11-25 00:58:02 +03:00
SMKRV 3f8f22ac61 Release v2.0.0 2024-11-25 00:50:44 +03:00
SMKRV 12c95d0e92 Release v2.0.0 2024-11-25 00:08:07 +03:00
SMKRV b00f600cd9 Release v2.0.0 2024-11-25 00:05:15 +03:00
SMKRV a4925fc943 Release v2.0.0 2024-11-24 23:43:36 +03:00
SMKRV 28248ac3c4 Release v2.0.0 2024-11-24 23:23:37 +03:00
SMKRV d2b5626977 Release v2.0.0 2024-11-24 23:17:41 +03:00
SMKRV b61429b52d Release v2.0.0 2024-11-24 23:14:48 +03:00
SMKRV d05b39d8ae Release v2.0.0 2024-11-24 22:49:11 +03:00
SMKRV 6b3b0f1bd6 Release v2.0.0 2024-11-24 21:38:22 +03:00
SMKRV feb679ae77 Release v2.0.0 2024-11-24 20:17:01 +03:00
SMKRV 9779e5552d Release v2.0.0 2024-11-24 20:12:03 +03:00
SMKRV fbd187dc29 Release v2.0.0 2024-11-24 19:37:01 +03:00
SMKRV e8b6116439 Release v2.0.0 2024-11-24 19:24:30 +03:00
SMKRV fcd3e79cb7 Release v2.0.0 2024-11-24 18:36:37 +03:00
SMKRV d03078cfd4 Release v2.0.0 2024-11-24 17:55:39 +03:00
SMKRV b94d859849 Release v2.0.0 2024-11-24 17:46:39 +03:00
SMKRV dc4fcdf578 Release v2.0.0 2024-11-24 17:41:48 +03:00
SMKRV da4c40017d Release v2.0.0 2024-11-24 17:34:51 +03:00
SMKRV ac420b6495 Release v2.0.0 2024-11-24 17:27:49 +03:00
SMKRV 5791601c7e Release v2.0.0 2024-11-24 17:09:23 +03:00
SMKRV eb149184c3 Release v2.0.0 2024-11-24 16:56:39 +03:00
SMKRV d410073c64 Release v2.0.0 2024-11-24 16:45:03 +03:00
SMKRV b373f6c513 Release v2.0.0 2024-11-24 16:29:57 +03:00
SMKRV 18395a2265 Release v2.0.0 2024-11-24 13:52:27 +03:00
SMKRV 2e4c63ba7d Release v2.0.0 2024-11-24 13:51:04 +03:00
SMKRV 9fdf7c4642 Release v2.0.0 2024-11-24 03:03:01 +03:00
SMKRV 053a9050b6 Release v2.0.0 2024-11-24 02:58:04 +03:00
SMKRV 7efabdfa70 Release v2.0.0 2024-11-24 02:52:41 +03:00
SMKRV e5077969e9 Release v2.0.0 2024-11-24 02:32:08 +03:00
SMKRV 2688da5a82 Release v2.0.0 2024-11-24 02:20:29 +03:00
SMKRV 4f46d077df Release v2.0.0 2024-11-24 01:54:09 +03:00
SMKRV bde856c576 Release v2.0.0 2024-11-24 01:18:09 +03:00
SMKRV 083cb9f730 Release v2.0.0 2024-11-24 01:03:51 +03:00
SMKRV 2644d720e7 Release v2.0.0 2024-11-24 01:01:51 +03:00
SMKRV 2fe84ab801 Release v2.0.0 2024-11-24 00:56:33 +03:00
SMKRV ca1d79f848 Release v2.0.0 2024-11-24 00:29:45 +03:00
SMKRV e52572beaa Release v2.0.0 2024-11-24 00:16:27 +03:00
SMKRV 0f77a98d76 Release v2.0.0 2024-11-23 23:46:55 +03:00
SMKRV ebede3d56b Release v2.0.0 2024-11-23 23:42:33 +03:00
SMKRV 1692f5519f Release v2.0.0 2024-11-23 23:33:53 +03:00
SMKRV dad1aa1c45 Release v2.0.0 2024-11-23 23:30:39 +03:00
SMKRV 5d2244db6e Release v2.0.0 2024-11-23 23:21:32 +03:00
SMKRV 8a15cfe4b4 Release v2.0.0 2024-11-23 23:17:41 +03:00
SMKRV 500c7fbe30 Release v2.0.0 2024-11-23 21:55:34 +03:00
SMKRV 894b600b09 Release v2.0.0 2024-11-23 21:39:28 +03:00
SMKRV 97f0b30cd6 Release v2.0.0 2024-11-23 21:31:27 +03:00
SMKRV 9855e8a561 Release v2.0.0 2024-11-23 20:12:10 +03:00
SMKRV c2b259ade1 Release v2.0.0 2024-11-23 19:51:21 +03:00
SMKRV cfc185117c Release v2.0.0 2024-11-23 19:42:10 +03:00
SMKRV 9e89920e79 Release v2.0.0 2024-11-23 19:03:06 +03:00
SMKRV af190da333 Misc 2024-11-23 18:59:27 +03:00
SMKRV 3e3ec45b19 Release v2.0.0 2024-11-23 18:58:30 +03:00
SMKRV f0fe593d78 Release v2.0.0 2024-11-23 18:57:02 +03:00
SMKRV 8646118a27 Release v2.0.0 2024-11-23 18:50:48 +03:00
SMKRV 31f21b3a6b Release v2.0.0 2024-11-23 03:06:13 +03:00
SMKRV 12d87e30e1 Release v2.0.0 2024-11-23 03:02:35 +03:00
SMKRV 6ecc3f72d1 Release v2.0.0 2024-11-23 02:21:10 +03:00
SMKRV e8c40dc6b8 Release v2.0.0 2024-11-23 01:45:53 +03:00
SMKRV 0279517a42 Release v2.0.0 2024-11-23 01:34:05 +03:00
SMKRV 072eab1703 Release v2.0.0 2024-11-23 01:21:42 +03:00
SMKRV 3b38a6dd29 Release v2.0.0 2024-11-23 01:09:38 +03:00
SMKRV 7f8d8be5fb Release v2.0.0 2024-11-23 00:59:09 +03:00
SMKRV 3aa6b6a2ef Release v2.0.0 2024-11-23 00:51:57 +03:00
SMKRV d870cfbba6 Release v2.0.0 2024-11-23 00:36:58 +03:00
SMKRV 13a9e1a5d7 Release v2.0.0 2024-11-22 19:18:10 +03:00
SMKRV 06aba7e692 Release v2.0.0 2024-11-22 18:59:10 +03:00
SMKRV c73ff02bfb Release v2.0.0 2024-11-22 18:48:19 +03:00
SMKRV ed03170817 Release v2.0.0 2024-11-22 18:46:22 +03:00
SMKRV 3079994a77 Release v2.0.0 2024-11-22 18:44:38 +03:00
SMKRV 41b37f9edf Release v2.0.0 2024-11-22 18:41:40 +03:00
SMKRV 8d65d3ef4e Release v2.0.0 2024-11-22 18:39:29 +03:00
SMKRV 357c8b8be4 Release v2.0.0 2024-11-22 18:37:55 +03:00
SMKRV a47b343f93 Release v2.0.0 2024-11-22 18:16:15 +03:00
SMKRV 4388e0f2e1 Release v2.0.0 2024-11-22 17:55:46 +03:00
SMKRV f2adff1d85 Release v2.0.0 2024-11-22 17:45:41 +03:00
SMKRV ad51da7950 Release v2.0.0 2024-11-22 17:31:48 +03:00
SMKRV 7610a71829 Release v2.0.0 2024-11-22 17:20:07 +03:00
SMKRV 8800f226d2 Release v2.0.0 2024-11-22 17:19:40 +03:00
SMKRV ba16932b44 Release v2.0.0 2024-11-22 17:10:42 +03:00
SMKRV 81394345b4 Misc 2024-11-22 17:02:56 +03:00
SMKRV 72621d9d0e Release v2.0.0 2024-11-22 16:58:22 +03:00
SMKRV d35ded6502 Release v2.0.0 2024-11-22 16:46:40 +03:00
SMKRV b17e5c3db2 Release v2.0.0 2024-11-22 16:29:26 +03:00
SMKRV 365c4df2a4 Release v2.0.0 2024-11-22 16:16:58 +03:00
SMKRV ab79d05e96 Release v2.0.0 2024-11-22 16:08:33 +03:00
SMKRV a208004fe0 Release v2.0.0 2024-11-22 15:57:13 +03:00
SMKRV 51c714df25 Release v2.0.0 2024-11-22 15:49:47 +03:00
SMKRV 87b45df180 Release v2.0.0 2024-11-22 15:44:45 +03:00
SMKRV accb15a92a Release v2.0.0 2024-11-22 15:38:29 +03:00
SMKRV a421825050 Release v2.0.0 2024-11-22 15:25:01 +03:00
SMKRV c55112c8f7 Release v2.0.0 2024-11-22 15:08:45 +03:00
SMKRV accbc5635e Release v2.0.0 2024-11-22 13:51:23 +03:00
SMKRV 558ff7d141 Release v2.0.0 2024-11-22 13:39:01 +03:00
SMKRV fa70a0a4ab Release v2.0.0 2024-11-22 12:28:49 +03:00
SMKRV 50b414a904 Release v2.0.0 2024-11-22 12:23:28 +03:00
SMKRV 80c91039e1 Release v2.0.0 2024-11-22 12:17:06 +03:00
SMKRV 966e01e7e2 Release v2.0.0 2024-11-22 12:02:26 +03:00
SMKRV 82ffce1e25 Release v2.0.0 2024-11-22 11:50:47 +03:00
SMKRV c7257cc00a Release v2.0.0 2024-11-22 11:39:43 +03:00
SMKRV ab877c2e9a Release v2.0.0 2024-11-22 11:39:27 +03:00
SMKRV c6dcf307fd Release v2.0.0 2024-11-22 11:38:14 +03:00
SMKRV 30d69e7ed1 Release v2.0.0 2024-11-22 11:36:34 +03:00
SMKRV bb8195c0d1 Release v2.0.0 2024-11-22 10:16:46 +03:00
SMKRV a813302e86 Release v2.0.0 2024-11-22 02:45:31 +03:00
SMKRV 0f643664f7 Release v2.0.0 2024-11-22 02:39:40 +03:00
SMKRV 665537cb6a Release v2.0.0 2024-11-22 02:34:46 +03:00
SMKRV 766b9293cf Release v2.0.0 2024-11-22 02:34:05 +03:00
SMKRV 6e014e30d9 Release v2.0.0 2024-11-22 02:33:34 +03:00
SMKRV 30fc8ad1df Release v2.0.0 2024-11-22 02:33:07 +03:00
SMKRV bcad939a3d Release v2.0.0 2024-11-22 02:32:18 +03:00
SMKRV e153df85fb Release v2.0.0 2024-11-22 02:31:02 +03:00
SMKRV 26908b5d81 Release v2.0.0 2024-11-22 02:30:17 +03:00
SMKRV 0ddca8ff58 Release v2.0.0 2024-11-22 02:28:03 +03:00
SMKRV fd285d969e Release v2.0.0 2024-11-22 02:25:27 +03:00
SMKRV 10bebe5eb5 Release v2.0.0 2024-11-22 02:24:45 +03:00
SMKRV fef5e81033 Release v2.0.0 2024-11-22 02:23:04 +03:00
SMKRV a325ae180a Release v2.0.0 2024-11-22 02:20:10 +03:00
SMKRV df1ae9cd55 Release v2.0.0 2024-11-22 02:04:20 +03:00
SMKRV d5380b4195 Release v2.0.0 2024-11-22 02:03:24 +03:00
SMKRV 9e12ada4fa Release v2.0.0 2024-11-22 02:03:06 +03:00
SMKRV bb3240f1b3 Release v2.0.0 2024-11-22 02:00:42 +03:00
SMKRV 7780ac89ef Release v2.0.0 2024-11-22 01:57:21 +03:00
SMKRV c58eae695e Release v2.0.0 2024-11-22 01:56:36 +03:00
SMKRV 45244dcaa9 Release v2.0.0 2024-11-22 01:43:00 +03:00
SMKRV 53b15fa74c Release v2.0.0 2024-11-22 01:34:47 +03:00
SMKRV 9341b02f4b Release v2.0.0 2024-11-21 19:21:04 +03:00
SMKRV 158db522a8 Release v2.0.0 2024-11-21 19:16:21 +03:00
SMKRV f3b76c0bc1 Release v2.0.0 2024-11-21 19:04:28 +03:00
SMKRV d11f961566 Release v2.0.0 2024-11-21 18:55:11 +03:00
SMKRV 9d7f81d042 Release v2.0.0 2024-11-21 18:30:35 +03:00
SMKRV c95e1a829c Release v2.0.0 2024-11-21 18:24:39 +03:00
SMKRV 7bd06e7b88 Release v2.0.0 2024-11-21 18:17:57 +03:00
SMKRV 6e64b6feac Release v2.0.0 2024-11-21 18:12:33 +03:00
SMKRV f6c0e6265e Release v2.0.0 2024-11-21 18:05:45 +03:00
SMKRV 662ce701ca Release v2.0.0 2024-11-21 18:00:33 +03:00
SMKRV 72bbfb3f58 Release v2.0.0 2024-11-21 17:30:59 +03:00
SMKRV 9306c12fcd Release v2.0.0 2024-11-21 17:18:13 +03:00
SMKRV 9f1ea70c9f Release v2.0.0 2024-11-21 17:06:19 +03:00
SMKRV 98913b359a Release v2.0.0 2024-11-21 16:40:55 +03:00
SMKRV a4905f0778 Release v2.0.0 2024-11-21 16:17:51 +03:00
SMKRV e603231633 Release v2.0.0 2024-11-21 15:35:52 +03:00
SMKRV 345463322a Release v2.0.0 2024-11-21 15:15:50 +03:00
SMKRV c298866e3c Release v2.0.0 2024-11-21 14:55:27 +03:00
SMKRV 75e97ac652 Release v2.0.0 2024-11-21 14:28:17 +03:00
SMKRV 149ec16d57 Release v2.0.0 2024-11-21 13:56:22 +03:00
SMKRV 31e30d94aa Release v2.0.0 2024-11-20 14:29:17 +03:00
SMKRV 326f876410 Handles the VSE GPT API endpoint correctly 2024-11-20 12:23:26 +03:00
SMKRV 58a7ae4229 Resoved blocking SSL verification issue in
coordinator.py

 API endpoint handling in config_flow.py
 changes

 Added support for the custom models in const.py

 Requirements in manifest.json updated
2024-11-20 12:07:30 +03:00
SMKRV 8f796ad9dc Retry constants 2024-11-20 01:40:29 +03:00
SMKRV 8be860007a Retry constants
MAX_RETRIES: Final = 3
  RETRY_DELAY: Final = 1.0
2024-11-20 01:39:19 +03:00
SMKRV 1985e201b4 Updated 2024-11-20 01:29:14 +03:00
SMKRV 4e4b41661b Release v2.0.0 2024-11-20 01:25:01 +03:00
SMKRV b24a99a11f Release v2.0.0 2024-11-20 01:24:44 +03:00
SMKRV 69b151f990 Release v2.0.0 2024-11-20 01:11:23 +03:00
SMKRV 2d4eb59d6c Release v2.0.0 2024-11-20 01:09:19 +03:00
SMKRV 5037622fb6 Release v2.0.0 2024-11-20 01:02:27 +03:00
SMKRV 962a089bf4 Stability improvements 2024-11-19 23:38:07 +03:00
SMKRV 4c56565b66 Stability improvements 2024-11-19 23:30:28 +03:00
SMKRV 8432038f09 Stability improvements 2024-11-19 23:21:54 +03:00
SMKRV 929d916d41 Bugfixes 2024-11-19 19:39:05 +03:00
SMKRV 675975d951 Minor changes 2024-11-19 19:31:26 +03:00
SMKRV 524ec87395 Minor changes 2024-11-19 19:30:42 +03:00
SMKRV 94f8193996 last_update_success_time > last_update_success 2024-11-19 19:28:01 +03:00
SMKRV 7c3fcf73c3 Text edits 2024-11-19 19:23:03 +03:00
SMKRV 20bbf89679 Changes 2024-11-19 19:20:52 +03:00
SMKRV 86dca52d07 Small changes 2024-11-19 19:18:16 +03:00
SMKRV 78d1561e74 Markdown 2024-11-19 19:17:04 +03:00
SMKRV 054e4af258 Quick bugfix 2024-11-19 19:15:57 +03:00
SMKRV dc03faa97e docs: update HACS installation for custom
repository

  - Change HACS badge from Default to Custom
  - Add custom repository installation steps
  - Update installation instructions
  - Revise documentation format
2024-11-19 19:03:51 +03:00
SMKRV 9a7635c2ae Release v1.1.0 2024-11-19 18:55:14 +03:00
SMKRV df3d79c20c feat: add multi-provider support,
config improvements
2024-11-19 18:53:51 +03:00
SMKRV d6144be7ed v.1.0.10 2024-11-19 17:49:48 +03:00
SMKRV 9afbb904b3 __init__.py:
added: from .coordinator import HATextAICoordinator
2024-11-19 17:48:00 +03:00
SMKRV 93558b2444 Version update 2024-11-19 17:34:27 +03:00
SMKRV 9f93f1ee18 Main changes:
Removed the validate_endpoint function
Optimized the validate_api_connection function
Simplified API connection verification
Preserved all error handling and retry logic
Improved exception handling
The integration should now correctly verify the
OpenAI API connection without false endpoint_not_available errors.
2024-11-19 17:33:27 +03:00
SMKRV 30a9b53ba1 Markdown changes 2024-11-19 17:18:23 +03:00
SMKRV 5175970d55 structure.md added 2024-11-19 17:16:30 +03:00
SMKRV f6bfbd4a07 Release v1.0.8 2024-11-19 17:02:38 +03:00
SMKRV 4ddb0dc977 Translation fixes 2024-11-19 16:59:42 +03:00
SMKRV 24dc4ac4d4 Hotfix 2024-11-19 16:53:27 +03:00
SMKRV 976f4f16a3 translation fixes 2024-11-19 16:40:56 +03:00
SMKRV 2bdef2b494 Main changes:
Created global SSL_CONTEXT at module level
Removed blocking create_default_context calls from async functions
Optimized aiohttp.ClientSession handling:
Using single connector with SSL context
Session is created once for all requests in validate_api_connection
Improved resource management:
Automatic session closure using context managers
More efficient connection handling
These changes should eliminate the blocking call warning and improve
overall code performance.
2024-11-19 16:33:30 +03:00
SMKRV 42324a793b bufix 2024-11-19 16:25:44 +03:00
SMKRV 30aa894634 Release v1.0.6 2024-11-19 15:10:06 +03:00
50 changed files with 7571 additions and 1192 deletions
+15
View File
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore"
labels:
- "dependencies"
- "github-actions"
groups:
actions:
patterns:
- "*"
+6 -9
View File
@@ -1,5 +1,6 @@
name: Validate with hassfest name: Validate with hassfest
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -25,19 +26,15 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: ⤵️ Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: 🚀 Run hassfest validation - name: Run hassfest validation
uses: home-assistant/actions/hassfest@master uses: home-assistant/actions/hassfest@master
- name: ️ Print hassfest version
if: always()
run: |
echo "Hassfest version: $(hassfest --version)"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
+38
View File
@@ -0,0 +1,38 @@
name: Release
on:
release:
types: [created]
permissions:
contents: write
jobs:
build:
name: Build and upload release asset
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- name: Create zip archive
run: |
cd custom_components
zip -r ../ha_text_ai.zip ha_text_ai \
-x "ha_text_ai/__pycache__/*" \
-x "*.pyc" \
-x "*.pyo" \
-x "*/__pycache__/*" \
-x "*.DS_Store"
- name: Upload release asset
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.release.tag_name }}
files: ha_text_ai.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+19
View File
@@ -0,0 +1,19 @@
name: Validate
permissions:
contents: read
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
validate-hacs:
runs-on: "ubuntu-latest"
steps:
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"
+37 -5
View File
@@ -1,8 +1,40 @@
# Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *.egg-info/
.DS_Store dist/
.env build/
.venv *.egg
# Virtual environments
.venv/
venv/ venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Archives
*.zip
# Home Assistant
.storage/
# Claude Code working files
handoff.*.md
CLAUDE.md
AGENTS.md
GEMINI.md
.claude/
.cursor/
.cursorrules
.windsurfrules
docs/specs/
docs/superpowers/
docs/plans/
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
issue tracker.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+110
View File
@@ -0,0 +1,110 @@
# 🤝 Contributing Guide
We welcome contributions to the HA Text AI project! This document will help you contribute to the project's development.
## 🌟 How to Contribute
### 1. Preparation
1. Fork the Repository
- Go to the repository page on GitHub
- Click the "Fork" button in the top right corner
2. Clone Your Fork
```bash
git clone https://github.com/YOUR_USERNAME/ha-text-ai.git
cd ha-text-ai
```
3. Set Up Remote Repositories
```bash
git remote add upstream https://github.com/smkrv/ha-text-ai.git
```
### 2. Creating a Development Branch
```bash
# Update the main branch
git checkout main
git pull upstream main
# Create a new branch for your feature
git checkout -b feature/short-description-of-changes
```
### 3. Development
- Follow the project's coding standards
- Write clean and understandable code
- Add comments when necessary
- Create unit tests for new functionality
### 4. Committing Changes
```bash
# Add modified files
git add .
# Create a meaningful commit
git commit -m "Feat: Add [short feature description]"
```
### 5. Commit Message Style
Use the following prefixes:
- `Feat:` - new feature
- `Fix:` - bug fixes
- `Docs:` - documentation updates
- `Style:` - formatting changes
- `Refactor:` - code refactoring
- `Test:` - adding tests
- `Chore:` - project maintenance
### 6. Pushing Changes
```bash
# Push changes to your fork
git push origin feature/short-description-of-changes
```
### 7. Creating a Pull Request (PR)
1. Go to your fork on GitHub
2. Click "New Pull Request"
3. Select the base branch `main` of the original repository
4. Fill out the PR description:
- Brief description of changes
- Motivation for changes
- Screenshots (if applicable)
### 8. Review Process
- Project maintainers will review your PR
- There may be comments and requests for changes
- After approval, the PR will be merged
## 🛠 Code Requirements
- Follow PEP 8 for Python
- Write clear and self-documenting code
- Add type hints
- Cover code with tests
## 🐛 Found a Bug?
1. Check existing Issues
2. Create a new Issue with:
- Bug description
- Reproduction steps
- Home Assistant version
- Plugin version
## 📜 License
By contributing to the project, you agree to the [project's license](LICENSE).
## 🤔 Questions?
If you have any questions, create an Issue or contact the project maintainers.
**Thank you for your contribution!** 🎉
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 smkrv Copyright (c) 2024-2026 SMKRV
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+536 -155
View File
@@ -1,115 +1,241 @@
# 🤖 HA Text AI for Home Assistant # HA Text AI for Home Assistant
<div align="center"> <div align="center">
![GitHub release](https://img.shields.io/github/release/smkrv/ha-text-ai.svg?style=flat-square) ![GitHub release](https://img.shields.io/github/v/release/smkrv/ha-text-ai?style=flat-square) ![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai?style=flat-square) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=flat-square)](https://github.com/hacs/integration)
![GitHub downloads](https://img.shields.io/github/downloads/smkrv/ha-text-ai/total.svg?style=flat-square) ![Deutsch](https://img.shields.io/badge/lang-DE-blue?style=flat-square) ![English](https://img.shields.io/badge/lang-EN-blue?style=flat-square) ![Español](https://img.shields.io/badge/lang-ES-blue?style=flat-square) ![हिन्दी](https://img.shields.io/badge/lang-HI-blue?style=flat-square) ![Italiano](https://img.shields.io/badge/lang-IT-blue?style=flat-square) ![Русский](https://img.shields.io/badge/lang-RU-blue?style=flat-square) ![Српски](https://img.shields.io/badge/lang-SR-blue?style=flat-square) ![中文](https://img.shields.io/badge/lang-ZH-blue?style=flat-square)
![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social)
![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai.svg?style=flat-square)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=flat-square)](https://github.com/hacs/integration)
[![Community Forum](https://img.shields.io/badge/Community-Forum-blue.svg?style=flat-square)](https://community.home-assistant.io/t/ha-text-ai-integration)
<img src="https://github.com/smkrv/ha-text-ai/blob/main/custom_components/ha_text_ai/icons/logo%402x.png" alt="HA Text AI" style="width: 50%; max-width: 256px; max-height: 128px; aspect-ratio: 2/1; object-fit: contain;"/>
### Multi-provider LLM integration for [Home Assistant](https://www.home-assistant.io/)
</div> </div>
<p align="center"> <p align="center">
Transform your smart home experience with powerful AI assistance powered by OpenAI's GPT models. Get intelligent responses, automate complex scenarios, and enhance your home automation with natural language processing. Ask OpenAI, Anthropic Claude, DeepSeek and Google Gemini models questions from your automations and scripts. The integration keeps per-instance conversation history, returns full-length responses through response variables, supports structured JSON output, and exposes token, latency and error metrics as sensor attributes.
</p> </p>
--- ---
## 🌟 Features > [!IMPORTANT]
> Community driven: for more details on the integration,
> check out the discussion on the **[Home Assistant Community forum](https://community.home-assistant.io/t/ha-text-ai-transforming-home-automation-through-multi-llm-integration/799741)**
>
> <a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=smkrv&repository=ha-text-ai&category=Integration"><img src="https://my.home-assistant.io/badges/hacs_repository.svg" width="210" height="auto"></a>
>
> [Screenshots](assets/images/screenshots/screenshot.jpg)
- 🧠 **Advanced AI Integration**: ## Features
- Support for latest GPT models
- Context-aware responses
- Multi-turn conversations
- 💬 **Natural Language Control**:
- Control devices using everyday language
- Get detailed explanations and recommendations
- Natural conversation flow
- 📝 **Smart Memory Management**:
- Persistent conversation history
- Context-aware responses
- Customizable history limits
-**Performance Optimized**:
- Efficient token usage
- Rate limit handling
- Response caching
- 🎯 **Advanced Customization**:
- Adjustable response parameters
- Custom system prompts
- Model selection per request
- 🔒 **Enhanced Security**:
- Secure API key storage
- Rate limiting protection
- Error handling
- 🎨 **User Experience**:
- Intuitive configuration UI
- Detailed sensor attributes
- Rich service interface
- 🔄 **Automation Integration**:
- Event-driven responses
- Conditional logic support
- Template compatibility
## 📋 Prerequisites - **Multi-provider support**: OpenAI, Anthropic Claude, DeepSeek, Google Gemini, plus any OpenAI-compatible endpoint
- **Conversation context**: the model sees previous messages; depth is configurable per request (1-20)
- **Response variables**: `ask_question` returns the full response directly to the calling automation, bypassing the 255-character state limit
- **Structured output**: JSON responses matching a schema you provide
- **Per-request overrides**: model, temperature, max_tokens, system prompt, thinking mode
- **Usage metrics**: token counters, latency and success/error statistics as sensor attributes
- **File-based history**: per-instance JSON storage with automatic rotation at 1 MB
- Home Assistant 2023.8.0 or newer #### Translations
- OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys))
- Python 3.9 or newer
- Stable internet connection
## ⚡ Installation | Code | Language | Status |
|------|----------|--------|
| de | Deutsch | Full |
| en | English | Primary |
| es | Español | Full |
| hi | हिन्दी | Full |
| it | Italiano | Full |
| ru | Русский | Full |
| sr | Српски | Full |
| zh | 中文 | Full |
## Prerequisites
- Home Assistant 2024.12.0 or later
- An API key from one of:
- OpenAI ([Get key](https://platform.openai.com/account/api-keys))
- Anthropic ([Get key](https://console.anthropic.com/))
- DeepSeek ([Get key](https://platform.deepseek.com/api_keys))
- OpenRouter ([Get key](https://openrouter.ai/keys))
- Google Gemini ([Get key](https://ai.google.dev/gemini-api/docs/api-key)) thanks to ([@Azzedde](https://github.com/Azzedde))
- Any OpenAI-compatible API provider
## Configuration Options
### Core Configuration Settings
- **API Provider**: OpenAI / Anthropic / DeepSeek / Gemini
- **API Key**: provider-specific authentication
- **Model**: any model your provider offers
- **Temperature**: sampling temperature (0.0-2.0)
- **Max Tokens**: response length cap, passed to the LLM API
- **Request Interval**: minimum delay between API calls (seconds)
- **History Size**: number of conversations to retain
- **Custom API Endpoint**: for OpenRouter, proxies and self-hosted servers
- **Disable Thinking**: turn off model reasoning where the provider supports it
- **Allow Local Network**: permit endpoints on private addresses (needed for local servers like Ollama)
### Recommended Models
#### OpenAI Models
- **GPT-5.6 Sol** - flagship tier for the hardest tasks
- **GPT-5.6 Terra** - mid-tier for high-volume tasks
- **GPT-5.6 Luna** - fastest and cheapest, enough for most home automation queries
#### Anthropic Claude Models
- **Claude Fable 5** - the most capable model for complex tasks
- **Claude Sonnet 5** - balance between quality and cost
- **Claude Haiku 4.5** - the fastest and cheapest option in the lineup
#### DeepSeek Models
- **deepseek-v4-flash** - fast general-purpose model (default)
- **deepseek-v4-pro** - stronger at reasoning and coding
> The legacy model names `deepseek-chat` and `deepseek-reasoner` stop working on 2026-07-24. If your instance still uses one of them, switch the model in the integration options.
#### Google Gemini Models
- **gemini-3.5-flash** - default; Google's strongest currently available model
- **gemini-3.1-pro** - previous flagship, still supported
> Google shut down `gemini-2.0-flash` on 2026-06-01 and retires the 2.5 family on 2026-10-16. If your instance uses one of those, switch the model in the integration options.
<details>
<summary>Potentially Compatible Providers</summary>
Other providers with OpenAI-compatible APIs may work through the custom endpoint option:
- Groq
- Together AI
- Perplexity AI
- Mistral AI
- Local AI servers (like Ollama - enable **Allow Local Network** in the options)
- Custom OpenAI-compatible endpoints
Compatibility is not guaranteed. A provider needs an OpenAI-like REST API with JSON request/response format, standard bearer authentication and similar parameter handling. Check the provider's documentation and make sure your API key has sufficient quota.
</details>
## Installation
### HACS Installation (Recommended) ### HACS Installation (Recommended)
>[!TIP]
>HA Text AI is available in the default HACS repository. You can install it directly through HACS or click the button below to open it there.
<a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=smkrv&repository=ha-text-ai&category=Integration"><img src="https://my.home-assistant.io/badges/hacs_repository.svg" width="170" height="auto"></a>
1. Open HACS in Home Assistant 1. Open HACS in Home Assistant
2. Click the "+" button 2. Search for "HA Text AI"
3. Search for "HA Text AI" 3. Click "Download"
4. Click "Install" 4. Restart Home Assistant
5. Restart Home Assistant
**Alternative Method (Custom Repository):**
If the integration is not found in the default repository:
1. Click "..." in top right corner of HACS
2. Select "Custom repositories"
3. Add repository URL: `https://github.com/smkrv/ha-text-ai`
4. Choose "Integration" as category
5. Click "Download"
### Manual Installation ### Manual Installation
1. Download the latest release 1. Download `ha_text_ai.zip` from the latest release
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory 2. Extract the archive and copy the `ha_text_ai` folder into your `custom_components` directory
3. Restart Home Assistant 3. Restart Home Assistant
4. Add configuration via UI or YAML 4. Add configuration via UI (Settings > Devices & Services > Add Integration)
## ⚙️ Configuration ## Configuration
### Via UI (Recommended) ### Via UI (Recommended)
1. Go to Settings Devices & Services 1. Go to Settings > Devices & Services
2. Click "Add Integration" 2. Click "Add Integration"
3. Search for "HA Text AI" 3. Search for "HA Text AI"
4. Follow the configuration steps 4. Follow the configuration steps
### Via YAML > **Note:** This integration is configured exclusively through the UI (config entries). YAML configuration is not supported.
## Quick Start
After configuration you get one entity per instance, named `sensor.ha_text_ai_<name>`. It is a status sensor: it shows the last response and usage metrics, but you don't type questions into it. Questions go through the `ha_text_ai.ask_question` action, called from Developer Tools, automations, or scripts.
### First question, no YAML
1. Open Developer Tools > Actions (called "Services" in older HA versions).
2. Search for "HA Text AI: Ask Question".
3. Pick your instance, type a question, press "Perform action".
4. The response appears below the form.
### In an automation
1. Go to Settings > Automations & scenes > Create automation.
2. Add a trigger: a button press, a time, a state change.
3. Add action > search "HA Text AI: Ask Question" > fill in the question and pick your instance.
4. To use the reply in a follow-up step, the action needs `response_variable: ai_response`. If the visual editor doesn't show a field for it, open the three-dot menu on that action, choose "Edit in YAML", and add the line at the end.
5. In the next action, `{{ ai_response.response_text }}` holds the full answer, for example as a notification message.
Complete working automations: [Automation Examples](#automation-examples-with-response-variables).
### On a dashboard
The sensor keeps the last question and answer as attributes, so a Markdown card can show them:
```yaml ```yaml
ha_text_ai: type: markdown
api_key: !secret openai_api_key content: >-
model: gpt-3.5-turbo **Q:** {{ state_attr('sensor.ha_text_ai_my_assistant', 'question') }}
temperature: 0.7
max_tokens: 1000 **A:** {{ state_attr('sensor.ha_text_ai_my_assistant', 'response') }}
request_interval: 1.0
api_endpoint: https://api.openai.com/v1 # optional
``` ```
## 🛠️ Available Services The attributes fill in after the first question. They are capped at 2048 characters, so long answers come back complete only via `response_variable`.
## Available Services
### Response Variables
`ask_question` returns its result directly to the calling automation via `response_variable`. The full response text comes back regardless of length (no 255-character truncation), it is available immediately without polling sensor state, and each service call gets its own result, so parallel automations don't overwrite each other.
### ask_question ### ask_question
```yaml ```yaml
service: ha_text_ai.ask_question service: ha_text_ai.ask_question
data: data:
question: "What's the optimal temperature for sleeping?" question: "What's the optimal temperature for sleeping?"
model: "gpt-4o" # optional instance: sensor.ha_text_ai_claude
model: "claude-sonnet-5" # optional, overrides the configured model
temperature: 0.5 # optional temperature: 0.5 # optional
max_tokens: 500 # optional max_tokens: 500 # optional
context_messages: 10 # optional, previous messages to include (1-20, default 5)
system_prompt: "You are a sleep optimization expert" # optional
disable_thinking: true # optional, disable model reasoning for this request
response_variable: ai_response
```
For structured JSON output, add `structured_output` with a schema:
```yaml
service: ha_text_ai.ask_question
data:
question: "Suggest three energy-saving actions for tonight"
instance: sensor.ha_text_ai_gpt
structured_output: true
json_schema: >-
{"type": "object", "properties": {"actions": {"type": "array", "items": {"type": "string"}}}}
response_variable: ai_response
```
#### Response Data Structure
```yaml
# The service returns structured data:
response_text: "The optimal sleeping temperature is 65-68°F (18-20°C)..."
tokens_used: 150
prompt_tokens: 50
completion_tokens: 100
model_used: "claude-sonnet-5"
instance: "sensor.ha_text_ai_claude"
question: "What's the optimal temperature for sleeping?"
timestamp: "2026-07-09T16:57:00.000Z"
success: true
# error and error_type are present only when success is false
``` ```
### set_system_prompt ### set_system_prompt
```yaml ```yaml
service: ha_text_ai.set_system_prompt service: ha_text_ai.set_system_prompt
data: data:
instance: sensor.ha_text_ai_gpt
prompt: | prompt: |
You are a home automation expert focused on: You are a home automation expert focused on:
1. Energy efficiency 1. Energy efficiency
@@ -121,124 +247,350 @@ data:
### clear_history ### clear_history
```yaml ```yaml
service: ha_text_ai.clear_history service: ha_text_ai.clear_history
data:
instance: sensor.ha_text_ai_gpt
``` ```
### get_history ### get_history
```yaml ```yaml
service: ha_text_ai.get_history service: ha_text_ai.get_history
data: data:
limit: 5 # optional limit: 5 # optional, number of conversations to return (values above 200 are clamped); omit to get the full stored history
filter_model: "gpt-4o" # optional, filter by specific AI model
start_date: "2026-02-01" # optional, filter conversations from this date
include_metadata: false # optional, include tokens, response time, etc.
sort_order: "newest" # optional, sort order: "newest" or "oldest"
instance: sensor.ha_text_ai_gpt
response_variable: history_result # entries are in history_result.history
``` ```
## 🔧 Advanced Examples ## Automation Examples with Response Variables
### Smart Energy Management ### Example 1: Smart Home Advice with Direct Response
```yaml ```yaml
automation: automation:
alias: "AI Energy Optimization" - alias: "Get AI Home Advice"
trigger: trigger:
platform: time_pattern - platform: state
hours: "/2" entity_id: input_button.ask_ai_advice
action: action:
- service: ha_text_ai.ask_question - service: ha_text_ai.ask_question
data: data:
question: > question: "What's the best way to optimize energy usage in my home?"
Current power usage: {{ states('sensor.total_power') }}W instance: sensor.ha_text_ai_gpt
Temperature: {{ states('sensor.indoor_temperature') }}°C response_variable: ai_advice
Time: {{ now().strftime('%H:%M') }} - service: notify.mobile_app
Occupancy: {{ states('binary_sensor.occupancy') }} data:
title: "Smart Home Tip"
message: |
{{ ai_advice.response_text }}
Analyze current energy usage and suggest optimizations Tokens used: {{ ai_advice.tokens_used }}
considering comfort and efficiency. Model: {{ ai_advice.model_used }}
temperature: 0.3
max_tokens: 200
- service: notify.mobile_app
data:
message: "{{ states.sensor.ha_text_ai.attributes.response }}"
``` ```
### Contextual Lighting Control ### Example 2: Weather-Based AI Recommendations
```yaml ```yaml
automation: automation:
alias: "AI Lighting Assistant" - alias: "Weather-Based AI Suggestions"
trigger: trigger:
platform: state - platform: numeric_state
entity_id: binary_sensor.motion entity_id: sensor.outdoor_temperature
variables: below: 0
context: > action:
Time: {{ now().strftime('%H:%M') }} - service: ha_text_ai.ask_question
Light Level: {{ states('sensor.illuminance') }} data:
Room: {{ trigger.to_state.attributes.room }} question: |
Activity: {{ states('input_select.current_activity') }} The outdoor temperature is {{ states('sensor.outdoor_temperature') }}°C.
Weather: {{ states('weather.home') }} What should I do to prepare my home for freezing weather?
action: system_prompt: "You are a home maintenance expert. Provide practical, actionable advice."
- service: ha_text_ai.ask_question instance: sensor.ha_text_ai_gpt
data: response_variable: winter_advice
question: > - if:
Based on this context: - condition: template
{{ context }} value_template: "{{ winter_advice.success }}"
then:
- service: persistent_notification.create
data:
title: "Winter Preparation Advice"
message: |
{{ winter_advice.response_text }}
Suggest optimal lighting settings for current conditions. Generated at: {{ winter_advice.timestamp }}
model: gpt-3.5-turbo else:
temperature: 0.4 - service: persistent_notification.create
- service: scene.turn_on data:
data: title: "AI Service Error"
entity_id: > message: "Failed to get winter advice: {{ winter_advice.error }}"
{{ states.sensor.ha_text_ai.attributes.response | regex_findall('scene\.[a-z_]+') | first }}
``` ```
## 📊 Performance Optimization ### Example 3: Multi-Step AI Workflow
```yaml
automation:
- alias: "Multi-Step AI Analysis"
trigger:
- platform: state
entity_id: input_button.analyze_home_status
action:
# Step 1: Get current status analysis
- service: ha_text_ai.ask_question
data:
question: |
Current home status:
- Temperature: {{ states('sensor.indoor_temperature') }}°C
- Humidity: {{ states('sensor.indoor_humidity') }}%
- Energy usage: {{ states('sensor.power_consumption') }}W
### Token Usage Analyze this data and provide insights.
- Use focused system prompts instance: sensor.ha_text_ai_gpt
- Implement response caching response_variable: status_analysis
- Clear history periodically
- Monitor token usage
### Response Time # Step 2: Get recommendations based on analysis
- Adjust request_interval - service: ha_text_ai.ask_question
- Use faster models for simple queries data:
- Implement timeout handling question: |
- Cache frequent responses Based on this analysis: "{{ status_analysis.response_text[:500] }}"
### Memory Management Provide 3 specific actionable recommendations for improvement.
- Set appropriate history limits context_messages: 2 # Include previous conversation
- Clear unused contexts instance: sensor.ha_text_ai_gpt
- Monitor memory usage response_variable: recommendations
- Use efficient data structures
## ❗ Troubleshooting # Step 3: Send the combined report
- service: notify.telegram
data:
title: "Home Analysis Report"
message: |
**Analysis:**
{{ status_analysis.response_text }}
### API Issues **Recommendations:**
- Verify API key validity {{ recommendations.response_text }}
- Check rate limits
- Monitor usage quotas
- Test endpoint accessibility
### Performance Issues **Report Details:**
- Reduce max_tokens - Total tokens used: {{ status_analysis.tokens_used + recommendations.tokens_used }}
- Increase request_interval - Analysis model: {{ status_analysis.model_used }}
- Clear conversation history - Generated: {{ recommendations.timestamp }}
- Check network connectivity ```
### Integration Issues ### Migration from Sensors to Response Variables
- Verify HA version compatibility
- Check component dependencies
- Review log files
- Update configuration
## 📘 FAQ #### Old Method:
```yaml
# Old way: delay-based polling, response truncated by the 255-character state limit
automation:
- alias: "Old AI Response Method"
action:
- service: ha_text_ai.ask_question
data:
question: "Long question here..."
instance: sensor.ha_text_ai_gpt
- delay: "00:00:05" # Wait for sensor update
- service: notify.mobile
data:
message: "{{ state_attr('sensor.ha_text_ai_gpt', 'response')[:255] }}..." # Truncated
```
#### New Method:
```yaml
# New way: full response, available immediately
automation:
- alias: "New AI Response Method"
action:
- service: ha_text_ai.ask_question
data:
question: "Long question here..."
instance: sensor.ha_text_ai_gpt
response_variable: ai_response
- service: notify.mobile
data:
message: "{{ ai_response.response_text }}" # Full response, no truncation
```
### HA Text AI Sensor Naming Convention
#### Naming Rules
- Only lowercase letters (a-z), numbers (0-9) and underscore (_)
- The part after the `sensor.ha_text_ai_` prefix is limited to 50 characters
- No spaces; keep it descriptive but short
#### Sensor Name Structure
```yaml
# Always starts with 'sensor.ha_text_ai_'
# You define only the part after the prefix
sensor.ha_text_ai_YOUR_UNIQUE_SUFFIX
# Examples:
sensor.ha_text_ai_gpt # GPT-based sensor
sensor.ha_text_ai_claude # Claude-based sensor
sensor.ha_text_ai_abc # Custom suffix
```
#### Response Retrieval
```yaml
# Use your specific sensor name
{{ state_attr('sensor.ha_text_ai_gpt', 'response') }}
```
#### Practical Usage
```yaml
automation:
- alias: "AI Response with Custom Sensor"
action:
- service: ha_text_ai.ask_question
data:
question: "Home automation advice"
instance: sensor.ha_text_ai_gpt
- service: notify.mobile
data:
message: >
AI Tip:
{{ state_attr('sensor.ha_text_ai_gpt', 'response') }}
```
### HA Text AI Sensor Attributes
- **Model and provider**: current model, API provider, model used for the last response
- **System status**: processing, rate-limit and endpoint state
- **Performance metrics**: request success/failure counters and latency statistics
- **Token usage**: total, prompt and completion token counters as reported by the provider's API
- **Last interaction**: most recent question, response and timestamp
- **System health**: error counter, maintenance flag, uptime
Attributes may be 0 or empty until the first request completes.
<details>
<summary>Detailed Sensor Attributes</summary>
#### Model and Provider Information
```yaml
# Model currently configured for this instance
{{ state_attr('sensor.ha_text_ai_gpt', 'model') }} # gpt-4o-mini
# Service provider (determines API endpoint and authentication)
{{ state_attr('sensor.ha_text_ai_gpt', 'api_provider') }} # openai
# Model that produced the last response (may differ after a per-request override)
{{ state_attr('sensor.ha_text_ai_gpt', 'last_model') }} # gpt-4o-mini
```
#### System Status
```yaml
# Indicates if a request is currently being processed
{{ state_attr('sensor.ha_text_ai_gpt', 'is_processing') }} # false
# Shows if the API has hit its request rate limit
{{ state_attr('sensor.ha_text_ai_gpt', 'is_rate_limited') }} # false
# Status of the API endpoint being used
{{ state_attr('sensor.ha_text_ai_gpt', 'endpoint_status') }} # ready
```
#### Performance Metrics
```yaml
# Number of successfully completed API requests
{{ state_attr('sensor.ha_text_ai_gpt', 'successful_requests') }} # 42
# Number of API requests that encountered errors
{{ state_attr('sensor.ha_text_ai_gpt', 'failed_requests') }} # 0
# Average / max / min response time, in seconds
{{ state_attr('sensor.ha_text_ai_gpt', 'average_latency') }} # 1.85
{{ state_attr('sensor.ha_text_ai_gpt', 'max_latency') }} # 4.2
{{ state_attr('sensor.ha_text_ai_gpt', 'min_latency') }} # 0.9
```
#### Conversation and Token Usage
```yaml
# Number of entries in the current history file
{{ state_attr('sensor.ha_text_ai_gpt', 'history_size') }} # 12
# Token counters as reported by the provider's API
{{ state_attr('sensor.ha_text_ai_gpt', 'total_tokens') }} # 4520
{{ state_attr('sensor.ha_text_ai_gpt', 'prompt_tokens') }} # 3100
{{ state_attr('sensor.ha_text_ai_gpt', 'completion_tokens') }} # 1420
# Last 3 conversation entries, each truncated to 256 characters
# (full history is available via the get_history service)
{{ state_attr('sensor.ha_text_ai_gpt', 'conversation_history') }} # [...]
```
#### Last Interaction Details
```yaml
# Most recent response, truncated to 2048 characters in the attribute
# (the response_variable path returns the full text)
{{ state_attr('sensor.ha_text_ai_gpt', 'response') }} # Last AI response
# The most recently processed question
{{ state_attr('sensor.ha_text_ai_gpt', 'question') }} # Last asked question
# When the last interaction occurred
{{ state_attr('sensor.ha_text_ai_gpt', 'last_timestamp') }} # Timestamp
```
#### System Health
```yaml
# Cumulative count of errors across all requests
{{ state_attr('sensor.ha_text_ai_gpt', 'total_errors') }} # 0
# Error message of the last failed request (null after a success)
{{ state_attr('sensor.ha_text_ai_gpt', 'last_error') }} # null
# Maintenance flag
{{ state_attr('sensor.ha_text_ai_gpt', 'is_maintenance') }} # false
# Seconds since the integration instance was set up
{{ state_attr('sensor.ha_text_ai_gpt', 'uptime') }} # 547.58
```
### History Storage
Conversation history stored in `.storage/ha_text_ai_history/` directory:
- Each instance has its own history file (JSON)
- Files are automatically rotated when size limit is reached
- Archived history files are timestamped
- Default maximum file size: 1MB
</details>
## FAQ
**Q: Which AI providers are supported?**
A: OpenAI, Anthropic, DeepSeek and Google Gemini are built-in providers. OpenRouter and other OpenAI-compatible services work through the OpenAI provider with a custom endpoint.
**Q: How can I reduce API costs?** **Q: How can I reduce API costs?**
A: Use GPT-3.5-Turbo for most queries, implement caching, and optimize token usage. A: Use a cheap fast model (GPT-5.6 Luna, Claude Haiku 4.5, deepseek-v4-flash, gemini-3.5-flash) for routine queries, lower `context_messages`, and cap `max_tokens`.
**Q: Is my data secure?** **Q: Are there limitations on the number of requests?**
A: Yes, API keys are stored securely and data is transmitted via encrypted connections. A: Depends on your API provider's plan. Monitor usage via the sensor attributes and throttle calls with the `request_interval` option.
**Q: Can I use custom models?** **Q: Can I use custom models?**
A: Yes, configure custom endpoints and models via configuration options. A: Yes, you can configure custom endpoints and use any compatible model by specifying it in the configuration.
## 🤝 Contributing **Q: How do I switch between different AI providers?**
A: Each integration instance is bound to one provider. Add a separate instance per provider and pick the instance in your service calls; within an instance you can override the model per request.
**Q: What are the token limits for different models?**
A: Context window sizes vary by provider and model - check your provider's documentation. The `max_tokens` option caps only the response length, not the context window.
**Q: How do I monitor token usage?**
A: Use the sensor attributes `total_tokens`, `prompt_tokens` and `completion_tokens`. You can also create automations to alert you when usage exceeds a threshold.
**Q: Is my data secure?**
A: Conversation history and API keys are stored locally in your Home Assistant instance. Questions and context are sent to the provider you configure over HTTPS; nothing is shared with third parties beyond that provider.
**Q: How do context messages work?**
A: Context messages let the AI reference previous conversation history. By default 5 previous messages are included; you can set 1 to 20 per request to balance conversation depth against token usage.
**Q: Where is conversation history stored?**
A: History is stored in files under the `.storage/ha_text_ai_history/` directory, with automatic rotation and size management.
**Q: Can I access old conversation history?**
A: Yes, archived history files are stored with timestamps and can be accessed manually if needed.
**Q: How much history is kept?**
A: 50 conversations by default, configurable up to 100 in the UI. Files are automatically rotated when they reach 1MB.
## Contributing
Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md). Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
@@ -248,15 +600,44 @@ Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
4. Push branch (`git push origin feature/Enhancement`) 4. Push branch (`git push origin feature/Enhancement`)
5. Open Pull Request 5. Open Pull Request
## 📝 License ## Legal Disclaimer and Limitation of Liability
MIT License - see [LICENSE](LICENSE) for details. ### Software Disclaimer
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
## License
Author: SMKRV
[MIT License](https://opensource.org/licenses/MIT) - see [LICENSE](LICENSE) for details.
## Support the Project
The best support is:
- Sharing feedback
- Contributing ideas
- Recommending to friends
- Reporting issues
- Star the repository
If you want to say thanks financially, you can send a small token of appreciation in USDT:
**USDT Wallet (TRC10/TRC20):**
`TXC9zYHYPfWUGi4Sv4R1ctTBGScXXQk5HZ`
--- ---
<div align="center"><img src="https://github.com/smkrv/ha-text-ai/blob/2aaf3405759eb2d97624834594e24ace896131df/assets/images/icons/footer_icon.png" alt="HA Text AI" style="width: 128px; height: auto;"/></div>
<div align="center"> <div align="center">
Made with ❤️ for the Home Assistant Community Made for the Home Assistant Community
[Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues) [Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues)
+148
View File
@@ -0,0 +1,148 @@
# Using response_variable with HA Text AI
After updating the HA Text AI integration, it now supports using the `response_variable` parameter in Home Assistant scripts and automations.
## What Changed
- Added response schema support in the `ha_text_ai.ask_question` service
- Service is now correctly registered with `supports_response=True` flag
- You can now use `response_variable` to capture AI response in a variable
## Example Usage in Script
```yaml
action: ha_text_ai.ask_question
data:
context_messages: 0
temperature: 0.7
max_tokens: 1000
instance: sensor.ha_text_ai_gemini
question: "What time is it?"
response_variable: ai_response
```
## Example Usage in Automation
```yaml
alias: "Get AI Response"
trigger:
- platform: state
entity_id: input_boolean.ask_ai
to: "on"
action:
- action: ha_text_ai.ask_question
data:
instance: sensor.ha_text_ai_gemini
question: "What's the current weather?"
temperature: 0.7
max_tokens: 500
response_variable: weather_response
- action: notify.persistent_notification
data:
title: "AI Response"
message: "{{ weather_response.response_text }}"
```
## Available Fields in response_variable
When you use `response_variable`, you will receive an object with the following fields:
- `response_text` (string) - The AI response text
- `tokens_used` (integer) - Total number of tokens used
- `prompt_tokens` (integer) - Number of tokens in the prompt
- `completion_tokens` (integer) - Number of tokens in the completion
- `model_used` (string) - The AI model that was used for the response
- `instance` (string) - The instance name that was used
- `question` (string) - The original question that was asked
- `timestamp` (string) - ISO timestamp when the response was generated
- `success` (boolean) - Whether the request was successful
- `error` (string) - Error message if the request failed
## Example Using Response Fields
```yaml
action:
- action: ha_text_ai.ask_question
data:
instance: sensor.ha_text_ai_gemini
question: "Tell me a joke"
response_variable: joke_response
- condition: template
value_template: "{{ joke_response.success }}"
- action: input_text.set_value
target:
entity_id: input_text.last_ai_response
data:
value: "{{ joke_response.response_text }}"
- action: input_number.set_value
target:
entity_id: input_number.tokens_used
data:
value: "{{ joke_response.tokens_used }}"
```
## Error Handling
```yaml
action:
- action: ha_text_ai.ask_question
data:
instance: sensor.ha_text_ai_gemini
question: "Test question"
response_variable: ai_result
- choose:
- conditions:
- condition: template
value_template: "{{ ai_result.success }}"
sequence:
- action: notify.mobile_app_phone
data:
title: "AI Response"
message: "{{ ai_result.response_text }}"
- conditions:
- condition: template
value_template: "{{ not ai_result.success }}"
sequence:
- action: notify.mobile_app_phone
data:
title: "AI Error"
message: "Error: {{ ai_result.error }}"
```
## Migration from Old Approach
**Old method (without response_variable):**
```yaml
# Ask question
- action: ha_text_ai.ask_question
data:
instance: sensor.ha_text_ai_gemini
question: "Hello!"
# Wait and read response from sensor
- delay: 00:00:05
- action: notify.mobile_app_phone
data:
message: "{{ states('sensor.ha_text_ai_gemini') }}"
```
**New method (with response_variable):**
```yaml
# Ask question and get response immediately
- action: ha_text_ai.ask_question
data:
instance: sensor.ha_text_ai_gemini
question: "Hello!"
response_variable: greeting_response
- action: notify.mobile_app_phone
data:
message: "{{ greeting_response.response_text }}"
```
The new approach is more reliable as it doesn't require waiting and reading from the sensor.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+397 -47
View File
@@ -1,89 +1,439 @@
"""The HA Text AI integration.""" """
The HA Text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import asyncio
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_CLOSE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import DOMAIN, PLATFORMS
from .coordinator import HATextAICoordinator from .coordinator import HATextAICoordinator
from .api_client import APIClient
from .utils import create_pinned_session, normalize_name, safe_log_data, validate_endpoint
from .providers import get_default_endpoint, get_default_model, build_auth_headers
from .const import (
DOMAIN,
PLATFORMS,
CONF_MODEL,
CONF_TEMPERATURE,
CONF_MAX_TOKENS,
CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL,
CONF_API_TIMEOUT,
CONF_API_PROVIDER,
CONF_CONTEXT_MESSAGES,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
DEFAULT_REQUEST_INTERVAL,
DEFAULT_API_TIMEOUT,
DEFAULT_CONTEXT_MESSAGES,
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT,
DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
CONF_DISABLE_THINKING,
DEFAULT_DISABLE_THINKING,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required("instance"): cv.string,
vol.Required("question"): vol.All(cv.string, vol.Length(min=1, max=100000)),
vol.Optional("system_prompt"): vol.All(cv.string, vol.Length(max=50000)),
vol.Optional("model"): cv.string,
vol.Optional("temperature"): vol.All(
vol.Coerce(float), vol.Range(min=0.0, max=2.0)
),
vol.Optional("max_tokens"): cv.positive_int,
vol.Optional("context_messages"): cv.positive_int,
vol.Optional("structured_output", default=False): cv.boolean,
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
vol.Optional("disable_thinking"): cv.boolean,
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
vol.Required("instance"): cv.string,
vol.Required("prompt"): cv.string,
})
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Required("instance"): cv.string,
# No default and no schema max: omitting limit returns the full history
# (pre-2.5.0 behavior) and oversized values are clamped to
# ABSOLUTE_MAX_HISTORY_SIZE in history.async_get_history instead of
# failing the whole service call.
vol.Optional("limit"): vol.All(cv.positive_int, vol.Range(min=1)),
vol.Optional("filter_model"): cv.string,
vol.Optional("start_date"): cv.string,
vol.Optional("include_metadata"): cv.boolean,
vol.Optional("sort_order"): vol.In(["newest", "oldest"]),
})
def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAICoordinator:
"""Get coordinator by instance name or normalized name.
Accepts instance_name, normalized_name, or sensor entity_id.
"""
if instance.startswith("sensor."):
instance = instance.replace("sensor.ha_text_ai_", "", 1)
normalized_input = normalize_name(instance)
for entry_id, coord in hass.data[DOMAIN].items():
if not isinstance(coord, HATextAICoordinator):
continue
if (
coord.instance_name.lower() == instance.lower()
or coord.normalized_name == normalized_input
):
return coord
raise HomeAssistantError(f"Instance {instance} not found")
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the HA Text AI component.""" """Set up the Home Assistant Text AI component."""
# Initialize domain data storage
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
_async_register_services(hass)
return True return True
def _async_register_services(hass: HomeAssistant) -> None:
"""Register domain services; safe to call again after unload.
Unloading the last config entry unregisters the services, and a config
entry reload (every options change does one) runs unload + setup_entry
without re-running async_setup — so setup_entry must be able to bring
the services back.
"""
if hass.services.has_service(DOMAIN, SERVICE_ASK_QUESTION):
return
async def async_ask_question(call: ServiceCall) -> dict:
"""Handle ask_question service with response data."""
try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
response = await coordinator.async_ask_question(
question=call.data["question"],
model=call.data.get("model"),
temperature=call.data.get("temperature"),
max_tokens=call.data.get("max_tokens"),
system_prompt=call.data.get("system_prompt"),
context_messages=call.data.get("context_messages"),
structured_output=call.data.get("structured_output", False),
json_schema=call.data.get("json_schema"),
disable_thinking=call.data.get("disable_thinking"),
)
# Return structured response data
return {
"response_text": response.get("content", ""),
"tokens_used": response.get("tokens", {}).get("total", 0),
"prompt_tokens": response.get("tokens", {}).get("prompt", 0),
"completion_tokens": response.get("tokens", {}).get("completion", 0),
"model_used": response.get("model", call.data.get("model", coordinator.model)),
"instance": call.data["instance"],
"question": call.data["question"],
"timestamp": response.get("timestamp"),
"success": True
}
except Exception as err:
_LOGGER.error("Error asking question: %s", str(err))
# Return error response
return {
"response_text": "",
"tokens_used": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"model_used": call.data.get("model", ""),
"instance": call.data["instance"],
"question": call.data["question"],
"timestamp": dt_util.utcnow().isoformat(),
"success": False,
"error": str(err),
"error_type": type(err).__name__
}
async def async_clear_history(call: ServiceCall) -> None:
"""Handle clear_history service."""
try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
await coordinator.async_clear_history()
except Exception as err:
_LOGGER.error("Error clearing history: %s", str(err))
raise HomeAssistantError(f"Failed to clear history: {str(err)}") from err
async def async_get_history(call: ServiceCall) -> dict:
"""Handle get_history service."""
try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
history = await coordinator.async_get_history(
limit=call.data.get("limit"),
filter_model=call.data.get("filter_model"),
start_date=call.data.get("start_date"),
include_metadata=call.data.get("include_metadata", False),
sort_order=call.data.get("sort_order", "newest")
)
# HA requires action responses to be dicts. The bare list made
# every return_response call fail with a server error, so this
# path never worked before and the wrapper breaks no consumer.
return {"history": history}
except Exception as err:
_LOGGER.error("Error getting history: %s", str(err))
raise HomeAssistantError(f"Failed to get history: {str(err)}") from err
async def async_set_system_prompt(call: ServiceCall) -> None:
"""Handle set_system_prompt service."""
try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
await coordinator.async_set_system_prompt(call.data["prompt"])
except Exception as err:
_LOGGER.error("Error setting system prompt: %s", str(err))
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") from err
# Register services
hass.services.async_register(
DOMAIN,
SERVICE_ASK_QUESTION,
async_ask_question,
schema=SERVICE_SCHEMA_ASK_QUESTION,
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_HISTORY,
async_clear_history,
schema=vol.Schema({vol.Required("instance"): cv.string})
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_HISTORY,
async_get_history,
schema=SERVICE_SCHEMA_GET_HISTORY,
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_SYSTEM_PROMPT,
async_set_system_prompt,
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
)
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
"""Check API availability using provider registry configuration."""
try:
from .providers import get_provider_config
provider_config = get_provider_config(provider)
check_path = provider_config.get("check_path")
if check_path is None:
# Provider does not support /models check (e.g. Gemini)
auth_header = provider_config["auth_header"]
auth_value = headers.get(auth_header, "").replace(provider_config.get("auth_prefix", ""), "")
if auth_value:
return True
_LOGGER.error("API key is missing or empty for %s", provider)
return False
check_url = f"{endpoint}{check_path}"
async with asyncio.timeout(api_timeout):
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 200:
return True
elif response.status == 401:
_LOGGER.error("Invalid API key")
return False
elif response.status == 429:
_LOGGER.warning("Rate limit exceeded during API check")
return False
else:
_LOGGER.error("API check failed with status: %d", response.status)
return False
except Exception as ex:
_LOGGER.error("API check error: %s", str(ex))
return False
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry.""" """Set up HA Text AI from a config entry."""
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
session = None
try: try:
session = aiohttp_client.async_get_clientsession(hass) # Get provider from data or options (options takes precedence)
config = {**entry.data, **entry.options}
api_provider = config.get(CONF_API_PROVIDER)
if not api_provider:
_LOGGER.error("API provider not specified")
raise ConfigEntryNotReady("API provider is required")
model = config.get(CONF_MODEL, get_default_model(api_provider))
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
allow_local = config.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
if allow_local:
_LOGGER.info(
"Local network mode enabled for endpoint %s"
"SSRF protection relaxed for self-hosted proxies",
raw_endpoint,
)
try:
endpoint, resolved_ips = await validate_endpoint(
hass, raw_endpoint, allow_local=allow_local
)
except ValueError as err:
_LOGGER.error("Invalid API endpoint: %s", err)
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err
# Pinned session closes DNS-rebinding TOCTOU and isolates cookies
# from other integrations sharing the same endpoint hostname.
# The integration owns this session: APIClient.shutdown() closes it
# on unload, the except handler below closes it on failed setup.
session = create_pinned_session(endpoint, resolved_ips)
# API key can now be updated via options
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
request_interval = config.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)
api_timeout = config.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT)
max_tokens = config.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
disable_thinking = config.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING)
headers = build_auth_headers(api_provider, api_key)
if not await async_check_api(session, endpoint, headers, api_provider, api_timeout):
raise ConfigEntryNotReady("API connection failed")
_LOGGER.debug("Creating API client for %s with endpoint %s", api_provider, endpoint)
api_client = APIClient(
session=session,
endpoint=endpoint,
headers=headers,
api_provider=api_provider,
model=model,
api_timeout=api_timeout,
api_key=api_key,
)
coordinator = HATextAICoordinator( coordinator = HATextAICoordinator(
hass, hass=hass,
api_key=entry.data[CONF_API_KEY], client=api_client,
endpoint=entry.data.get("api_endpoint", "https://api.openai.com/v1"), model=model,
model=entry.data.get("model", "gpt-3.5-turbo"), update_interval=request_interval,
temperature=entry.data.get("temperature", 0.7), instance_name=instance_name,
max_tokens=entry.data.get("max_tokens", 1000), config_entry=entry,
request_interval=entry.data.get("request_interval", 1.0), max_tokens=max_tokens,
session=session, temperature=temperature,
max_history_size=max_history_size,
context_messages=context_messages,
api_timeout=api_timeout,
disable_thinking=disable_thinking,
) )
try: # Initialize coordinator (directories, history, metrics)
await coordinator.async_config_entry_first_refresh() await coordinator.async_initialize()
except Exception as refresh_ex:
_LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex))
return False
if not coordinator.last_update_success: _LOGGER.debug("Created coordinator for %s", instance_name)
_LOGGER.error("Failed to communicate with OpenAI API")
return False
# Store coordinator
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
try: # A reload after the last entry was unloaded needs the services back.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) _async_register_services(hass)
except Exception as setup_ex:
_LOGGER.error("Failed to setup platforms: %s", str(setup_ex))
return False
_LOGGER.info( _LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
"Successfully set up HA Text AI with model: %s",
entry.data.get("model", "gpt-3.5-turbo") # Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
# HA Core stop does not unload entries, so close the dedicated
# session on the CLOSE event too; unload removes this listener
# and closes the session via APIClient.shutdown() instead.
async def _async_close_session_on_stop(_event) -> None:
if not session.closed:
await session.close()
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_CLOSE, _async_close_session_on_stop
)
) )
_LOGGER.debug("Setup completed for %s", instance_name)
return True return True
except Exception as ex: except Exception as err:
_LOGGER.exception("Unexpected error setting up entry: %s", str(ex)) _LOGGER.exception("Error setting up HA Text AI: %s", err)
return False if session is not None and not session.closed:
await session.close()
raise
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update - reload the config entry."""
_LOGGER.info("Options updated for %s, reloading integration", entry.title)
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
try: try:
if entry.entry_id not in hass.data.get(DOMAIN, {}):
return True
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok and entry.entry_id in hass.data[DOMAIN]:
coordinator = hass.data[DOMAIN].pop(entry.entry_id) coordinator = hass.data[DOMAIN].pop(entry.entry_id)
if hasattr(coordinator.client, 'shutdown'):
await coordinator.client.shutdown()
await coordinator.async_shutdown() await coordinator.async_shutdown()
# When removing the last config entry, also unregister services and
# clear the domain bucket so HA doesn't show stale services in the UI.
if not hass.data.get(DOMAIN):
hass.data.pop(DOMAIN, None)
for service in (
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT,
):
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
return unload_ok return unload_ok
except Exception as ex: except Exception as ex:
_LOGGER.exception("Error unloading entry: %s", str(ex)) _LOGGER.exception("Error unloading entry: %s", str(ex))
return False return False
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
try:
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
except Exception as ex:
_LOGGER.exception("Error reloading entry: %s", str(ex))
+767
View File
@@ -0,0 +1,767 @@
"""
API Client for HA Text AI.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from aiohttp import ClientSession, ClientTimeout
from homeassistant.exceptions import HomeAssistantError
from .const import (
DEFAULT_API_TIMEOUT,
API_RETRY_COUNT,
API_PROVIDER_ANTHROPIC,
API_PROVIDER_DEEPSEEK,
API_PROVIDER_OPENAI,
API_PROVIDER_GEMINI,
MIN_TEMPERATURE,
MAX_TEMPERATURE,
MIN_MAX_TOKENS,
MAX_MAX_TOKENS,
)
_LOGGER = logging.getLogger(__name__)
class APIClient:
"""API Client for OpenAI and Anthropic."""
def __init__(
self,
session: ClientSession,
endpoint: str,
headers: dict[str, str],
api_provider: str,
model: str,
api_timeout: int = DEFAULT_API_TIMEOUT,
api_key: str | None = None,
) -> None:
"""Initialize API client."""
self.session = session
self.endpoint = endpoint
self.headers = headers
self.api_provider = api_provider
self.model = model
self.api_timeout = api_timeout
self.timeout = ClientTimeout(total=api_timeout)
self._api_key = api_key
if self.api_provider == API_PROVIDER_GEMINI and not api_key:
raise ValueError("Gemini provider requires api_key parameter")
self._closed = False
async def __aenter__(self):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.shutdown()
def _validate_parameters(
self,
temperature: float,
max_tokens: int,
) -> None:
"""Validate API parameters with enhanced type checking."""
# Type validation
if not isinstance(temperature, (int, float)):
raise TypeError(f"Temperature must be a number, got {type(temperature)}")
if not isinstance(max_tokens, int):
raise TypeError(f"Max tokens must be an integer, got {type(max_tokens)}")
# Range validation
if not MIN_TEMPERATURE <= temperature <= MAX_TEMPERATURE:
raise ValueError(
f"Temperature must be between {MIN_TEMPERATURE} and {MAX_TEMPERATURE}, got {temperature}"
)
if not MIN_MAX_TOKENS <= max_tokens <= MAX_MAX_TOKENS:
raise ValueError(
f"Max tokens must be between {MIN_MAX_TOKENS} and {MAX_MAX_TOKENS}, got {max_tokens}"
)
async def _make_request(
self,
url: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Make API request with retry logic for transient errors only.
Retries on:
- asyncio.TimeoutError
- HTTP 429 (rate limit) — honors Retry-After header when present
- HTTP 502/503/504 (upstream transient errors)
4xx (other than 429) return immediately — they are not retryable.
"""
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
retryable_5xx = {502, 503, 504}
for attempt in range(API_RETRY_COUNT):
try:
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=self.timeout,
# The session pins DNS to validated IPs; following a
# redirect would resolve a new host past that pin.
allow_redirects=False,
) as response:
_LOGGER.debug("Response status: %s", response.status)
if response.status == 200:
return await response.json()
# Try to get error details
error_data = {}
try:
error_data = await response.json()
except Exception:
error_data = {"raw": await response.text()}
# Rate limit — retry with backoff, prefer Retry-After header
if response.status == 429:
_LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
)
if attempt < API_RETRY_COUNT - 1:
retry_after = self._parse_retry_after(
response.headers.get("Retry-After")
)
await asyncio.sleep(retry_after or (2 ** attempt))
continue
raise HomeAssistantError("API rate limit exceeded")
# Upstream transient errors — retry with backoff
if response.status in retryable_5xx:
_LOGGER.warning(
"Upstream %d on attempt %d/%d",
response.status, attempt + 1, API_RETRY_COUNT,
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
continue
raise HomeAssistantError(
f"Upstream error after retries: status {response.status}"
)
# Other client/server errors — don't retry
truncated_error = str(error_data)[:512]
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
raise HomeAssistantError(f"API error: status {response.status}")
except asyncio.TimeoutError as err:
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out") from err
await asyncio.sleep(2 ** attempt)
except HomeAssistantError:
raise
except Exception as e:
_LOGGER.warning(
"API request failed on attempt %d/%d: %s",
attempt + 1, API_RETRY_COUNT, type(e).__name__,
)
if attempt == API_RETRY_COUNT - 1:
raise
await asyncio.sleep(2 ** attempt)
raise HomeAssistantError("API request failed after all retries")
@staticmethod
def _parse_retry_after(value: str | None) -> float | None:
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
if not value:
return None
try:
seconds = float(value.strip())
except (ValueError, AttributeError):
return None
if seconds <= 0:
return None
return min(seconds, 60.0)
async def create(
self,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using appropriate API."""
try:
self._validate_parameters(temperature, max_tokens)
if self.api_provider == API_PROVIDER_ANTHROPIC:
return await self._create_anthropic_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_DEEPSEEK:
return await self._create_deepseek_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_GEMINI:
return await self._create_gemini_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema, disable_thinking
)
else:
return await self._create_openai_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema, disable_thinking
)
except Exception as e:
_LOGGER.error("API request failed: %s", str(e))
raise HomeAssistantError(f"API request failed: {str(e)}") from e
# Non-reasoning variants whose names otherwise overlap with the
# reasoning prefix set (e.g. "gpt-5-chat-latest" is classic chat).
_OPENAI_NON_REASONING_PATTERNS: tuple[str, ...] = ("gpt-5-chat",)
_OPENAI_REASONING_REGEX = re.compile(
r"^(?:o\d+|gpt-[5-9](?:\.\d+)?)(?:[-_].*)?$"
)
@classmethod
def _is_openai_reasoning_model(cls, model: str) -> bool:
"""Detect OpenAI reasoning models (o-series and GPT-5+ family).
Reasoning models require max_completion_tokens (not max_tokens),
do not accept custom temperature, and use "developer" role instead
of "system". Uses a regex so future o5/gpt-6 releases are caught
without code change. Explicitly excludes chat-variants
(e.g. gpt-5-chat-latest) which are classic chat models.
"""
if not model:
return False
m = model.strip().lower()
# OpenRouter-style "openai/o3" prefix — strip provider namespace.
if "/" in m:
m = m.rsplit("/", 1)[-1]
for non_reasoning in cls._OPENAI_NON_REASONING_PATTERNS:
if m.startswith(non_reasoning):
return False
return bool(cls._OPENAI_REASONING_REGEX.match(m))
@staticmethod
def _convert_system_to_developer(
messages: list[dict[str, str]],
) -> list[dict[str, str]]:
"""Rename role "system" to "developer" for OpenAI reasoning models."""
return [
{**m, "role": "developer"} if m.get("role") == "system" else m
for m in messages
]
# Matches /no_think only as a standalone soft-switch token (word-bounded),
# not when users discuss the concept ("discuss /no_think semantics").
_NO_THINK_TOKEN_RE = re.compile(r"(?:^|\s)/no_think(?:\s|$)")
@classmethod
def _apply_no_think_tag(
cls,
messages: list[dict[str, str]],
) -> list[dict[str, str]]:
"""Append Qwen-style /no_think soft switch to the last user message.
Why: Qwen3 reasoning models treat "/no_think" in the last user turn as a
request to skip thinking. Non-Qwen models ignore the trailing token
harmlessly, so this is safe to apply to all OpenAI-compatible backends.
Uses word-boundary regex for dedup so that user content mentioning
"/no_think" mid-sentence isn't mistaken for an existing soft switch.
"""
if not messages:
return messages
patched = [m.copy() for m in messages]
for i in range(len(patched) - 1, -1, -1):
if patched[i].get("role") == "user":
content = patched[i].get("content", "")
if not cls._NO_THINK_TOKEN_RE.search(content):
patched[i]["content"] = f"{content.rstrip()} /no_think".lstrip()
break
return patched
@staticmethod
def _strip_think_blocks(text: str) -> str:
"""Remove <think>...</think> reasoning blocks from model output.
Why: Some reasoning models (DeepSeek-R1, Qwen-Thinking) emit chain-of-thought
wrapped in <think> tags even when thinking is nominally disabled. Strip them
so the final answer stays clean. Handles nested blocks via iterative
replacement, and drops dangling opening tags when a response is
truncated mid-block.
"""
if not text or "<think>" not in text:
return text
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
cleaned = text
# Iterative pass: each iteration peels one layer of nested tags.
# Bounded to 10 iterations to avoid pathological inputs.
for _ in range(10):
new = pattern.sub("", cleaned)
if new == cleaned:
break
cleaned = new
# If a truncated response left a dangling <think> open, drop the rest
# from that marker onward to avoid leaking partial reasoning.
if "<think>" in cleaned:
cleaned = cleaned.split("<think>", 1)[0]
return cleaned.strip()
@staticmethod
def _apply_structured_output(
payload: dict[str, Any],
structured_output: bool,
json_schema: str | None,
) -> None:
"""Apply OpenAI-compatible structured output to payload in-place."""
if not (structured_output and json_schema):
return
try:
schema = json.loads(json_schema)
payload["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_response",
"strict": True,
"schema": schema,
},
}
except json.JSONDecodeError as e:
_LOGGER.warning("Invalid JSON schema: %s. Falling back to json_object.", e)
payload["response_format"] = {"type": "json_object"}
async def _create_deepseek_completion(
self,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using DeepSeek API.
DeepSeek-reasoner (R1) is a reasoning model: it ignores /no_think
(thinking is always on by design) and emits reasoning_content as a
separate field alongside content. We skip the no_think append for
this model and preserve reasoning_content in the response payload
so it's available for logging/debug.
DeepSeek V4+ (deepseek-v4-flash/-pro) selects thinking mode via a
top-level "thinking" request parameter instead of the model name,
so /no_think does not apply there.
"""
url = f"{self.endpoint}/chat/completions"
m_lower = model.lower()
is_reasoner = "reasoner" in m_lower
is_v4plus = re.search(r"deepseek-v[4-9]", m_lower) is not None
final_messages = (
self._apply_no_think_tag(messages)
if (disable_thinking and not is_reasoner and not is_v4plus)
else messages
)
payload = {
"model": model,
"messages": final_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
if disable_thinking and is_v4plus:
payload["thinking"] = {"type": "disabled"}
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
message = data["choices"][0]["message"]
content = message.get("content", "")
reasoning = message.get("reasoning_content")
if disable_thinking and not is_reasoner:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {
"content": content,
**({"reasoning_content": reasoning} if reasoning else {}),
},
}
],
"usage": {
"prompt_tokens": data["usage"]["prompt_tokens"],
"completion_tokens": data["usage"]["completion_tokens"],
"total_tokens": data["usage"]["total_tokens"],
},
}
async def _create_openai_completion(
self,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using OpenAI API.
Reasoning models (o-series, gpt-5 family) require a different payload
shape: max_completion_tokens instead of max_tokens, no custom
temperature, and role "developer" instead of "system". When
disable_thinking=True for a reasoning model we set reasoning_effort
to "low" to minimize hidden CoT tokens. For classic chat models the
Qwen-style /no_think soft switch is appended instead.
"""
url = f"{self.endpoint}/chat/completions"
is_reasoning = self._is_openai_reasoning_model(model)
if is_reasoning:
prepared_messages = self._convert_system_to_developer(messages)
payload: dict[str, Any] = {
"model": model,
"messages": prepared_messages,
"max_completion_tokens": max_tokens,
}
if disable_thinking:
# gpt-5+ supports "minimal" (cheapest, lowest-CoT). o-series
# rejects "minimal" and accepts low/medium/high — fall back to "low".
effort = "minimal" if model.lower().startswith(("gpt-5", "gpt5")) else "low"
payload["reasoning_effort"] = effort
else:
prepared_messages = (
self._apply_no_think_tag(messages) if disable_thinking else messages
)
payload = {
"model": model,
"messages": prepared_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
content = data["choices"][0]["message"]["content"]
# Strip <think> blocks only for classic chat models. Reasoning models
# never emit the tags in user-facing content.
if disable_thinking and not is_reasoning:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {"content": content},
}
],
"usage": {
"prompt_tokens": data["usage"]["prompt_tokens"],
"completion_tokens": data["usage"]["completion_tokens"],
"total_tokens": data["usage"]["total_tokens"],
},
}
async def _create_anthropic_completion(
self,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using Anthropic API."""
url = f"{self.endpoint}/v1/messages"
system_prompt = None
filtered_messages = []
for msg in messages:
if msg['role'] == 'system':
if system_prompt is None:
system_prompt = msg['content']
else:
system_prompt += f" {msg['content']}"
else:
filtered_messages.append(msg)
# For Anthropic, add structured output instruction to system prompt.
# Validate schema is well-formed JSON before concatenation: untrusted
# schema strings (built from templates/webhook data) could otherwise
# break out of the JSON fence and rewrite the system instruction.
if structured_output and json_schema:
try:
json.loads(json_schema)
except json.JSONDecodeError as err:
_LOGGER.warning(
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
)
else:
schema_instruction = (
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
f"this JSON Schema:\n{json_schema}\n"
f"Do not include any text before or after the JSON. "
f"Do not wrap the JSON in markdown code blocks."
)
if system_prompt:
system_prompt += schema_instruction
else:
system_prompt = schema_instruction.strip()
_LOGGER.debug("Anthropic structured output enabled via system prompt")
# Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI.
# Clip silently to avoid a 400 when a user-set config exceeds the cap.
clipped_temp = min(1.0, max(0.0, float(temperature)))
payload = {
"model": model,
"messages": filtered_messages,
"max_tokens": max_tokens,
"temperature": clipped_temp,
}
if system_prompt:
payload["system"] = system_prompt
data = await self._make_request(url, payload)
# Anthropic returns an array of content blocks; if extended thinking
# is ever enabled the first block may be type="thinking". Find the
# first text-type block instead of hardcoding index [0].
content = ""
for block in data.get("content", []):
if block.get("type") == "text":
content = block.get("text", "")
break
return {
"choices": [
{
"message": {"content": content},
}
],
"usage": {
"prompt_tokens": data["usage"]["input_tokens"],
"completion_tokens": data["usage"]["output_tokens"],
"total_tokens": data["usage"]["input_tokens"] + data["usage"]["output_tokens"],
},
}
async def _create_gemini_completion(
self,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using Gemini API with google-genai library.
Args:
model: The model name to use
messages: List of message dictionaries with role and content
temperature: Sampling temperature between 0.0 and 2.0
max_tokens: Maximum number of tokens to generate
structured_output: Enable JSON structured output mode
json_schema: JSON Schema for structured output validation
Returns:
Dictionary with response content and token usage
"""
try:
def import_genai():
from google import genai
return genai
genai = await asyncio.to_thread(import_genai)
api_key = self._api_key
def create_client():
if self.endpoint and self.endpoint != "https://generativelanguage.googleapis.com/v1beta":
return genai.Client(api_key=api_key, transport="rest",
client_options={"api_endpoint": self.endpoint})
else:
return genai.Client(api_key=api_key)
client = await asyncio.to_thread(create_client)
# Process messages to extract system instruction and chat history
system_instruction = ""
contents = []
for msg in messages:
if msg['role'] == 'system':
system_instruction += msg['content'] + "\n"
else:
# For chat history, we need to convert to the format Gemini expects
role = "user" if msg['role'] == 'user' else "model"
contents.append({
"role": role,
"parts": [{"text": msg['content']}]
})
# Parse JSON schema if structured output is enabled
parsed_schema = None
if structured_output and json_schema:
try:
parsed_schema = json.loads(json_schema)
_LOGGER.debug("Gemini structured output enabled with schema")
except json.JSONDecodeError as e:
_LOGGER.warning("Invalid JSON schema provided: %s. Structured output disabled.", e)
# Create configuration
def create_config():
from google.genai import types
config = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
# Add system instruction if present
if system_instruction:
config.system_instruction = system_instruction.strip()
# Add structured output configuration for Gemini
if structured_output and parsed_schema:
config.response_mime_type = "application/json"
config.response_schema = parsed_schema
# Disable thinking. Gemini 3.x+ replaced the numeric
# thinking_budget with a semantic thinking_level; Pro
# variants do not accept MINIMAL, their floor is LOW.
# Gemini 2.5: Flash accepts thinking_budget=0 (fully off),
# Pro rejects 0 and requires at least 128 tokens.
# 2.0 and earlier ignore the field.
if disable_thinking:
m_lower = model.lower()
try:
if re.search(r"gemini-[3-9]", m_lower):
level = "LOW" if "pro" in m_lower else "MINIMAL"
config.thinking_config = types.ThinkingConfig(
thinking_level=level
)
else:
budget = 128 if "2.5-pro" in m_lower else 0
config.thinking_config = types.ThinkingConfig(
thinking_budget=budget
)
except (AttributeError, TypeError, ValueError) as err:
_LOGGER.debug(
"ThinkingConfig not supported by this google-genai version: %s", err
)
return config
config = await asyncio.to_thread(create_config)
def generate_content():
# For single message without history, use generate_content
if len(contents) <= 1:
if not contents:
prompt = "I need your assistance."
else:
prompt = contents[0]["parts"][0]["text"]
return client.models.generate_content(
model=model,
contents=prompt,
config=config
)
else:
# For multi-turn conversations, pass history to chat
# and only send the last user message
last_user_msg = None
history = []
# Find the last user message — that's the new query
for i in range(len(contents) - 1, -1, -1):
if contents[i]["role"] == "user":
last_user_msg = contents[i]["parts"][0]["text"]
history = contents[:i]
break
if last_user_msg is None:
# No user messages at all — shouldn't happen, but handle gracefully
return client.models.generate_content(
model=model,
contents="I need your assistance.",
config=config
)
chat = client.chats.create(
model=model, config=config, history=history
)
return chat.send_message(last_user_msg)
# Gemini uses sync SDK via to_thread, so needs its own timeout
# (aiohttp ClientTimeout doesn't apply here)
async with asyncio.timeout(self.api_timeout):
response = await asyncio.to_thread(generate_content)
# Extract response text
def extract_response():
response_text = response.text if hasattr(response, 'text') else ""
# Try to get token usage if available
usage = {}
if hasattr(response, 'usage_metadata'):
usage = {
"prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
"completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
"total_tokens": getattr(response.usage_metadata, 'total_token_count', 0)
}
else:
# Estimate token count as fallback
usage = {
"prompt_tokens": len(" ".join([m["content"] for m in messages]).split()) // 3,
"completion_tokens": len(response_text.split()) // 3,
"total_tokens": 0 # Will be calculated below
}
usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"]
return response_text, usage
response_text, usage = await asyncio.to_thread(extract_response)
if disable_thinking:
response_text = self._strip_think_blocks(response_text)
return {
"choices": [{
"message": {
"content": response_text
}
}],
"usage": usage
}
except ImportError as e:
_LOGGER.error("Google Gemini library not installed: %s", e)
raise HomeAssistantError(
"Missing dependency: google-genai. Please install it."
) from e
except Exception as e:
_LOGGER.error("Gemini API error: %s", e)
raise HomeAssistantError(f"Gemini API request failed: {e}") from e
async def shutdown(self) -> None:
"""Shutdown API client and close its dedicated session."""
_LOGGER.debug("Shutting down API client")
self._closed = True
# The session is dedicated to this config entry (pinned resolver,
# isolated cookie jar), so it must be closed here to release the
# connector; nothing else owns it.
if self.session is not None and not self.session.closed:
await self.session.close()
+474 -230
View File
@@ -1,19 +1,22 @@
"""Config flow for HA text AI integration.""" """
from typing import Any, Dict, Optional, Tuple Config flow for HA text AI integration.
import voluptuous as vol
import ssl
import certifi
import asyncio
from async_timeout import timeout
import aiohttp
from urllib.parse import urlparse
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY, CONF_NAME
import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback from homeassistant.core import callback
from openai import AsyncOpenAI from homeassistant.config_entries import ConfigFlowResult
from openai import OpenAIError, APIError, APIConnectionError, AuthenticationError, RateLimitError from homeassistant.helpers import selector
from .const import ( from .const import (
DOMAIN, DOMAIN,
@@ -22,256 +25,497 @@ from .const import (
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_API_ENDPOINT, CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
DEFAULT_MODEL, CONF_API_TIMEOUT,
CONF_API_PROVIDER,
CONF_CONTEXT_MESSAGES,
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC,
API_PROVIDER_DEEPSEEK,
API_PROVIDER_GEMINI,
API_PROVIDERS,
DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS, DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT,
DEFAULT_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL,
DEFAULT_API_TIMEOUT,
DEFAULT_CONTEXT_MESSAGES,
MIN_TEMPERATURE,
MAX_TEMPERATURE,
MIN_MAX_TOKENS,
MAX_MAX_TOKENS,
MIN_REQUEST_INTERVAL,
MIN_API_TIMEOUT,
MAX_API_TIMEOUT,
DEFAULT_NAME_PREFIX,
DEFAULT_INSTANCE_NAME,
DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE,
MIN_CONTEXT_MESSAGES,
MAX_CONTEXT_MESSAGES,
MIN_HISTORY_SIZE,
MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
CONF_DISABLE_THINKING,
DEFAULT_DISABLE_THINKING,
) )
from homeassistant.util import dt as dt_util
from .utils import (
create_pinned_session,
normalize_name,
safe_log_data,
validate_endpoint,
)
from .providers import get_default_endpoint, get_default_model, build_auth_headers
import logging
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({ def _build_parameter_schema(data: dict[str, Any]) -> dict:
vol.Required(CONF_API_KEY): str, """Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str, return {
vol.Optional( vol.Optional(
CONF_TEMPERATURE, CONF_TEMPERATURE,
default=DEFAULT_TEMPERATURE default=data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
): vol.All( ): vol.All(vol.Coerce(float), vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)),
vol.Coerce(float), vol.Optional(
vol.Range(min=0, max=2), CONF_MAX_TOKENS,
msg="Temperature must be between 0 and 2" default=data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
), ): vol.All(vol.Coerce(int), vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)),
vol.Optional( vol.Optional(
CONF_MAX_TOKENS, CONF_REQUEST_INTERVAL,
default=DEFAULT_MAX_TOKENS default=data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
): vol.All( ): vol.All(vol.Coerce(float), vol.Range(min=MIN_REQUEST_INTERVAL)),
vol.Coerce(int), vol.Optional(
vol.Range(min=1, max=4096), CONF_API_TIMEOUT,
msg="Max tokens must be between 1 and 4096" default=data.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT),
), ): vol.All(vol.Coerce(int), vol.Range(min=MIN_API_TIMEOUT, max=MAX_API_TIMEOUT)),
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): vol.All( vol.Optional(
str, CONF_CONTEXT_MESSAGES,
vol.Url(), # Заменено с URL на Url default=data.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
msg="Must be a valid URL" ): vol.All(vol.Coerce(int), vol.Range(min=MIN_CONTEXT_MESSAGES, max=MAX_CONTEXT_MESSAGES)),
), vol.Optional(
vol.Optional( CONF_MAX_HISTORY_SIZE,
CONF_REQUEST_INTERVAL, default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
default=DEFAULT_REQUEST_INTERVAL ): vol.All(vol.Coerce(int), vol.Range(min=MIN_HISTORY_SIZE, max=MAX_HISTORY_SIZE)),
): vol.All( vol.Optional(
vol.Coerce(float), CONF_DISABLE_THINKING,
vol.Range(min=0.1), default=data.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
msg="Request interval must be at least 0.1 seconds" ): bool,
), }
})
async def validate_endpoint(endpoint: str) -> Tuple[bool, str]:
"""Validate API endpoint accessibility."""
try:
parsed_url = urlparse(endpoint)
if parsed_url.scheme not in ('http', 'https'):
return False, "invalid_endpoint_scheme"
ssl_context = ssl.create_default_context(cafile=certifi.where())
async with timeout(5):
async with aiohttp.ClientSession() as session:
async with session.get(endpoint, ssl=ssl_context) as response:
if response.status != 200:
return False, "endpoint_not_available"
return True, ""
except Exception as e:
_LOGGER.error("Error validating endpoint: %s", str(e))
return False, "endpoint_error"
async def validate_api_connection(
api_key: str,
endpoint: str,
model: str,
retry_count: int = 3,
retry_delay: float = 1.0
) -> Tuple[bool, str, list]:
"""Validate API connection with improved retry logic."""
ssl_context = ssl.create_default_context(cafile=certifi.where())
# Validate endpoint first
endpoint_valid, endpoint_error = await validate_endpoint(endpoint)
if not endpoint_valid:
return False, endpoint_error, []
for attempt in range(retry_count):
try:
async with timeout(10):
client = AsyncOpenAI(
api_key=api_key,
base_url=endpoint,
http_client=aiohttp.ClientSession(
connector=aiohttp.TCPConnector(
ssl=ssl_context,
enable_cleanup_closed=True
)
)
)
try:
models = await client.models.list()
model_ids = [model.id for model in models.data]
finally:
await client.http_client.close()
if model not in model_ids:
_LOGGER.warning(
"Model %s not found in available models: %s",
model,
", ".join(model_ids)
)
return False, "invalid_model", model_ids
return True, "", model_ids
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout during API validation (attempt %d/%d)",
attempt + 1,
retry_count
)
if attempt == retry_count - 1:
return False, "timeout", []
await asyncio.sleep(retry_delay)
except AuthenticationError as err:
_LOGGER.error("Authentication error: %s", str(err))
return False, "invalid_auth", []
except RateLimitError as err:
_LOGGER.error("Rate limit exceeded: %s", str(err))
return False, "rate_limit", []
except APIConnectionError as err:
_LOGGER.error("API connection error: %s", str(err))
return False, "cannot_connect", []
except APIError as err:
_LOGGER.error("API error: %s", str(err))
return False, "api_error", []
except Exception as err:
_LOGGER.exception("Unexpected error during validation: %s", str(err))
return False, "unknown", []
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI.""" """Handle a config flow for HA text AI."""
VERSION = 1 VERSION = 1
async def async_step_user( def __init__(self) -> None:
self, """Initialize flow."""
user_input: Optional[Dict[str, Any]] = None self._errors = {}
) -> Dict[str, Any]: self._data = {}
self._provider = None
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors: Dict[str, str] = {} if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_API_PROVIDER): selector.SelectSelector(
selector.SelectSelectorConfig(
options=API_PROVIDERS,
translation_key="api_provider"
)
),
})
)
if user_input is not None: self._provider = user_input[CONF_API_PROVIDER]
try: return await self.async_step_provider()
# Validate input data
user_input = STEP_USER_DATA_SCHEMA(user_input)
is_valid, error_code, available_models = await validate_api_connection( def _build_provider_schema(
user_input[CONF_API_KEY], self, data: dict[str, Any] | None = None
user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT), ) -> vol.Schema:
user_input[CONF_MODEL] """Build provider configuration schema with optional defaults from data."""
defaults = data or {}
schema_dict = {
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str,
vol.Required(CONF_API_KEY): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str,
vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
vol.Optional(
CONF_ALLOW_LOCAL_NETWORK,
default=defaults.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
): bool,
}
schema_dict.update(_build_parameter_schema(defaults))
return vol.Schema(schema_dict)
async def async_step_provider(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle provider configuration step."""
self._errors = {}
if user_input is None:
return self.async_show_form(
step_id="provider",
data_schema=self._build_provider_schema(),
)
_LOGGER.debug("Provider step input data: %s", safe_log_data(user_input))
input_copy = user_input.copy()
# Check if CONF_NAME exists in input_copy and ensure it's not empty
if CONF_NAME not in input_copy or not input_copy[CONF_NAME]:
_LOGGER.warning("Missing name in configuration input: %s", safe_log_data(input_copy))
input_copy[CONF_NAME] = f"assistant_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}"
_LOGGER.info("Auto-generated name: %s", input_copy[CONF_NAME])
# Ensure API key is present
if CONF_API_KEY not in input_copy or not input_copy[CONF_API_KEY]:
self._errors["base"] = "invalid_auth"
_LOGGER.error("API validation error: 'api_key'")
return self.async_show_form(
step_id="provider",
data_schema=self._build_provider_schema(input_copy),
errors=self._errors
)
try:
normalized_name = self._validate_and_normalize_name(input_copy[CONF_NAME])
input_copy[CONF_NAME] = normalized_name
except ValueError as e:
return self.async_show_form(
step_id="provider",
data_schema=self._build_provider_schema(input_copy),
errors={"name": str(e)}
)
try:
if not await self._async_validate_api(input_copy):
return self.async_show_form(
step_id="provider",
data_schema=self._build_provider_schema(input_copy),
errors=self._errors
) )
except Exception:
_LOGGER.exception("Unexpected error during API validation")
return self.async_show_form(
step_id="provider",
data_schema=self._build_provider_schema(input_copy),
errors={"base": "unknown"}
)
if is_valid: return await self._create_entry(input_copy)
await self.async_set_unique_id(user_input[CONF_API_KEY])
self._abort_if_unique_id_configured()
return self.async_create_entry( def _validate_and_normalize_name(self, name: str) -> str:
title="HA text AI", """Validate and normalize name.
data=user_input
)
errors["base"] = error_code Truncates before uniqueness check to prevent collisions.
if error_code == "invalid_model":
_LOGGER.warning(
"Selected model %s not found in available models: %s",
user_input[CONF_MODEL],
", ".join(available_models)
)
except vol.Invalid as err: Raises:
_LOGGER.error("Validation error: %s", str(err)) ValueError: If name is invalid or already exists.
errors["base"] = "invalid_input" """
if not name or not name.strip():
raise ValueError("empty")
return self.async_show_form( normalized = normalize_name(name.strip())[:50]
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, if not normalized:
errors=errors, raise ValueError("empty")
description_placeholders={
"default_model": DEFAULT_MODEL, for entry in self._async_current_entries():
"default_endpoint": DEFAULT_API_ENDPOINT, if entry.data.get(CONF_NAME, "") == normalized:
} raise ValueError("name_exists")
return normalized
async def _async_validate_api(self, user_input: dict[str, Any]) -> bool:
"""Validate API connection using provider registry."""
try:
if CONF_API_KEY not in user_input:
_LOGGER.error("API validation error: 'api_key'")
self._errors["base"] = "invalid_auth"
return False
try:
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
endpoint, resolved_ips = await validate_endpoint(
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
)
except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect"
return False
if self._provider == API_PROVIDER_GEMINI:
if not user_input[CONF_API_KEY]:
self._errors["base"] = "invalid_auth"
return False
return True
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
from .providers import get_provider_config
check_path = get_provider_config(self._provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}"
# Pinned session ensures the reachability check goes to the same
# IP that will later be used by api_client (no DNS rebinding).
session = create_pinned_session(endpoint, resolved_ips)
try:
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
finally:
await session.close()
except Exception as err:
_LOGGER.error("API validation error: %s", str(err))
self._errors["base"] = "cannot_connect"
return False
async def _create_entry(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Create the config entry with unique_id deduplication."""
instance_name = user_input[CONF_NAME]
normalized_name = normalize_name(instance_name)
unique_id = f"{DOMAIN}_{normalized_name}_{self._provider}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
default_model = get_default_model(self._provider)
entry_data = {
CONF_API_PROVIDER: self._provider,
CONF_NAME: instance_name,
CONF_API_KEY: user_input.get(CONF_API_KEY),
CONF_API_ENDPOINT: user_input.get(CONF_API_ENDPOINT),
CONF_MODEL: user_input.get(CONF_MODEL, default_model),
CONF_TEMPERATURE: user_input.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
CONF_MAX_TOKENS: user_input.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
CONF_REQUEST_INTERVAL: user_input.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
CONF_API_TIMEOUT: user_input.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT),
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
CONF_DISABLE_THINKING: user_input.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
}
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
return self.async_create_entry(
title=instance_name,
data=entry_data
) )
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow:
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return OptionsFlowHandler(config_entry) return OptionsFlowHandler()
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for HA text AI.""" """Handle options flow."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: async def _async_validate_api(self, provider: str, api_key: str, endpoint: str, *, allow_local: bool = False) -> bool:
"""Initialize options flow.""" """Validate API connection using provider registry."""
self.config_entry = config_entry try:
if not api_key:
self._errors["base"] = "invalid_auth"
return False
try:
endpoint, resolved_ips = await validate_endpoint(
self.hass, endpoint, allow_local=allow_local
)
except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect"
return False
if provider == API_PROVIDER_GEMINI:
return True
headers = build_auth_headers(provider, api_key)
from .providers import get_provider_config
check_path = get_provider_config(provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}"
session = create_pinned_session(endpoint, resolved_ips)
try:
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
finally:
await session.close()
except Exception as err:
_LOGGER.error("API validation error: %s", str(err))
self._errors["base"] = "cannot_connect"
return False
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle provider selection step."""
if not hasattr(self, "_errors"):
self._errors: dict[str, str] = {}
self._selected_provider: str | None = None
current_data = {**self.config_entry.data, **self.config_entry.options}
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
async def async_step_init(
self,
user_input: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Handle options flow."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) self._selected_provider = user_input.get(CONF_API_PROVIDER, current_provider)
return await self.async_step_settings()
options_schema = vol.Schema({
vol.Optional(
CONF_TEMPERATURE,
default=self.config_entry.options.get(
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
),
description={"suggested_value": DEFAULT_TEMPERATURE},
): vol.All(
vol.Coerce(float),
vol.Range(min=0, max=2),
msg="Temperature must be between 0 and 2"
),
vol.Optional(
CONF_MAX_TOKENS,
default=self.config_entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
),
description={"suggested_value": DEFAULT_MAX_TOKENS},
): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=4096),
msg="Max tokens must be between 1 and 4096"
),
vol.Optional(
CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
),
description={"suggested_value": DEFAULT_REQUEST_INTERVAL},
): vol.All(
vol.Coerce(float),
vol.Range(min=0.1),
msg="Request interval must be at least 0.1 seconds"
),
})
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=options_schema, data_schema=vol.Schema({
vol.Required(
CONF_API_PROVIDER,
default=current_provider
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=API_PROVIDERS,
translation_key="api_provider"
)
),
}),
description_placeholders={
"current_provider": current_provider
}
) )
async def async_step_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle settings configuration step."""
self._errors = {}
current_data = {**self.config_entry.data, **self.config_entry.options}
provider = self._selected_provider or current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
# Determine if provider changed to show appropriate defaults
provider_changed = provider != current_data.get(CONF_API_PROVIDER)
# Use new defaults if provider changed, otherwise use current values
if provider_changed:
default_endpoint = get_default_endpoint(provider)
default_model = get_default_model(provider)
else:
default_endpoint = current_data.get(CONF_API_ENDPOINT, get_default_endpoint(provider))
default_model = current_data.get(CONF_MODEL, get_default_model(provider))
if user_input is not None:
api_key = user_input.get(CONF_API_KEY, "").strip()
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint)
# Require API key re-entry when endpoint or provider changed.
# Why: reusing a stored key after provider/endpoint change could
# ship credentials to a different service (e.g. OpenAI key to
# api.anthropic.com). Always force explicit re-entry.
stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
endpoint_changed = endpoint != stored_endpoint
if not api_key and (provider_changed or endpoint_changed):
self._errors["base"] = "api_key_required"
return self.async_show_form(
step_id="settings",
data_schema=self._get_settings_schema(
provider=provider,
current_data=current_data,
user_input=user_input,
default_endpoint=default_endpoint,
default_model=default_model,
),
errors=self._errors,
description_placeholders={
"provider": provider
}
)
# Fall back to stored key only when neither provider nor endpoint changed.
# Defensive: never silently reuse stored key across providers.
if not api_key and not provider_changed and not endpoint_changed:
api_key = current_data.get(CONF_API_KEY, "")
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
if await self._async_validate_api(provider, api_key, endpoint, allow_local=allow_local):
final_data = {
CONF_API_PROVIDER: provider,
**user_input,
CONF_API_KEY: api_key,
}
return self.async_create_entry(title="", data=final_data)
# Show form again with errors
return self.async_show_form(
step_id="settings",
data_schema=self._get_settings_schema(
provider=provider,
current_data=current_data,
user_input=user_input,
default_endpoint=default_endpoint,
default_model=default_model,
),
errors=self._errors,
description_placeholders={
"provider": provider
}
)
return self.async_show_form(
step_id="settings",
data_schema=self._get_settings_schema(
provider=provider,
current_data=current_data,
user_input=None,
default_endpoint=default_endpoint,
default_model=default_model,
),
description_placeholders={
"provider": provider
}
)
def _get_settings_schema(
self,
provider: str,
current_data: dict[str, Any],
user_input: dict[str, Any] | None,
default_endpoint: str,
default_model: str,
) -> vol.Schema:
"""Build settings schema using shared parameter definitions."""
data = user_input or current_data
schema_dict = {
vol.Optional(CONF_API_KEY, default=""): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(
CONF_API_ENDPOINT,
default=data.get(CONF_API_ENDPOINT, default_endpoint),
): str,
vol.Required(
CONF_MODEL,
default=data.get(CONF_MODEL, default_model),
): str,
vol.Optional(
CONF_ALLOW_LOCAL_NETWORK,
default=data.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
): bool,
}
schema_dict.update(_build_parameter_schema(data))
return vol.Schema(schema_dict)
+110 -76
View File
@@ -1,10 +1,41 @@
"""Constants for the HA text AI integration.""" """
Constants for the HA text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
from typing import Final from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
# Domain and platforms # Domain and platforms
DOMAIN: Final = "ha_text_ai" DOMAIN: Final = "ha_text_ai"
PLATFORMS: Final = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
# Provider configuration
CONF_API_PROVIDER: Final = "api_provider"
API_PROVIDER_OPENAI: Final = "openai"
API_PROVIDER_ANTHROPIC: Final = "anthropic"
API_PROVIDER_DEEPSEEK: Final = "deepseek"
API_PROVIDER_GEMINI: Final = "gemini"
API_PROVIDERS: Final = [
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC,
API_PROVIDER_DEEPSEEK,
API_PROVIDER_GEMINI
]
VERSION: Final = "2.5.1"
# Default endpoints
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
DEFAULT_ANTHROPIC_ENDPOINT: Final = "https://api.anthropic.com"
DEFAULT_DEEPSEEK_ENDPOINT: Final = "https://api.deepseek.com"
DEFAULT_GEMINI_ENDPOINT: Final = "https://generativelanguage.googleapis.com/v1beta"
# Configuration constants # Configuration constants
CONF_MODEL: Final = "model" CONF_MODEL: Final = "model"
@@ -12,25 +43,56 @@ CONF_TEMPERATURE: Final = "temperature"
CONF_MAX_TOKENS: Final = "max_tokens" CONF_MAX_TOKENS: Final = "max_tokens"
CONF_API_ENDPOINT: Final = "api_endpoint" CONF_API_ENDPOINT: Final = "api_endpoint"
CONF_REQUEST_INTERVAL: Final = "request_interval" CONF_REQUEST_INTERVAL: Final = "request_interval"
CONF_API_TIMEOUT: Final = "api_timeout"
CONF_INSTANCE: Final = "instance"
CONF_MAX_HISTORY_SIZE: Final = "max_history_size" # Correct constant name
CONF_CONTEXT_MESSAGES: Final = "context_messages"
CONF_STRUCTURED_OUTPUT: Final = "structured_output"
CONF_JSON_SCHEMA: Final = "json_schema"
CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network"
CONF_DISABLE_THINKING: Final = "disable_thinking"
ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
MAX_ATTRIBUTE_SIZE = 4 * 1024
MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
# Default values # Default values
DEFAULT_MODEL: Final = "gpt-3.5-turbo" DEFAULT_MODEL: Final = "gpt-4o-mini"
DEFAULT_TEMPERATURE: Final = 0.7 DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
# deepseek-chat/deepseek-reasoner are discontinued 2026-07-24; V4 models
# select thinking mode via a request parameter instead of the model name.
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-v4-flash"
# gemini-2.0-flash was shut down 2026-06-01; 2.5-flash follows 2026-10-16.
DEFAULT_GEMINI_MODEL: Final = "gemini-3.5-flash"
DEFAULT_TEMPERATURE: Final = 0.1
DEFAULT_MAX_TOKENS: Final = 1000 DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1"
DEFAULT_REQUEST_INTERVAL: Final = 1.0 DEFAULT_REQUEST_INTERVAL: Final = 1.0
DEFAULT_TIMEOUT: Final = 30 DEFAULT_API_TIMEOUT: Final = 30
DEFAULT_QUEUE_SIZE: Final = 100 DEFAULT_MAX_HISTORY: Final = 50
DEFAULT_HISTORY_LIMIT: Final = 50 DEFAULT_NAME: Final = "HA Text AI"
DEFAULT_NAME_PREFIX = "ha_text_ai"
DEFAULT_INSTANCE_NAME: Final = "my_assistant"
DEFAULT_CONTEXT_MESSAGES: Final = 5
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
DEFAULT_DISABLE_THINKING: Final = False
MIN_CONTEXT_MESSAGES: Final = 1
MAX_CONTEXT_MESSAGES: Final = 20
MIN_HISTORY_SIZE: Final = 1
MAX_HISTORY_SIZE: Final = 100
TRUNCATION_INDICATOR = " ... "
# Parameter constraints # Parameter constraints
MIN_TEMPERATURE: Final = 0.0 MIN_TEMPERATURE: Final = 0.0
MAX_TEMPERATURE: Final = 2.0 MAX_TEMPERATURE: Final = 2.0
MIN_MAX_TOKENS: Final = 1 MIN_MAX_TOKENS: Final = 1
MAX_MAX_TOKENS: Final = 4096 MAX_MAX_TOKENS: Final = 100000
MIN_REQUEST_INTERVAL: Final = 0.1 MIN_REQUEST_INTERVAL: Final = 0.1
MIN_TIMEOUT: Final = 5 MAX_REQUEST_INTERVAL: Final = 60.0
MAX_TIMEOUT: Final = 120 MIN_API_TIMEOUT: Final = 5
MAX_API_TIMEOUT: Final = 600
# API constants
API_RETRY_COUNT: Final = 3
# Service names # Service names
SERVICE_ASK_QUESTION: Final = "ask_question" SERVICE_ASK_QUESTION: Final = "ask_question"
@@ -38,26 +100,48 @@ SERVICE_CLEAR_HISTORY: Final = "clear_history"
SERVICE_GET_HISTORY: Final = "get_history" SERVICE_GET_HISTORY: Final = "get_history"
SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt" SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt"
# Service descriptions
SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Ask a question to the AI model"
SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Clear conversation history"
SERVICE_GET_HISTORY_DESCRIPTION: Final = "Get conversation history"
SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model"
# Attribute keys # Attribute keys
ATTR_QUESTION: Final = "question" ATTR_QUESTION: Final = "question"
ATTR_RESPONSE: Final = "response" ATTR_RESPONSE: Final = "response"
ATTR_LAST_UPDATED: Final = "last_updated" ATTR_INSTANCE: Final = "instance"
ATTR_MODEL: Final = "model" ATTR_MODEL: Final = "model"
ATTR_TEMPERATURE: Final = "temperature" ATTR_TEMPERATURE: Final = "temperature"
ATTR_MAX_TOKENS: Final = "max_tokens" ATTR_MAX_TOKENS: Final = "max_tokens"
ATTR_TOTAL_RESPONSES: Final = "total_responses"
ATTR_SYSTEM_PROMPT: Final = "system_prompt" ATTR_SYSTEM_PROMPT: Final = "system_prompt"
ATTR_RESPONSE_TIME: Final = "response_time"
ATTR_QUEUE_SIZE: Final = "queue_size"
ATTR_API_STATUS: Final = "api_status" ATTR_API_STATUS: Final = "api_status"
ATTR_ERROR_COUNT: Final = "error_count" ATTR_ERROR_COUNT: Final = "error_count"
ATTR_CONVERSATION_HISTORY: Final = "conversation_history"
# Sensor attributes
ATTR_TOTAL_RESPONSES: Final = "total_responses"
ATTR_TOTAL_ERRORS: Final = "total_errors"
ATTR_AVG_RESPONSE_TIME: Final = "average_response_time"
ATTR_LAST_REQUEST_TIME: Final = "last_request_time"
ATTR_LAST_ERROR: Final = "last_error" ATTR_LAST_ERROR: Final = "last_error"
ATTR_IS_PROCESSING: Final = "is_processing"
ATTR_IS_RATE_LIMITED: Final = "is_rate_limited"
ATTR_IS_MAINTENANCE: Final = "is_maintenance"
ATTR_API_VERSION: Final = "api_version"
ATTR_ENDPOINT_STATUS: Final = "endpoint_status"
ATTR_PERFORMANCE_METRICS: Final = "performance_metrics"
ATTR_HISTORY_SIZE: Final = "history_size"
ATTR_UPTIME: Final = "uptime"
ATTR_API_PROVIDER: Final = "api_provider"
ATTR_METRICS: Final = "metrics"
ATTR_STATE: Final = "state"
ATTR_LAST_RESPONSE: Final = "last_response"
ATTR_ERROR: Final = "error"
ATTR_TIMESTAMP: Final = "timestamp"
# Sensor metrics
METRIC_TOTAL_TOKENS: Final = "total_tokens"
METRIC_PROMPT_TOKENS: Final = "prompt_tokens"
METRIC_COMPLETION_TOKENS: Final = "completion_tokens"
METRIC_SUCCESSFUL_REQUESTS: Final = "successful_requests"
METRIC_FAILED_REQUESTS: Final = "failed_requests"
METRIC_AVERAGE_LATENCY: Final = "average_latency"
METRIC_MAX_LATENCY: Final = "max_latency"
METRIC_MIN_LATENCY: Final = "min_latency"
# Error messages # Error messages
ERROR_INVALID_API_KEY: Final = "invalid_api_key" ERROR_INVALID_API_KEY: Final = "invalid_api_key"
@@ -68,72 +152,22 @@ ERROR_RATE_LIMIT: Final = "rate_limit_exceeded"
ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded" ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded"
ERROR_API_ERROR: Final = "api_error" ERROR_API_ERROR: Final = "api_error"
ERROR_TIMEOUT: Final = "timeout_error" ERROR_TIMEOUT: Final = "timeout_error"
ERROR_QUEUE_FULL: Final = "queue_full" ERROR_INVALID_INSTANCE: Final = "invalid_instance"
ERROR_INVALID_PROMPT: Final = "invalid_prompt" ERROR_NAME_EXISTS: Final = "name_exists"
# Configuration descriptions
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
CONF_TEMPERATURE_DESCRIPTION: Final = "Temperature for response generation (0-2)"
CONF_MAX_TOKENS_DESCRIPTION: Final = "Maximum tokens in response (1-4096)"
CONF_API_ENDPOINT_DESCRIPTION: Final = "API endpoint URL"
CONF_REQUEST_INTERVAL_DESCRIPTION: Final = "Minimum time between API requests (seconds)"
# Entity attributes descriptions
ATTR_QUESTION_DESCRIPTION: Final = "Last question asked"
ATTR_RESPONSE_DESCRIPTION: Final = "Last response received"
ATTR_LAST_UPDATED_DESCRIPTION: Final = "Time of last update"
ATTR_MODEL_DESCRIPTION: Final = "Current AI model in use"
ATTR_TEMPERATURE_DESCRIPTION: Final = "Current temperature setting"
ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting"
ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses"
ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt"
ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response"
ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue"
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
# Entity attributes # Entity attributes
ENTITY_NAME: Final = "HA Text AI"
ENTITY_ICON: Final = "mdi:robot" ENTITY_ICON: Final = "mdi:robot"
ENTITY_ICON_ERROR: Final = "mdi:robot-dead" ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited" ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
# Translation keys
TRANSLATION_KEY_CONFIG: Final = "config"
TRANSLATION_KEY_OPTIONS: Final = "options"
TRANSLATION_KEY_ERROR: Final = "error"
TRANSLATION_KEY_STATE: Final = "state"
TRANSLATION_KEY_SERVICES: Final = "services"
# State attributes # State attributes
STATE_READY: Final = "ready" STATE_READY: Final = "ready"
STATE_PROCESSING: Final = "processing" STATE_PROCESSING: Final = "processing"
STATE_ERROR: Final = "error" STATE_ERROR: Final = "error"
STATE_DISCONNECTED: Final = "disconnected"
STATE_RATE_LIMITED: Final = "rate_limited"
STATE_INITIALIZING: Final = "initializing" STATE_INITIALIZING: Final = "initializing"
STATE_MAINTENANCE: Final = "maintenance"
# Logging STATE_RATE_LIMITED: Final = "rate_limited"
LOGGER_NAME: Final = "custom_components.ha_text_ai" STATE_DISCONNECTED: Final = "disconnected"
LOG_LEVEL_DEFAULT: Final = "INFO"
# Queue constants
QUEUE_TIMEOUT: Final = 5
QUEUE_MAX_SIZE: Final = 100
# API constants
API_TIMEOUT: Final = 30
API_RETRY_COUNT: Final = 3
API_BACKOFF_FACTOR: Final = 1.5
# Service schema constants
SCHEMA_QUESTION: Final = "question"
SCHEMA_MODEL: Final = "model"
SCHEMA_TEMPERATURE: Final = "temperature"
SCHEMA_MAX_TOKENS: Final = "max_tokens"
SCHEMA_PROMPT: Final = "prompt"
SCHEMA_LIMIT: Final = "limit"
# Event names # Event names
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received" EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
+385 -142
View File
@@ -1,194 +1,437 @@
"""Data coordinator for HA text AI.""" """
The HA Text AI coordinator.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict, Optional from typing import Any
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
import async_timeout from homeassistant.util import dt as dt_util
from .const import DOMAIN from .const import (
DEFAULT_API_TIMEOUT,
DEFAULT_CONTEXT_MESSAGES,
DEFAULT_DISABLE_THINKING,
DEFAULT_MAX_HISTORY,
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
STATE_ERROR,
STATE_MAINTENANCE,
STATE_PROCESSING,
STATE_RATE_LIMITED,
STATE_READY,
TRUNCATION_INDICATOR,
)
from .history import HistoryManager
from .metrics import MetricsManager
from .utils import normalize_name
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class HATextAICoordinator(DataUpdateCoordinator): class HATextAICoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API.""" """Home Assistant Text AI Conversation Coordinator."""
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
api_key: str, client: Any,
endpoint: str,
model: str, model: str,
temperature: float, update_interval: int,
max_tokens: int, instance_name: str,
request_interval: float, config_entry: ConfigEntry,
session: Optional[Any] = None, max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
max_history_size: int = DEFAULT_MAX_HISTORY,
context_messages: int = DEFAULT_CONTEXT_MESSAGES,
api_timeout: int = DEFAULT_API_TIMEOUT,
disable_thinking: bool = DEFAULT_DISABLE_THINKING,
) -> None: ) -> None:
"""Initialize.""" """Initialize coordinator."""
self.instance_name = instance_name
self.normalized_name = normalize_name(instance_name)
history_dir = os.path.join(
hass.config.path(".storage"), "ha_text_ai_history"
)
metrics_file = os.path.join(
history_dir,
f"ha_text_ai_metrics_{self.normalized_name}.json",
)
# Delegate history and metrics to dedicated managers
self._history = HistoryManager(
hass=hass,
instance_name=instance_name,
normalized_name=self.normalized_name,
history_dir=history_dir,
max_history_size=max_history_size,
)
self._metrics = MetricsManager(
hass=hass,
instance_name=instance_name,
metrics_file=metrics_file,
)
self.hass = hass
self.client = client
self.model = model
self.temperature = temperature
self.max_tokens = max_tokens
self.api_timeout = api_timeout
self.disable_thinking = disable_thinking
# Concurrency control
self._request_lock = asyncio.Lock()
# State flags
self._is_processing = False
self._is_rate_limited = False
self._is_maintenance = False
self.endpoint_status = "ready"
self._system_prompt: str | None = None
self._last_response: dict[str, Any] = {
"timestamp": dt_util.utcnow().isoformat(),
"question": "",
"response": "",
"model": model,
"instance": instance_name,
"normalized_name": self.normalized_name,
"error": None,
}
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=DOMAIN, name=instance_name,
update_interval=timedelta(seconds=request_interval), update_interval=timedelta(seconds=update_interval),
config_entry=config_entry,
) )
self._validate_params(api_key, temperature, max_tokens) self.available = True
self._state = STATE_READY
self._start_time = dt_util.utcnow()
self.context_messages = context_messages
self.api_key = api_key _LOGGER.info("Initialized HA Text AI coordinator: %s", instance_name)
self.endpoint = endpoint
self.model = model
self.temperature = float(temperature)
self.max_tokens = int(max_tokens)
self._question_queue = asyncio.Queue()
self._responses: Dict[str, Any] = {}
self.system_prompt: Optional[str] = None
self._is_ready = False
self._error_count = 0
self._MAX_ERRORS = 3
self.client = AsyncOpenAI( # ------------------------------------------------------------------
api_key=self.api_key, # Convenience accessors for backward compatibility
base_url=self.endpoint, # ------------------------------------------------------------------
http_client=session, @property
) def _conversation_history(self) -> list[dict[str, Any]]:
return self._history.conversation_history
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None: @property
"""Validate initialization parameters.""" def max_history_size(self) -> int:
if not api_key: return self._history.max_history_size
raise ValueError("API key is required")
if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2:
raise ValueError("Temperature must be between 0 and 2")
if not isinstance(max_tokens, int) or max_tokens < 1:
raise ValueError("Max tokens must be a positive integer")
async def _async_update_data(self) -> Dict[str, Any]: # ------------------------------------------------------------------
"""Update data via OpenAI API.""" # Lifecycle
if self._question_queue.empty(): # ------------------------------------------------------------------
return self._responses async def async_initialize(self) -> None:
"""Initialize coordinator: directories, history, metrics. Must be awaited."""
await self._history.async_initialize()
await self._metrics.async_initialize()
async def async_shutdown(self) -> None:
"""Shutdown coordinator."""
_LOGGER.debug("Shutting down coordinator for %s", self.instance_name)
# ------------------------------------------------------------------
# Last response
# ------------------------------------------------------------------
@property
def last_response(self) -> dict[str, Any]:
"""Get the last response."""
return self._last_response
@last_response.setter
def last_response(self, value: dict[str, Any]) -> None:
self._last_response = value
# ------------------------------------------------------------------
# HA state update
# ------------------------------------------------------------------
async def async_update_ha_state(self) -> None:
"""Update Home Assistant state via coordinator refresh."""
try: try:
async with async_timeout.timeout(30): await self.async_request_refresh()
question = await self._question_queue.get() except Exception as err:
try: _LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
response_content = await self._make_api_call(question)
self._responses[question] = {
"question": question,
"response": response_content,
"error": None,
"timestamp": self.hass.loop.time()
}
self._error_count = 0
self._is_ready = True
_LOGGER.debug("Response received for question: %s", question)
except Exception as err: async def _async_update_data(self) -> dict[str, Any]:
self._handle_api_error(question, err) """Update coordinator data."""
finally: try:
self._question_queue.task_done() current_state = self._get_current_state()
history_data = self._history.get_limited_history()
metrics = await self._metrics.get_current_metrics()
return self._responses data = {
"state": current_state,
"metrics": metrics or {},
"last_response": self._get_sanitized_last_response(),
"is_processing": self._is_processing,
"is_rate_limited": self._is_rate_limited,
"is_maintenance": self._is_maintenance,
"endpoint_status": self.endpoint_status,
"uptime": self._calculate_uptime(),
"system_prompt": self._get_truncated_system_prompt(),
"history_size": self._history.history_size,
"conversation_history": history_data["entries"],
"history_info": history_data["info"],
"normalized_name": self.normalized_name,
}
except asyncio.TimeoutError as err: self._validate_update_data(data)
_LOGGER.error("Timeout while processing question") return data
await self._handle_timeout_error()
return self._responses
def _handle_api_error(self, question: str, error: Exception) -> None: except Exception as err:
"""Handle API errors.""" _LOGGER.error("Error updating data: %s", err, exc_info=True)
self._error_count += 1 return self._get_safe_initial_state()
error_msg = str(error)
if isinstance(error, AuthenticationError): # ------------------------------------------------------------------
error_msg = "Authentication failed - invalid API key" # Question processing
self._is_ready = False # ------------------------------------------------------------------
elif isinstance(error, RateLimitError): async def async_ask_question(
error_msg = "Rate limit exceeded" self,
elif isinstance(error, APIError): question: str,
error_msg = f"API error: {error}" model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
system_prompt: str | None = None,
context_messages: int | None = None,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool | None = None,
) -> dict:
"""Process question with context management."""
if self.client is None:
raise HomeAssistantError("AI client not initialized")
self._responses[question] = { async with self._request_lock:
"question": question,
"response": None,
"error": error_msg,
"timestamp": self.hass.loop.time()
}
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
if self._error_count >= self._MAX_ERRORS:
_LOGGER.warning(
"Multiple errors occurred (%d). Coordinator needs attention.",
self._error_count
)
async def _handle_timeout_error(self) -> None:
"""Handle timeout errors."""
self._error_count += 1
if not self._question_queue.empty():
try: try:
# Clear the queue if we have timeout issues self._is_processing = True
while not self._question_queue.empty(): await self.async_update_ha_state()
self._question_queue.get_nowait()
self._question_queue.task_done() temp_context = context_messages if context_messages is not None else self.context_messages
temp_model = model if model is not None else self.model
temp_temperature = temperature if temperature is not None else self.temperature
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
temp_disable_thinking = disable_thinking if disable_thinking is not None else self.disable_thinking
start_time = dt_util.utcnow()
messages = []
if temp_system_prompt:
messages.append({"role": "system", "content": temp_system_prompt})
context_history = self._conversation_history[-temp_context:]
for entry in context_history:
messages.append({"role": "user", "content": entry["question"]})
messages.append({"role": "assistant", "content": entry["response"]})
messages.append({"role": "user", "content": question})
response = await self._send_to_api(
question=question,
model=temp_model,
messages=messages,
temperature=temp_temperature,
max_tokens=temp_max_tokens,
structured_output=structured_output,
json_schema=json_schema,
disable_thinking=temp_disable_thinking,
)
latency = (dt_util.utcnow() - start_time).total_seconds()
await self._metrics.update_metrics(latency, response)
await self._history.update_history(question, response)
return response
except Exception as err: except Exception as err:
_LOGGER.error("Error clearing question queue: %s", err) error_details = await self._metrics.handle_error(err, self.model)
if error_details.get("is_connection_error"):
self.endpoint_status = "unavailable"
self.last_response = error_details
raise HomeAssistantError(f"Failed to process question: {err}") from err
async def _make_api_call(self, question: str) -> str: finally:
"""Make API call to OpenAI.""" self._is_processing = False
await self.async_update_ha_state()
async def _send_to_api(
self,
question: str,
model: str,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
structured_output: bool = False,
json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict:
"""Send request to AI provider and return structured response.
Note: timeout is handled by APIClient via aiohttp ClientTimeout.
No additional asyncio.timeout wrapper to avoid dual timeout stacking.
"""
try: try:
messages = [] response = await self.client.create(
if self.system_prompt: model=model,
messages.append({"role": "system", "content": self.system_prompt})
messages.append({"role": "user", "content": question})
completion = await self.client.chat.completions.create(
model=self.model,
messages=messages, messages=messages,
temperature=self.temperature, temperature=temperature,
max_tokens=self.max_tokens, max_tokens=max_tokens,
structured_output=structured_output,
json_schema=json_schema,
disable_thinking=disable_thinking,
) )
return completion.choices[0].message.content
# Reset error state on success
self._is_rate_limited = False
self.endpoint_status = "ready"
timestamp = dt_util.utcnow().isoformat()
content = response["choices"][0]["message"]["content"]
tokens = {
"prompt": response["usage"]["prompt_tokens"],
"completion": response["usage"]["completion_tokens"],
"total": response["usage"]["total_tokens"],
}
self.last_response = {
"timestamp": timestamp,
"question": question,
"response": content,
"model": model,
"instance": self.instance_name,
"normalized_name": self.normalized_name,
"error": None,
}
return {
"content": content,
"tokens": tokens,
"model": model,
"timestamp": timestamp,
"instance": self.instance_name,
"question": question,
"success": True,
}
except Exception as err: except Exception as err:
_LOGGER.error("Error in API call: %s", err) _LOGGER.error("Error in API call: %s", err)
raise raise
async def async_ask_question(self, question: str) -> None: # ------------------------------------------------------------------
"""Add question to queue.""" # History / prompt delegation
if not self._is_ready and self._error_count >= self._MAX_ERRORS: # ------------------------------------------------------------------
_LOGGER.warning("Coordinator is not ready due to previous errors") async def async_clear_history(self) -> None:
return """Clear conversation history."""
await self._history.async_clear_history()
await self.async_update_ha_state()
await self._question_queue.put(question) async def async_get_history(
await self.async_refresh() self,
limit: int | None = None,
filter_model: str | None = None,
start_date: str | None = None,
include_metadata: bool = False,
sort_order: str = "newest",
) -> list[dict[str, Any]]:
"""Get conversation history with optional filtering."""
return await self._history.async_get_history(
limit=limit,
filter_model=filter_model,
start_date=start_date,
include_metadata=include_metadata,
sort_order=sort_order,
default_model=self.model,
)
async def async_shutdown(self) -> None: async def async_set_system_prompt(self, prompt: str) -> None:
"""Shutdown the coordinator.""" """Set system prompt."""
try: self._system_prompt = prompt
# Clear the queue await self.async_update_ha_state()
while not self._question_queue.empty():
self._question_queue.get_nowait()
self._question_queue.task_done()
await self.client.close() # ------------------------------------------------------------------
self._is_ready = False # Internal helpers
# ------------------------------------------------------------------
def _get_current_state(self) -> str:
if self._is_processing:
return STATE_PROCESSING
if self._is_rate_limited:
return STATE_RATE_LIMITED
if self._is_maintenance:
return STATE_MAINTENANCE
if self.last_response.get("error") or self.last_response.get("error_message"):
return STATE_ERROR
return STATE_READY
except Exception as err: def _get_safe_initial_state(self) -> dict[str, Any]:
_LOGGER.error("Error during shutdown: %s", err) return {
"state": STATE_ERROR,
"metrics": {},
"last_response": self.last_response,
"is_processing": False,
"is_rate_limited": False,
"is_maintenance": False,
"endpoint_status": "error",
"uptime": self._calculate_uptime(),
"system_prompt": None,
"history_size": 0,
"conversation_history": [],
"history_info": {
"total_entries": 0,
"displayed_entries": 0,
},
"normalized_name": self.normalized_name,
}
@property def _get_sanitized_last_response(self) -> dict[str, Any]:
def is_ready(self) -> bool: """Get sanitized version of last response with truncation."""
"""Return if coordinator is ready.""" response = self.last_response.copy()
return self._is_ready
@property for field in ("response", "question"):
def error_count(self) -> int: if field in response and response[field]:
"""Return current error count.""" original = response[field]
return self._error_count truncated = len(original) > 4096
response[field] = (
original[:4096] + TRUNCATION_INDICATOR if truncated else original
)
response[f"is_{field}_truncated"] = truncated
response[f"full_{field}_length"] = len(original)
def reset_error_count(self) -> None: return response
"""Reset error counter."""
self._error_count = 0 def _calculate_uptime(self) -> float:
return (dt_util.utcnow() - self._start_time).total_seconds()
def _get_truncated_system_prompt(self) -> str | None:
if not self._system_prompt:
return None
if len(self._system_prompt) <= 4096:
return self._system_prompt
return self._system_prompt[:4096] + TRUNCATION_INDICATOR
@staticmethod
def _validate_update_data(data: dict[str, Any]) -> None:
for key in ("state", "metrics", "last_response"):
if key not in data:
raise ValueError(f"Missing required key: {key}")
if not isinstance(data["metrics"], dict):
raise ValueError("Invalid metrics format")
+480
View File
@@ -0,0 +1,480 @@
"""
History management for HA Text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import traceback
from datetime import datetime
from typing import Any
import aiofiles
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .const import (
ABSOLUTE_MAX_HISTORY_SIZE,
MAX_ATTRIBUTE_SIZE,
MAX_HISTORY_FILE_SIZE,
TRUNCATION_INDICATOR,
)
# Per-entry storage cap (32KB per field) to prevent disk exhaustion
MAX_STORED_FIELD_SIZE = 32 * 1024
MAX_ARCHIVE_FILES = 3
_LOGGER = logging.getLogger(__name__)
class AsyncFileHandler:
"""Async context manager for file operations."""
def __init__(self, file_path: str, mode: str = "a"):
self.file_path = file_path
self.mode = mode
async def __aenter__(self):
self.file = await aiofiles.open(self.file_path, self.mode)
return self.file
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.file.close()
def _assert_not_symlink(path: str) -> None:
"""Refuse to operate on a path that resolves to a symlink.
Why: another component or an attacker with filesystem access could
replace our history file with a symlink pointing at arbitrary disk
locations. Then os.remove or shutil.move would hit the target
instead of our managed file. Check before destructive ops.
"""
if os.path.islink(path):
raise OSError(f"Refusing to operate on symlink: {path}")
class HistoryManager:
"""Manages conversation history for an instance."""
def __init__(
self,
hass: HomeAssistant,
instance_name: str,
normalized_name: str,
history_dir: str,
max_history_size: int,
) -> None:
self.hass = hass
self.instance_name = instance_name
self.normalized_name = normalized_name
self._history_dir = history_dir
self.max_history_size = min(
max(1, max_history_size), ABSOLUTE_MAX_HISTORY_SIZE
)
self._history_file = os.path.join(
history_dir, f"{normalized_name}_history.json"
)
self._max_history_file_size = MAX_HISTORY_FILE_SIZE
self._conversation_history: list[dict[str, Any]] = []
@property
def conversation_history(self) -> list[dict[str, Any]]:
return self._conversation_history
@property
def history_size(self) -> int:
return len(self._conversation_history)
async def async_initialize(self) -> None:
"""Initialize history: directories, file, migration."""
await self._create_history_dir()
await self._check_history_directory()
await self._initialize_history_file()
await self._migrate_history_from_txt_to_json()
async def _file_exists(self, path: str) -> bool:
try:
return await self.hass.async_add_executor_job(os.path.exists, path)
except Exception as e:
_LOGGER.error("Error checking file existence for %s: %s", path, e)
return False
async def _create_history_dir(self) -> None:
try:
await self.hass.async_add_executor_job(
os.makedirs, self._history_dir, 0o755, True
)
except PermissionError:
_LOGGER.error("Permission denied creating history directory: %s", self._history_dir)
raise
except OSError as e:
_LOGGER.error("Error creating history directory %s: %s", self._history_dir, e)
raise
async def _check_history_directory(self) -> None:
"""Check history directory permissions and writability."""
try:
test_file_path = os.path.join(self._history_dir, ".write_test")
await self.hass.async_add_executor_job(
self._sync_test_directory_write, test_file_path
)
except PermissionError:
_LOGGER.error("No write permissions for history directory: %s", self._history_dir)
except Exception as e:
_LOGGER.error("Error checking history directory: %s", e)
@staticmethod
def _sync_test_directory_write(test_file_path: str) -> None:
try:
os.makedirs(os.path.dirname(test_file_path), mode=0o755, exist_ok=True)
with open(test_file_path, "w") as f:
f.write("Permission test")
os.remove(test_file_path)
except Exception as e:
_LOGGER.error("Directory write test failed: %s", e)
async def _initialize_history_file(self) -> None:
"""Initialize history file and load existing history."""
try:
if await self._file_exists(self._history_file):
async with AsyncFileHandler(self._history_file, "r") as f:
content = await f.read()
if content:
history = json.loads(content)
if isinstance(history, list):
self._conversation_history = history[
-self.max_history_size :
]
_LOGGER.debug(
"Loaded %d history entries for %s",
len(self._conversation_history),
self.instance_name,
)
else:
async with AsyncFileHandler(self._history_file, "w") as f:
await f.write(json.dumps([]))
await self._check_history_size()
except Exception as e:
_LOGGER.error("Could not initialize history file: %s", e)
_LOGGER.debug(traceback.format_exc())
async def update_history(self, question: str, response: dict) -> None:
"""Update conversation history.
In-memory history stores full text for context retrieval.
On-disk storage caps per-field size to prevent disk exhaustion.
Display truncation is handled by get_limited_history().
"""
try:
content = response.get("content", "")
history_entry = {
"timestamp": dt_util.utcnow().isoformat(),
"question": question[:MAX_STORED_FIELD_SIZE],
"response": content[:MAX_STORED_FIELD_SIZE],
}
self._conversation_history.append(history_entry)
while len(self._conversation_history) > self.max_history_size:
self._conversation_history.pop(0)
await self._save_history_to_file()
except Exception as e:
_LOGGER.error("Error updating history: %s", e)
_LOGGER.debug(traceback.format_exc())
async def _save_history_to_file(self) -> None:
"""Serialize in-memory history to file with rotation if needed."""
try:
data = json.dumps(self._conversation_history, indent=2)
data_size = len(data.encode("utf-8"))
if data_size > MAX_HISTORY_FILE_SIZE:
await self._rotate_history()
async with AsyncFileHandler(self._history_file, "w") as f:
await f.write(data)
except Exception as e:
_LOGGER.error("Error writing history file: %s", e)
_LOGGER.debug(traceback.format_exc())
async def _check_history_size(self) -> None:
if len(self._conversation_history) > self.max_history_size:
_LOGGER.warning(
"History size (%d) exceeds maximum (%d). Trimming...",
len(self._conversation_history), self.max_history_size,
)
self._conversation_history = self._conversation_history[
-self.max_history_size :
]
async def _check_file_size(self, file_path: str) -> int:
try:
if await self._file_exists(file_path):
return await self.hass.async_add_executor_job(
os.path.getsize, file_path
)
return 0
except Exception as e:
_LOGGER.error("Error checking file size for %s: %s", file_path, e)
return 0
async def _rotate_history(self) -> None:
try:
_LOGGER.debug("Starting history rotation for %s", self._history_file)
await self._rotate_history_files()
except Exception as e:
_LOGGER.error("Error rotating history: %s", e)
_LOGGER.debug(traceback.format_exc())
async def _rotate_history_files(self) -> None:
"""Rotate history files with size validation."""
try:
if await self._file_exists(self._history_file):
current_size = await self._check_file_size(self._history_file)
if current_size > MAX_HISTORY_FILE_SIZE:
_LOGGER.info(
"Rotating history file. Current size: %d, Max: %d",
current_size, MAX_HISTORY_FILE_SIZE,
)
archive_file = os.path.join(
self._history_dir,
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json",
)
await self.hass.async_add_executor_job(
_assert_not_symlink, self._history_file
)
await self.hass.async_add_executor_job(
shutil.move, self._history_file, archive_file
)
async with AsyncFileHandler(self._history_file, "w") as f:
await f.write(
json.dumps(
self._conversation_history[
-self.max_history_size :
],
indent=2,
)
)
_LOGGER.info("History file rotated to: %s", archive_file)
# Clean up old archive files, keep only MAX_ARCHIVE_FILES
await self._cleanup_archives()
except Exception as e:
_LOGGER.error("History rotation failed: %s", e)
_LOGGER.debug(traceback.format_exc())
async def _cleanup_archives(self) -> None:
"""Remove old archive files beyond MAX_ARCHIVE_FILES."""
try:
prefix = f"{self.normalized_name}_history_"
def find_archives():
archives = []
for f in os.listdir(self._history_dir):
if f.startswith(prefix) and f.endswith(".json") and f != os.path.basename(self._history_file):
archives.append(os.path.join(self._history_dir, f))
archives.sort()
return archives
archives = await self.hass.async_add_executor_job(find_archives)
if len(archives) > MAX_ARCHIVE_FILES:
for old_file in archives[:-MAX_ARCHIVE_FILES]:
await self.hass.async_add_executor_job(
_assert_not_symlink, old_file
)
await self.hass.async_add_executor_job(os.remove, old_file)
_LOGGER.debug("Removed old archive: %s", old_file)
except Exception as e:
_LOGGER.warning("Archive cleanup error: %s", e)
async def _migrate_history_from_txt_to_json(self) -> None:
"""Migrate old .txt history to .json format."""
try:
old_history_file = os.path.join(
self._history_dir, f"{self.normalized_name}_history.txt"
)
if not await self._file_exists(old_history_file):
return
# Skip migration if JSON history already has entries
if self._conversation_history:
_LOGGER.debug(
"JSON history already has %d entries for %s, skipping txt migration",
len(self._conversation_history), self.instance_name,
)
return
_LOGGER.info(
"Found old history file for %s, migrating to JSON", self.instance_name
)
history_entries = []
async with AsyncFileHandler(old_history_file, "r") as f:
content = await f.read()
for line in content.split("\n"):
if not line or line.startswith("History initialized at:"):
continue
try:
parts = line.split(": ", 1)
if len(parts) != 2:
continue
timestamp = parts[0]
content_parts = parts[1].split(" - ")
if len(content_parts) != 2:
continue
question = content_parts[0].replace("Question: ", "")
response = content_parts[1].replace("Response: ", "")
history_entries.append(
{
"timestamp": timestamp,
"question": question,
"response": response,
}
)
except Exception as e:
_LOGGER.warning("Error parsing history line: %s. Error: %s", line, e)
continue
if history_entries:
async with AsyncFileHandler(self._history_file, "w") as f:
await f.write(json.dumps(history_entries, indent=2))
backup_file = old_history_file + ".backup"
await self.hass.async_add_executor_job(
shutil.move, old_history_file, backup_file
)
_LOGGER.info(
"Migrated %d entries from txt to JSON for %s. Old file: %s",
len(history_entries), self.instance_name, backup_file,
)
self._conversation_history = history_entries
except Exception as e:
_LOGGER.error("Error during history migration for %s: %s", self.instance_name, e)
_LOGGER.debug(traceback.format_exc())
async def async_clear_history(self) -> None:
"""Clear conversation history."""
try:
self._conversation_history = []
if await self._file_exists(self._history_file):
await self.hass.async_add_executor_job(
_assert_not_symlink, self._history_file
)
await self.hass.async_add_executor_job(os.remove, self._history_file)
_LOGGER.info("History for %s cleared", self.instance_name)
except Exception as e:
_LOGGER.error("Error clearing history: %s", e)
_LOGGER.debug(traceback.format_exc())
async def async_get_history(
self,
limit: int | None = None,
filter_model: str | None = None,
start_date: str | None = None,
include_metadata: bool = False,
sort_order: str = "newest",
default_model: str = "",
) -> list[dict[str, Any]]:
"""Get conversation history with optional filtering and sorting."""
try:
history = self._conversation_history.copy()
if filter_model:
history = [
entry for entry in history if entry.get("model") == filter_model
]
if start_date:
try:
start_dt = datetime.fromisoformat(
start_date.replace("Z", "+00:00")
)
history = [
entry
for entry in history
if datetime.fromisoformat(
entry["timestamp"].replace("Z", "+00:00")
)
>= start_dt
]
except (ValueError, KeyError) as e:
_LOGGER.warning("Invalid start_date format: %s. Error: %s", start_date, e)
if sort_order == "oldest":
history.sort(key=lambda x: x.get("timestamp", ""))
else:
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
# Clamp limit to ABSOLUTE_MAX_HISTORY_SIZE to prevent pathological
# caller requests from producing multi-MB service payloads.
if limit and limit > 0:
effective_limit = min(int(limit), ABSOLUTE_MAX_HISTORY_SIZE)
history = history[:effective_limit]
if include_metadata:
enriched = []
for entry in history:
enriched_entry = dict(entry)
enriched_entry["metadata"] = {
"entry_size": len(str(entry)),
"question_length": len(entry.get("question", "")),
"response_length": len(entry.get("response", "")),
"model_used": entry.get("model", default_model),
"instance": self.instance_name,
}
enriched.append(enriched_entry)
return enriched
return history
except Exception as e:
_LOGGER.error("Error getting history: %s", e)
return []
def get_limited_history(self, max_display: int = 5) -> dict[str, Any]:
"""Get limited conversation history for sensor attributes.
Returns last `max_display` entries with truncated text for HA state.
"""
recent = self._conversation_history[-max_display:]
limited_history = [
{
"timestamp": entry["timestamp"],
"question": self._truncate_text(entry["question"], 4096),
"response": self._truncate_text(entry["response"], 4096),
}
for entry in recent
]
return {
"entries": limited_history,
"info": {
"total_entries": len(self._conversation_history),
"displayed_entries": len(limited_history),
},
}
@staticmethod
def _truncate_text(text: str, max_length: int = MAX_ATTRIBUTE_SIZE) -> str:
"""Safely truncate text to maximum length with indicator."""
if not text:
return ""
if len(text) <= max_length:
return text
return text[:max_length] + TRUNCATION_INDICATOR
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

+10 -5
View File
@@ -1,14 +1,19 @@
{ {
"domain": "ha_text_ai", "domain": "ha_text_ai",
"name": "HA Text AI", "name": "HA Text AI",
"after_dependencies": ["http"],
"codeowners": ["@smkrv"], "codeowners": ["@smkrv"],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://github.com/smkrv/ha-text-ai/wiki", "documentation": "https://github.com/smkrv/ha-text-ai",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"requirements": ["openai>=1.0.0"], "loggers": ["custom_components.ha_text_ai"],
"ssdp": [], "requirements": [
"version": "1.0.5", "aiofiles>=23.0.0,<25.0.0",
"zeroconf": [] "google-genai>=1.16.0,<2.0.0"
],
"single_config_entry": false,
"version": "2.5.1"
} }
+176
View File
@@ -0,0 +1,176 @@
"""
Metrics management for HA Text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import json
import logging
import os
import re
import traceback
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
DEFAULT_METRICS: dict[str, Any] = {
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"successful_requests": 0,
"failed_requests": 0,
"total_errors": 0,
"average_latency": 0,
"max_latency": 0,
"min_latency": 0,
}
class MetricsManager:
"""Manages performance metrics for an instance."""
def __init__(
self,
hass: HomeAssistant,
instance_name: str,
metrics_file: str,
) -> None:
self.hass = hass
self.instance_name = instance_name
self._metrics_file = metrics_file
self._performance_metrics: dict[str, Any] = DEFAULT_METRICS.copy()
@property
def metrics(self) -> dict[str, Any]:
return self._performance_metrics
async def async_initialize(self) -> None:
"""Load metrics from storage or create defaults."""
loaded = await self._load_metrics()
self._performance_metrics = loaded or DEFAULT_METRICS.copy()
async def _load_metrics(self) -> dict[str, Any] | None:
try:
exists = await self.hass.async_add_executor_job(
os.path.exists, self._metrics_file
)
if exists:
def read_metrics():
with open(self._metrics_file, "r") as f:
try:
return json.load(f)
except json.JSONDecodeError:
_LOGGER.warning("Metrics file corrupted, creating new")
return None
return await self.hass.async_add_executor_job(read_metrics)
except Exception as e:
_LOGGER.warning("Failed to load metrics: %s", e)
return None
async def _save_metrics(self) -> None:
try:
def write_metrics():
with open(self._metrics_file, "w") as f:
json.dump(self._performance_metrics, f)
await self.hass.async_add_executor_job(write_metrics)
except Exception as e:
_LOGGER.warning("Failed to save metrics: %s", e)
async def update_metrics(self, latency: float, response: dict) -> None:
"""Update performance metrics after a successful request."""
metrics = self._performance_metrics
tokens = response.get("tokens", {})
metrics["total_tokens"] += tokens.get("total", 0)
metrics["prompt_tokens"] += tokens.get("prompt", 0)
metrics["completion_tokens"] += tokens.get("completion", 0)
metrics["successful_requests"] += 1
metrics["average_latency"] = (
(metrics["average_latency"] * (metrics["successful_requests"] - 1) + latency)
/ metrics["successful_requests"]
)
metrics["max_latency"] = max(metrics["max_latency"], latency)
if metrics["min_latency"] == 0:
metrics["min_latency"] = latency
else:
metrics["min_latency"] = min(metrics["min_latency"], latency)
await self._save_metrics()
async def get_current_metrics(self) -> dict[str, Any]:
"""Get current performance metrics."""
return self._performance_metrics.copy()
async def handle_error(
self,
error: Exception,
model: str,
) -> dict[str, Any]:
"""Record an error in metrics and return error details."""
self._performance_metrics["total_errors"] += 1
self._performance_metrics["failed_requests"] += 1
await self._save_metrics()
error_msg = str(error)
# Strip URLs, API keys, tokens, and query parameters from error messages.
# Patterns use word boundaries and explicit length bounds so that
# overly greedy matches don't accidentally swallow adjacent text.
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
# Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
error_msg = re.sub(
r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
)
# Anthropic / OpenAI / DeepSeek format: "sk-..." (anchors on word boundary).
error_msg = re.sub(r'\bsk-[A-Za-z0-9_\-]{20,}\b', '***', error_msg)
# Bearer tokens: header-style and JSON-embedded ("Bearer xxx").
error_msg = re.sub(r'[Bb]earer\s+[A-Za-z0-9_\-\.=]+', 'Bearer ***', error_msg)
# x-api-key header in any case, both raw and JSON-serialized forms.
error_msg = re.sub(
r'"?x-api-key"?\s*[:=]\s*"?[A-Za-z0-9_\-\.]+"?',
'x-api-key: ***',
error_msg,
flags=re.IGNORECASE,
)
if len(error_msg) > 256:
error_msg = error_msg[:256] + "..."
error_details: dict[str, Any] = {
"timestamp": dt_util.utcnow().isoformat(),
"model": model,
"instance": self.instance_name,
"error_message": error_msg,
"error_type": type(error).__name__,
"traceback": traceback.format_exc()
if _LOGGER.isEnabledFor(logging.DEBUG)
else None,
}
error_mapping = {
HomeAssistantError: {"is_ha_error": True},
ConnectionError: {"is_connection_error": True},
TimeoutError: {"is_timeout": True},
PermissionError: {"is_permission_denied": True},
ValueError: {"is_validation_error": True},
}
for error_type, error_flags in error_mapping.items():
if isinstance(error, error_type):
error_details.update(error_flags)
break
_LOGGER.error("AI Processing Error: %s", error_details)
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Full Error Traceback: %s", error_details.get("traceback"))
return error_details
+93
View File
@@ -0,0 +1,93 @@
"""
Provider registry for HA Text AI integration.
Centralizes provider-specific configuration to avoid dispatch duplication
across __init__.py, config_flow.py, and api_client.py.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
from typing import Any
from .const import (
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC,
API_PROVIDER_DEEPSEEK,
API_PROVIDER_GEMINI,
DEFAULT_MODEL,
DEFAULT_ANTHROPIC_MODEL,
DEFAULT_DEEPSEEK_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_OPENAI_ENDPOINT,
DEFAULT_ANTHROPIC_ENDPOINT,
DEFAULT_DEEPSEEK_ENDPOINT,
DEFAULT_GEMINI_ENDPOINT,
)
PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
API_PROVIDER_OPENAI: {
"default_model": DEFAULT_MODEL,
"default_endpoint": DEFAULT_OPENAI_ENDPOINT,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"check_path": "/models",
},
API_PROVIDER_ANTHROPIC: {
"default_model": DEFAULT_ANTHROPIC_MODEL,
"default_endpoint": DEFAULT_ANTHROPIC_ENDPOINT,
"auth_header": "x-api-key",
"auth_prefix": "",
"check_path": "/v1/models",
"extra_headers": {
"anthropic-version": "2023-06-01",
},
},
API_PROVIDER_DEEPSEEK: {
"default_model": DEFAULT_DEEPSEEK_MODEL,
"default_endpoint": DEFAULT_DEEPSEEK_ENDPOINT,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"check_path": "/models",
},
API_PROVIDER_GEMINI: {
"default_model": DEFAULT_GEMINI_MODEL,
"default_endpoint": DEFAULT_GEMINI_ENDPOINT,
"auth_header": "Authorization",
"auth_prefix": "Bearer ",
"check_path": None, # Gemini does not support /models check
},
}
def get_provider_config(provider: str) -> dict[str, Any]:
"""Get full provider configuration.
Raises ValueError for unknown providers to avoid sending
credentials to the wrong endpoint.
"""
if provider not in PROVIDER_REGISTRY:
raise ValueError(f"Unknown API provider: {provider}")
return PROVIDER_REGISTRY[provider]
def get_default_endpoint(provider: str) -> str:
"""Get default API endpoint for a provider."""
return get_provider_config(provider)["default_endpoint"]
def get_default_model(provider: str) -> str:
"""Get default model for a provider."""
return get_provider_config(provider)["default_model"]
def build_auth_headers(provider: str, api_key: str) -> dict[str, str]:
"""Build authentication headers for a provider."""
config = get_provider_config(provider)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
headers[config["auth_header"]] = f"{config['auth_prefix']}{api_key}"
if "extra_headers" in config:
headers.update(config["extra_headers"])
return headers
+301 -117
View File
@@ -1,64 +1,113 @@
"""Sensor platform for HA text AI.""" """
from datetime import datetime Sensor platform for HA Text AI.
import logging
from typing import Any, Dict, Optional
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import logging
import math
from typing import Any
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorStateClass, SensorEntityDescription,
SensorDeviceClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util import slugify
from .const import ( from .const import (
DOMAIN, DOMAIN,
ATTR_QUESTION, CONF_MODEL,
ATTR_RESPONSE, CONF_API_PROVIDER,
ATTR_LAST_UPDATED,
ATTR_MODEL,
ATTR_TEMPERATURE,
ATTR_MAX_TOKENS,
ATTR_TOTAL_RESPONSES, ATTR_TOTAL_RESPONSES,
ATTR_SYSTEM_PROMPT, ATTR_TOTAL_ERRORS,
ATTR_QUEUE_SIZE, ATTR_AVG_RESPONSE_TIME,
ATTR_API_STATUS, ATTR_LAST_REQUEST_TIME,
ATTR_ERROR_COUNT,
ATTR_LAST_ERROR, ATTR_LAST_ERROR,
ATTR_RESPONSE_TIME, ATTR_IS_PROCESSING,
ENTITY_ICON, ATTR_IS_RATE_LIMITED,
ENTITY_ICON_ERROR, ATTR_IS_MAINTENANCE,
ENTITY_ICON_PROCESSING, ATTR_API_VERSION,
ATTR_ENDPOINT_STATUS,
ATTR_PERFORMANCE_METRICS,
ATTR_HISTORY_SIZE,
ATTR_UPTIME,
ATTR_API_PROVIDER,
ATTR_MODEL,
ATTR_SYSTEM_PROMPT,
ATTR_RESPONSE,
ATTR_QUESTION,
METRIC_TOTAL_TOKENS,
METRIC_PROMPT_TOKENS,
METRIC_COMPLETION_TOKENS,
METRIC_SUCCESSFUL_REQUESTS,
METRIC_FAILED_REQUESTS,
METRIC_AVERAGE_LATENCY,
METRIC_MAX_LATENCY,
METRIC_MIN_LATENCY,
STATE_READY, STATE_READY,
STATE_PROCESSING, STATE_PROCESSING,
STATE_ERROR, STATE_ERROR,
STATE_DISCONNECTED,
STATE_RATE_LIMITED,
STATE_INITIALIZING, STATE_INITIALIZING,
STATE_MAINTENANCE,
STATE_RATE_LIMITED,
STATE_DISCONNECTED,
ENTITY_ICON,
ENTITY_ICON_ERROR,
ENTITY_ICON_PROCESSING,
DEFAULT_NAME_PREFIX,
CONF_MAX_HISTORY_SIZE,
VERSION,
) )
from .coordinator import HATextAICoordinator from .coordinator import HATextAICoordinator
from .utils import safe_log_data
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# HA Recorder limit is 16384 bytes for state_attributes.
# Budget per field to stay well within the limit.
_ATTR_TEXT_LIMIT = 2048
_ATTR_PROMPT_LIMIT = 512
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the HA text AI sensor.""" """Set up the HA Text AI sensor."""
coordinator = hass.data[DOMAIN][entry.entry_id] _LOGGER.debug("Starting sensor setup for entry: %s", entry.entry_id)
async_add_entities([HATextAISensor(coordinator, entry)], True)
try:
coordinator = hass.data[DOMAIN][entry.entry_id]
_LOGGER.debug("Found coordinator for entry %s", entry.entry_id)
instance_name = coordinator.instance_name
_LOGGER.debug("Setting up sensor with instance: %s", instance_name)
sensor = HATextAISensor(coordinator, entry)
_LOGGER.debug("Created sensor instance: %s", sensor.entity_id)
async_add_entities([sensor], True)
_LOGGER.debug("Added sensor entity: %s", sensor.entity_id)
except Exception as err:
_LOGGER.exception("Error setting up sensor: %s", err)
raise
class HATextAISensor(CoordinatorEntity, SensorEntity): class HATextAISensor(CoordinatorEntity, SensorEntity):
"""HA text AI Sensor.""" """HA Text AI Sensor."""
_attr_has_entity_name = True coordinator: HATextAICoordinator
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__( def __init__(
self, self,
@@ -66,116 +115,251 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
_LOGGER.debug("Initializing sensor with config entry: %s", safe_log_data(dict(config_entry.data)))
super().__init__(coordinator) super().__init__(coordinator)
self._config_entry = config_entry self._config_entry = config_entry
self._attr_unique_id = f"{config_entry.entry_id}" self._instance_name = coordinator.instance_name
self._attr_name = "Last Response" self._normalized_name = coordinator.normalized_name
self._attr_suggested_display_precision = 0
_LOGGER.debug("Instance name: %s", self._instance_name)
_LOGGER.debug("Normalized name: %s", self._normalized_name)
self._conversation_history = []
self._system_prompt = None
self._attr_has_entity_name = True
self._attr_name = self._instance_name
self.entity_id = f"sensor.ha_text_ai_{self._normalized_name}"
self._attr_unique_id = config_entry.entry_id
_LOGGER.debug("Created sensor with entity_id: %s", self.entity_id)
_LOGGER.debug("Sensor name: %s", self._attr_name)
_LOGGER.debug("Unique ID: %s", self._attr_unique_id)
self.entity_description = SensorEntityDescription(
key=f"ha_text_ai_{self._normalized_name.lower()}",
entity_registry_enabled_default=True,
)
self._current_state = STATE_INITIALIZING
self._error_count = 0 self._error_count = 0
self._last_error = None self._last_error = None
self._state = STATE_INITIALIZING self._last_update = None
self._is_processing = False
self._last_response = {}
self._metrics = {}
@property model = config_entry.data.get(CONF_MODEL, "Unknown")
def icon(self) -> str: api_provider = config_entry.data.get(CONF_API_PROVIDER, "Unknown")
"""Return the icon based on the current state."""
if self._state == STATE_PROCESSING:
return ENTITY_ICON_PROCESSING
elif self._state in [STATE_ERROR, STATE_DISCONNECTED, STATE_RATE_LIMITED]:
return ENTITY_ICON_ERROR
return ENTITY_ICON
@property self._attr_device_info = DeviceInfo(
def state(self) -> StateType: identifiers={(DOMAIN, self._attr_unique_id)},
"""Return the state of the sensor.""" name=self._attr_name,
if not self.coordinator.data or not self.coordinator.last_update_success_time: manufacturer="Community",
return None model=f"{model} ({api_provider} provider)",
sw_version=VERSION,
entry_type=DeviceEntryType.SERVICE,
)
try: _LOGGER.debug(
if isinstance(self.coordinator.last_update_success_time, datetime): "Initialized sensor: %s for instance: %s",
return dt_util.as_local(self.coordinator.last_update_success_time) self.entity_id, self._instance_name,
return self.coordinator.last_update_success_time )
except Exception as err:
_LOGGER.error("Error getting state: %s", err, exc_info=True)
return None
@property
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return entity specific state attributes."""
attributes = {
ATTR_TOTAL_RESPONSES: 0,
ATTR_MODEL: self.coordinator.model,
ATTR_TEMPERATURE: self.coordinator.temperature,
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
ATTR_SYSTEM_PROMPT: self.coordinator.system_prompt,
ATTR_QUEUE_SIZE: self.coordinator._question_queue.qsize(),
ATTR_API_STATUS: self._state,
ATTR_ERROR_COUNT: self._error_count,
ATTR_LAST_ERROR: self._last_error,
}
if not self.coordinator.data:
return attributes
try:
history = list(self.coordinator.data.items())
if history:
last_question, last_data = history[-1]
# Handle different response formats
if isinstance(last_data, dict):
last_response = last_data.get("response", "")
last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time)
response_time = last_data.get("response_time")
else:
last_response = str(last_data)
last_updated = self.coordinator.last_update_success_time
response_time = None
# Convert timestamp to local time if needed
if isinstance(last_updated, datetime):
last_updated = dt_util.as_local(last_updated)
attributes.update({
ATTR_QUESTION: last_question,
ATTR_RESPONSE: last_response,
ATTR_LAST_UPDATED: last_updated,
ATTR_TOTAL_RESPONSES: len(history),
})
if response_time is not None:
attributes[ATTR_RESPONSE_TIME] = response_time
return attributes
except Exception as err:
_LOGGER.error("Error getting attributes: %s", err, exc_info=True)
self._error_count += 1
self._last_error = str(err)
self._state = STATE_ERROR
return attributes
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return self.coordinator.last_update_success return (
self.coordinator.last_update_success
and self.coordinator.data is not None
and self._current_state != STATE_DISCONNECTED
)
def _sanitize_value(self, value: Any) -> Any:
"""Sanitize values for JSON serialization."""
if isinstance(value, float):
if math.isinf(value) or math.isnan(value):
return None
return value
def _sanitize_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]:
"""Sanitize all attributes for JSON serialization."""
sanitized = {
key: self._sanitize_value(value)
for key, value in attributes.items()
if value is not None
}
# Log metrics for debugging
metrics_keys = [
METRIC_TOTAL_TOKENS,
METRIC_PROMPT_TOKENS,
METRIC_COMPLETION_TOKENS,
METRIC_SUCCESSFUL_REQUESTS,
METRIC_FAILED_REQUESTS,
METRIC_AVERAGE_LATENCY,
METRIC_MAX_LATENCY,
METRIC_MIN_LATENCY,
]
metrics_values = {k: sanitized.get(k) for k in metrics_keys if k in sanitized}
_LOGGER.debug("Metrics for %s: %s", self.entity_id, metrics_values)
return sanitized
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
if not self.coordinator.last_update_success or not self.coordinator.data:
self._current_state = STATE_DISCONNECTED
return self._current_state
status = self.coordinator.data.get("state", STATE_READY)
self._current_state = status
return status
@property
def icon(self) -> str:
"""Return the icon based on the current state."""
if self._current_state == STATE_ERROR:
return ENTITY_ICON_ERROR
elif self._current_state == STATE_PROCESSING:
return ENTITY_ICON_PROCESSING
return ENTITY_ICON
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
if not self.coordinator.data:
return {}
try:
data = self.coordinator.data
metrics = data.get("metrics", {})
# Base attributes
attributes = {
ATTR_MODEL: self._config_entry.data.get(CONF_MODEL, "Unknown"),
ATTR_API_PROVIDER: self._config_entry.data.get(CONF_API_PROVIDER, "Unknown"),
ATTR_TOTAL_ERRORS: metrics.get("total_errors", 0),
"instance_name": self._instance_name,
"normalized_name": self._normalized_name,
ATTR_SYSTEM_PROMPT: (data.get("system_prompt", "")[:_ATTR_PROMPT_LIMIT]
if data.get("system_prompt") else None),
ATTR_IS_PROCESSING: data.get("is_processing", False),
ATTR_IS_RATE_LIMITED: data.get("is_rate_limited", False),
ATTR_IS_MAINTENANCE: data.get("is_maintenance", False),
ATTR_ENDPOINT_STATUS: data.get("endpoint_status", "unknown"),
ATTR_UPTIME: round(data.get("uptime", 0), 2),
ATTR_HISTORY_SIZE: data.get("history_size", 0),
}
# Conversation history preview (compact: last 3, truncated to 256 chars).
# Full history is available via ha_text_ai.get_history service.
conversation_history = data.get("conversation_history", [])
if conversation_history:
preview = conversation_history[-3:]
attributes["conversation_history"] = [
{
"timestamp": entry["timestamp"],
"question": entry["question"][:256],
"response": entry["response"][:256],
}
for entry in preview
]
# Metrics
if isinstance(metrics, dict):
attributes.update({
METRIC_TOTAL_TOKENS: metrics.get("total_tokens", 0),
METRIC_PROMPT_TOKENS: metrics.get("prompt_tokens", 0),
METRIC_COMPLETION_TOKENS: metrics.get("completion_tokens", 0),
METRIC_SUCCESSFUL_REQUESTS: metrics.get("successful_requests", 0),
METRIC_FAILED_REQUESTS: metrics.get("failed_requests", 0),
METRIC_AVERAGE_LATENCY: round(metrics.get("average_latency", 0), 2),
METRIC_MAX_LATENCY: round(metrics.get("max_latency", 0), 2),
METRIC_MIN_LATENCY: metrics.get("min_latency", 0) or None,
})
# Last response handling
last_response = data.get("last_response", {})
if isinstance(last_response, dict):
attributes.update({
ATTR_RESPONSE: last_response.get("response", "")[:_ATTR_TEXT_LIMIT],
ATTR_QUESTION: last_response.get("question", "")[:_ATTR_TEXT_LIMIT],
"last_model": last_response.get("model", ""),
"last_timestamp": last_response.get("timestamp", ""),
"last_error": (last_response.get("error", "")[:_ATTR_TEXT_LIMIT]
if last_response.get("error") else None),
})
return self._sanitize_attributes(attributes)
except Exception as err:
_LOGGER.error("Error preparing attributes: %s", err, exc_info=True)
return {}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
self._handle_coordinator_update() self._handle_coordinator_update()
self._state = STATE_READY _LOGGER.debug("Entity %s added to Home Assistant", self.entity_id)
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
try: try:
if self.coordinator.data: data = self.coordinator.data
self._state = STATE_READY if not self.coordinator.last_update_success or not data:
self._current_state = STATE_DISCONNECTED
_LOGGER.warning("No data available for %s", self.entity_id)
self.async_write_ha_state()
return
self._is_processing = data.get("is_processing", False)
# Update metrics
metrics = data.get("metrics", {})
if isinstance(metrics, dict):
self._metrics.update(metrics)
_LOGGER.debug("Updated metrics for %s: %s", self.entity_id, self._metrics)
# Update conversation history and system prompt
self._conversation_history = data.get("conversation_history", [])
self._system_prompt = data.get("system_prompt")
# Update state based on conditions
if self._is_processing:
self._current_state = STATE_PROCESSING
elif data.get("is_rate_limited"):
self._current_state = STATE_RATE_LIMITED
elif data.get("is_maintenance"):
self._current_state = STATE_MAINTENANCE
elif data.get("error"):
self._current_state = STATE_ERROR
self._last_error = data["error"]
self._error_count += 1
else: else:
self._state = STATE_DISCONNECTED self._current_state = data.get("state", STATE_READY)
# Update last update timestamp
self._last_update = dt_util.utcnow()
_LOGGER.debug(
"Updated %s state to: %s (available: %s)",
self.entity_id, self._current_state, self.available,
)
except Exception as err: except Exception as err:
_LOGGER.error("Error handling update: %s", err, exc_info=True) self._current_state = STATE_ERROR
self._error_count += 1
self._last_error = str(err) self._last_error = str(err)
self._state = STATE_ERROR self._error_count += 1
_LOGGER.error(
"Error handling update for %s: %s",
self.entity_id,
err,
exc_info=True,
)
self.async_write_ha_state() self.async_write_ha_state()
+128 -101
View File
@@ -3,59 +3,56 @@ ask_question:
description: >- description: >-
Send a question to the AI model and receive a detailed response. Send a question to the AI model and receive a detailed response.
The response will be stored in the conversation history and can be retrieved later. The response will be stored in the conversation history and can be retrieved later.
Response time may vary based on model selection and server load. This service now returns response data directly, eliminating the need to read from sensors.
fields: fields:
instance:
name: Instance
description: Name of the HA Text AI instance to use
required: true
selector:
entity:
integration: ha_text_ai
domain: sensor
question: question:
name: Question name: Question
description: >- description: Your question or prompt for the AI assistant
Your question or prompt for the AI assistant. Be specific and clear for better results.
You can ask about home automation, technical advice, or general questions.
For complex queries, consider breaking them into smaller parts.
required: true required: true
example: |
What automations would you recommend for a smart kitchen?
Consider energy efficiency, convenience, and integration with:
- Smart lighting
- Appliance control
- Temperature monitoring
- Voice commands
selector: selector:
text: text:
multiline: true multiline: true
type: text type: text
system_prompt:
name: System Prompt
description: Optional system prompt to set context for this specific question
required: false
selector:
text:
multiline: true
context_messages:
name: Context Messages
description: Number of previous messages to include in context (1-20)
required: false
default: 5
selector:
number:
min: 1
max: 20
step: 1
mode: slider
model: model:
name: Model name: Model
description: >- description: "Select AI model to use (optional, overrides default setting)"
Select an AI model to use (optional, overrides default setting).
Different models have different capabilities and token limits.
Note: More capable models may have longer response times and higher API costs.
required: false required: false
example: "gpt-3.5-turbo"
default: "gpt-3.5-turbo"
selector: selector:
select: text: {}
options:
- label: "GPT-3.5 Turbo (Fast & Efficient)"
value: "gpt-3.5-turbo"
- label: "GPT-3.5 Turbo 16K (Extended)"
value: "gpt-3.5-turbo-16k"
- label: "GPT-4 (Most Capable)"
value: "gpt-4"
- label: "GPT-4 32K (Extended Context)"
value: "gpt-4-32k"
- label: "GPT-4 Turbo (Latest)"
value: "gpt-4-1106-preview"
mode: dropdown
temperature: temperature:
name: Temperature name: Temperature
description: >- description: Controls response creativity (0.0-2.0)
Controls response creativity (0-2):
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
0.4-0.7: Balanced responses (recommended for most uses)
0.8-2.0: More creative, varied responses (best for brainstorming)
Note: Higher values may produce less predictable results.
required: false required: false
default: 0.7 default: 0.7
selector: selector:
@@ -64,46 +61,76 @@ ask_question:
max: 2.0 max: 2.0
step: 0.1 step: 0.1
mode: slider mode: slider
unit_of_measurement: ""
max_tokens: max_tokens:
name: Max Tokens name: Max Tokens
description: >- description: Maximum length of the response (tokens)
Maximum length of the response. Higher values allow longer responses but use more API tokens.
Recommended ranges:
- Short responses (256-512): Quick answers, status updates
- Medium responses (512-1024): Detailed explanations, instructions
- Long responses (1024-4096): Complex analysis, multiple examples
Note: Actual response length may be shorter based on content.
required: false required: false
default: 1000 default: 1000
selector: selector:
number: number:
min: 1 min: 1
max: 4096 max: 100000
step: 1 step: 1
mode: box mode: box
structured_output:
name: Structured Output
description: Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema.
required: false
default: false
selector:
boolean: {}
json_schema:
name: JSON Schema
description: >-
JSON Schema defining the structure of the expected response.
Required when structured_output is enabled.
required: false
selector:
text:
multiline: true
disable_thinking:
name: Disable Thinking
description: >-
Disable model thinking/reasoning for this request.
Overrides the integration-level setting when provided.
required: false
selector:
boolean: {}
clear_history: clear_history:
name: Clear History name: Clear History
description: >- description: >-
Delete all stored questions and responses from the conversation history. Delete all stored questions and responses from the conversation history
This action cannot be undone. Consider using 'get_history' first if you need to backup the data. fields:
System prompt settings will be preserved. instance:
fields: {} name: Instance
description: Name of the HA Text AI instance to clear history for
required: true
selector:
entity:
integration: ha_text_ai
domain: sensor
get_history: get_history:
name: Get History name: Get History
description: >- description: Retrieve conversation history with optional filtering and sorting
Retrieve recent conversation history, including questions, responses, and timestamps.
Results are ordered from newest to oldest and include metadata like model used and response times.
fields: fields:
instance:
name: Instance
description: Name of the HA Text AI instance to get history from
required: true
selector:
entity:
integration: ha_text_ai
domain: sensor
limit: limit:
name: Limit name: Limit
description: >- description: Number of conversations to return (1-100)
Number of most recent conversations to return (1-100).
Higher values return more history but may take longer to process.
Default: 10 conversations
required: false required: false
default: 10 default: 10
selector: selector:
@@ -114,57 +141,57 @@ get_history:
mode: box mode: box
filter_model: filter_model:
name: Filter by Model name: Filter Model
description: >- description: Filter conversations by specific AI model
Only return conversations using a specific AI model.
Leave empty to show all models.
required: false required: false
selector: selector:
select: text:
options: multiline: false
- label: "All Models"
value: ""
- label: "GPT-3.5 Turbo"
value: "gpt-3.5-turbo"
- label: "GPT-4"
value: "gpt-4"
mode: dropdown
set_system_prompt: start_date:
name: Set System Prompt name: Start Date
description: >- description: Filter conversations starting from this date/time
Configure the AI's behavior by setting a system prompt. required: false
This affects how the AI interprets and responds to all future questions.
The prompt will persist until changed or cleared.
fields:
prompt:
name: System Prompt
description: >-
Instructions that define how the AI should behave and respond.
Be specific about the desired expertise, tone, and format of responses.
Maximum length: 1000 characters.
required: true
example: |
You are a home automation expert assistant. Focus on:
1. Practical and efficient solutions
2. Energy-saving recommendations
3. Integration with popular smart home platforms
4. Security and privacy considerations
Provide detailed but concise responses with clear steps when applicable.
Format complex responses with bullet points or numbered lists.
Include warnings about potential risks or limitations.
selector: selector:
text: text:
multiline: true multiline: false
type: text
max_length: 1000
clear_prompt: include_metadata:
name: Clear Existing Prompt name: Include Metadata
description: >- description: Include additional information like tokens used, response time, etc.
Set to true to remove the current system prompt before applying the new one.
This ensures no conflicting instructions remain.
required: false required: false
default: false default: false
selector: selector:
boolean: {} boolean: {}
sort_order:
name: Sort Order
description: Sort order for results (newest or oldest first)
required: false
default: newest
selector:
select:
options:
- newest
- oldest
set_system_prompt:
name: Set System Prompt
description: Set default system behavior instructions for all future conversations
fields:
instance:
name: Instance
description: Name of the HA Text AI instance to set system prompt for
required: true
selector:
entity:
integration: ha_text_ai
domain: sensor
prompt:
name: System Prompt
description: Instructions that define how the AI should behave and respond
required: true
selector:
text:
multiline: true
+336
View File
@@ -0,0 +1,336 @@
{
"config": {
"step": {
"provider": {
"title": "Provider Settings",
"description": "Provide connection details for your chosen AI provider.",
"data": {
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
"api_key": "API key for authentication",
"model": "AI model to use",
"api_endpoint": "Custom API endpoint URL (optional)",
"temperature": "Response creativity (0-2, lower = more focused)",
"max_tokens": "Maximum response length (1-100000 tokens)",
"request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "Configure HA Text AI Instance",
"description": "Set up a new AI assistant instance with your selected provider.",
"data": {
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
"api_key": "API key for authentication",
"model": "AI model to use",
"temperature": "Response creativity (0-2, lower = more focused)",
"max_tokens": "Maximum response length (1-100000 tokens)",
"api_endpoint": "Custom API endpoint URL (optional)",
"api_provider": "API Provider",
"request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "Failed to initialize history storage. Check permissions.",
"history_rotation_error": "Error during history file rotation.",
"history_file_access_error": "Cannot access history storage directory.",
"name_exists": "An instance with this name already exists",
"invalid_name": "Invalid instance name",
"invalid_auth": "Authentication failed - check your API key",
"api_key_required": "API key is required when changing provider or endpoint",
"invalid_api_key": "Invalid API key - please verify your credentials",
"cannot_connect": "Failed to connect to API service",
"invalid_model": "Selected model is not available",
"rate_limit": "Rate limit exceeded",
"context_length": "Context length exceeded",
"rate_limit_exceeded": "API rate limit exceeded",
"maintenance": "Service is under maintenance",
"invalid_response": "Invalid API response received",
"api_error": "API service error occurred",
"timeout": "Request timed out",
"invalid_instance": "Invalid instance specified",
"unknown": "Unexpected error occurred",
"empty": "Name cannot be empty",
"name_too_long": "Name must be 50 characters or less"
},
"abort": {
"already_configured": "Instance already configured"
}
},
"options": {
"step": {
"init": {
"title": "Select Provider",
"description": "Choose the AI provider for this instance. The integration will reload after saving changes.",
"data": {
"api_provider": "API Provider"
}
},
"settings": {
"title": "Connection & Model Settings",
"description": "Configure API credentials and model parameters. Changes will take effect after the integration reloads.",
"data": {
"api_key": "API Key",
"api_endpoint": "API Endpoint URL",
"model": "AI model",
"temperature": "Response creativity (0-2)",
"max_tokens": "Maximum response length (1-100000)",
"request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatible)",
"anthropic": "Anthropic (compatible)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Ask Question (HA Text AI)",
"description": "Send a question to the AI model and receive a detailed response. This service now returns response data directly, eliminating the need for separate text sensors and the 255-character limitation. The response will also be stored in the conversation history.",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to use"
},
"question": {
"name": "Question",
"description": "Your question or prompt for the AI assistant"
},
"context_messages": {
"name": "Context Messages",
"description": "Number of previous messages to include in context (1-20)"
},
"system_prompt": {
"name": "System Prompt",
"description": "Optional system prompt to set context for this specific question"
},
"model": {
"name": "Model",
"description": "Select AI model to use (optional, overrides default setting)"
},
"temperature": {
"name": "Temperature",
"description": "Controls response creativity (0.0-2.0)"
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum length of the response (1-100000 tokens)"
},
"structured_output": {
"name": "Structured Output",
"description": "Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema."
},
"json_schema": {
"name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
},
"disable_thinking": {
"name": "Disable Thinking",
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored questions and responses from the conversation history",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to clear history for"
}
}
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history with optional filtering and sorting",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to get history from"
},
"limit": {
"name": "Limit",
"description": "Number of conversations to return (1-100)"
},
"filter_model": {
"name": "Filter Model",
"description": "Filter conversations by specific AI model"
},
"start_date": {
"name": "Start Date",
"description": "Filter conversations starting from this date/time"
},
"include_metadata": {
"name": "Include Metadata",
"description": "Include additional information like tokens used, response time, etc."
},
"sort_order": {
"name": "Sort Order",
"description": "Sort order for results (newest or oldest first)"
}
}
},
"set_system_prompt": {
"name": "Set System Prompt",
"description": "Set default system behavior instructions for all future conversations",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to set system prompt for"
},
"prompt": {
"name": "System Prompt",
"description": "Instructions that define how the AI should behave and respond"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Ready",
"processing": "Processing",
"error": "Error",
"disconnected": "Disconnected",
"rate_limited": "Rate Limited",
"maintenance": "Maintenance",
"initializing": "Initializing",
"retrying": "Retrying"
},
"state_attributes": {
"question": {
"name": "Last Question"
},
"response": {
"name": "Last Response"
},
"model": {
"name": "Current Model"
},
"temperature": {
"name": "Temperature"
},
"max_tokens": {
"name": "Max Tokens"
},
"system_prompt": {
"name": "System Prompt"
},
"response_time": {
"name": "Last Response Time"
},
"total_responses": {
"name": "Total Responses"
},
"error_count": {
"name": "Error Count"
},
"last_error": {
"name": "Last Error"
},
"api_status": {
"name": "API Status"
},
"tokens_used": {
"name": "Total Tokens Used"
},
"average_response_time": {
"name": "Average Response Time"
},
"last_request_time": {
"name": "Last Request Time"
},
"is_processing": {
"name": "Processing Status"
},
"is_rate_limited": {
"name": "Rate Limited Status"
},
"is_maintenance": {
"name": "Maintenance Status"
},
"api_version": {
"name": "API Version"
},
"endpoint_status": {
"name": "Endpoint Status"
},
"performance_metrics": {
"name": "Performance Metrics"
},
"history_size": {
"name": "History Size"
},
"uptime": {
"name": "Uptime"
},
"total_tokens": {
"name": "Total Tokens"
},
"prompt_tokens": {
"name": "Prompt Tokens"
},
"completion_tokens": {
"name": "Completion Tokens"
},
"successful_requests": {
"name": "Successful Requests"
},
"failed_requests": {
"name": "Failed Requests"
},
"average_latency": {
"name": "Average Latency"
},
"max_latency": {
"name": "Maximum Latency"
},
"min_latency": {
"name": "Minimum Latency"
},
"last_model": {
"name": "Last Used Model"
},
"last_timestamp": {
"name": "Last Response Time"
},
"instance_name": {
"name": "Instance Name"
},
"normalized_name": {
"name": "Normalized Name"
},
"last_error": {
"name": "Last Error"
},
"conversation_history": {
"name": "Conversation History"
}
}
}
}
}
}
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "Anbieter-Einstellungen",
"description": "Geben Sie die Verbindungsdetails für Ihren gewählten AI-Anbieter an.",
"data": {
"name": "Instanzname (z. B. 'GPT Assistant', 'Claude Helper')",
"api_key": "API-Schlüssel zur Authentifizierung",
"model": "Zu verwendendes AI-Modell",
"api_endpoint": "Benutzerdefinierte API-Endpunkt-URL (optional)",
"temperature": "Kreativität der Antwort (0-2, niedriger = fokussierter)",
"max_tokens": "Maximale Länge der Antwort (1-100000 Token)",
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "HA Text AI Instanz konfigurieren",
"description": "Richten Sie eine neue AI-Assistenteninstanz mit Ihrem ausgewählten Anbieter ein.",
"data": {
"name": "Instanzname (z. B. 'GPT Assistant', 'Claude Helper')",
"api_key": "API-Schlüssel zur Authentifizierung",
"model": "Zu verwendendes AI-Modell",
"temperature": "Kreativität der Antwort (0-2, niedriger = fokussierter)",
"max_tokens": "Maximale Länge der Antwort (1-100000 Token)",
"api_endpoint": "Benutzerdefinierte API-Endpunkt-URL (optional)",
"api_provider": "API-Anbieter",
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "Fehler beim Initialisieren des Verlaufspeichers. Überprüfen Sie die Berechtigungen.",
"history_rotation_error": "Fehler beim Drehen der Verlaufsdatei.",
"history_file_access_error": "Zugriff auf das Verzeichnis für den Verlaufsspeicher nicht möglich.",
"name_exists": "Eine Instanz mit diesem Namen existiert bereits",
"invalid_name": "Ungültiger Instanzname",
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel",
"api_key_required": "API-Schlüssel ist erforderlich, wenn Anbieter oder Endpunkt geändert wird",
"invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
"rate_limit": "Rate-Limit überschritten",
"context_length": "Kontextlänge überschritten",
"rate_limit_exceeded": "API-Rate-Limit überschritten",
"maintenance": "Dienst ist in Wartung",
"invalid_response": "Ungültige API-Antwort erhalten",
"api_error": "Ein Fehler im API-Dienst ist aufgetreten",
"timeout": "Zeitüberschreitung bei der Anfrage",
"invalid_instance": "Ungültige Instanz angegeben",
"unknown": "Unerwarteter Fehler aufgetreten",
"empty": "Name darf nicht leer sein",
"name_too_long": "Name darf höchstens 50 Zeichen lang sein"
},
"abort": {
"already_configured": "Instanz bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"title": "Anbieter auswählen",
"description": "Wählen Sie den AI-Anbieter für diese Instanz. Die Integration wird nach dem Speichern der Änderungen neu geladen.",
"data": {
"api_provider": "API-Anbieter"
}
},
"settings": {
"title": "Verbindungs- und Modelleinstellungen",
"description": "Konfigurieren Sie API-Anmeldeinformationen und Modellparameter. Änderungen werden nach dem Neuladen der Integration wirksam.",
"data": {
"api_key": "API-Schlüssel",
"api_endpoint": "API-Endpunkt-URL",
"model": "AI-Modell",
"temperature": "Kreativität der Antwort (0-2)",
"max_tokens": "Maximale Länge der Antwort (1-100000)",
"request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatible)",
"anthropic": "Anthropic (compatible)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Frage stellen (HA Text AI)",
"description": "Stellen Sie eine Frage an das AI-Modell und erhalten Sie eine detaillierte Antwort. Dieser Service gibt jetzt Antwortdaten direkt zurück, wodurch separate Textsensoren und die 255-Zeichen-Begrenzung überflüssig werden. Die Antwort wird auch im Gesprächsverlauf gespeichert.",
"fields": {
"instance": {
"name": "Instanz",
"description": "Name der zu verwendenden HA Text AI-Instanz"
},
"question": {
"name": "Frage",
"description": "Ihre Frage oder Aufforderung für den AI-Assistenten"
},
"context_messages": {
"name": "Kontextnachrichten",
"description": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)"
},
"system_prompt": {
"name": "Systemaufforderung",
"description": "Optionale Systemaufforderung zur Festlegung des Kontexts für diese spezifische Frage"
},
"model": {
"name": "Modell",
"description": "Wählen Sie das zu verwendende AI-Modell (optional, überschreibt die Standardeinstellung)"
},
"temperature": {
"name": "Temperatur",
"description": "Steuert die Kreativität der Antwort (0,0-2,0)"
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximale Länge der Antwort (1-100000 Token)"
},
"structured_output": {
"name": "Strukturierte Ausgabe",
"description": "JSON-Strukturausgabemodus aktivieren. Bei Aktivierung antwortet die KI mit gültigem JSON, das dem angegebenen Schema entspricht."
},
"json_schema": {
"name": "JSON-Schema",
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist."
},
"disable_thinking": {
"name": "Thinking deaktivieren",
"description": "Thinking/Reasoning-Modus für diese Anfrage deaktivieren. Überschreibt die Integrationseinstellung."
}
}
},
"clear_history": {
"name": "Verlauf löschen",
"description": "Löschen Sie alle gespeicherten Fragen und Antworten aus dem Gesprächsverlauf",
"fields": {
"instance": {
"name": "Instanz",
"description": "Name der HA Text AI-Instanz, für die der Verlauf gelöscht werden soll"
}
}
},
"get_history": {
"name": "Verlauf abrufen",
"description": "Rufen Sie den Gesprächsverlauf mit optionaler Filterung und Sortierung ab",
"fields": {
"instance": {
"name": "Instanz",
"description": "Name der HA Text AI-Instanz, von der der Verlauf abgerufen werden soll"
},
"limit": {
"name": "Limit",
"description": "Anzahl der zurückzugebenden Gespräche (1-100)"
},
"filter_model": {
"name": "Modell filtern",
"description": "Gespräche nach spezifischem AI-Modell filtern"
},
"start_date": {
"name": "Startdatum",
"description": "Gespräche ab diesem Datum/Zeit filtern"
},
"include_metadata": {
"name": "Metadaten einbeziehen",
"description": "Zusätzliche Informationen wie verwendete Tokens, Antwortzeit usw. einbeziehen"
},
"sort_order": {
"name": "Sortierreihenfolge",
"description": "Sortierreihenfolge für die Ergebnisse (neueste oder älteste zuerst)"
}
}
},
"set_system_prompt": {
"name": "Systemaufforderung festlegen",
"description": "Standardverhaltensanweisungen für alle zukünftigen Gespräche festlegen",
"fields": {
"instance": {
"name": "Instanz",
"description": "Name der HA Text AI-Instanz, für die die Systemaufforderung festgelegt werden soll"
},
"prompt": {
"name": "Systemaufforderung",
"description": "Anweisungen, die definieren, wie die AI sich verhalten und antworten soll"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Bereit",
"processing": "Verarbeitung",
"error": "Fehler",
"disconnected": "Getrennt",
"rate_limited": "Rate limitiert",
"maintenance": "Wartung",
"initializing": "Initialisierung",
"retrying": "Wiederholen"
},
"state_attributes": {
"question": {
"name": "Letzte Frage"
},
"response": {
"name": "Letzte Antwort"
},
"model": {
"name": "Aktuelles Modell"
},
"temperature": {
"name": "Temperatur"
},
"max_tokens": {
"name": "Max Tokens"
},
"system_prompt": {
"name": "Systemaufforderung"
},
"response_time": {
"name": "Letzte Antwortzeit"
},
"total_responses": {
"name": "Gesamtantworten"
},
"error_count": {
"name": "Fehleranzahl"
},
"last_error": {
"name": "Letzter Fehler"
},
"api_status": {
"name": "API-Status"
},
"tokens_used": {
"name": "Gesamte verwendete Tokens"
},
"average_response_time": {
"name": "Durchschnittliche Antwortzeit"
},
"last_request_time": {
"name": "Letzte Anfragezeit"
},
"is_processing": {
"name": "Verarbeitungsstatus"
},
"is_rate_limited": {
"name": "Rate-limitiert Status"
},
"is_maintenance": {
"name": "Wartungsstatus"
},
"api_version": {
"name": "API-Version"
},
"endpoint_status": {
"name": "Endpunktstatus"
},
"performance_metrics": {
"name": "Leistungskennzahlen"
},
"history_size": {
"name": "Größe des Verlaufs"
},
"uptime": {
"name": "Betriebszeit"
},
"total_tokens": {
"name": "Gesamte Tokens"
},
"prompt_tokens": {
"name": "Eingabe Tokens"
},
"completion_tokens": {
"name": "Vervollständigungs Tokens"
},
"successful_requests": {
"name": "Erfolgreiche Anfragen"
},
"failed_requests": {
"name": "Fehlgeschlagene Anfragen"
},
"average_latency": {
"name": "Durchschnittliche Latenz"
},
"max_latency": {
"name": "Maximale Latenz"
},
"min_latency": {
"name": "Minimale Latenz"
},
"last_model": {
"name": "Zuletzt verwendetes Modell"
},
"last_timestamp": {
"name": "Zeitpunkt der letzten Antwort"
},
"instance_name": {
"name": "Instanzname"
},
"normalized_name": {
"name": "Normalisierter Name"
},
"conversation_history": {
"name": "Konversationsverlauf"
}
}
}
}
}
}
+289 -145
View File
@@ -1,192 +1,336 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "provider": {
"title": "Set up HA Text AI", "title": "Provider Settings",
"description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key from platform.openai.com to proceed.", "description": "Provide connection details for your chosen AI provider.",
"data": { "data": {
"api_key": { "name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
"name": "OpenAI API Key", "api_key": "API key for authentication",
"description": "Your OpenAI API key from platform.openai.com. Keep this secure and never share it." "model": "AI model to use",
}, "api_endpoint": "Custom API endpoint URL (optional)",
"model": { "temperature": "Response creativity (0-2, lower = more focused)",
"name": "AI Model", "max_tokens": "Maximum response length (1-100000 tokens)",
"description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses as it offers the best balance of capabilities and cost." "request_interval": "Minimum time between requests (0.1-60 seconds)",
}, "api_timeout": "API request timeout in seconds (5-600)",
"temperature": { "context_messages": "Number of context messages to retain (1-20)",
"name": "Temperature", "max_history_size": "Maximum conversation history size (1-100)",
"description": "Controls response creativity (0-2). Low values (0.1-0.3) for focused responses, high values (0.8-2.0) for creative ones." "allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
}, "disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
"max_tokens": { }
"name": "Max Tokens", },
"description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens. Recommended: 512-1024." "user": {
}, "title": "Configure HA Text AI Instance",
"api_endpoint": { "description": "Set up a new AI assistant instance with your selected provider.",
"name": "API Endpoint", "data": {
"description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint or proxy." "name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
}, "api_key": "API key for authentication",
"request_interval": { "model": "AI model to use",
"name": "Request Interval", "temperature": "Response creativity (0-2, lower = more focused)",
"description": "Minimum time between API requests in seconds. Increase if experiencing rate limits." "max_tokens": "Maximum response length (1-100000 tokens)",
} "api_endpoint": "Custom API endpoint URL (optional)",
"api_provider": "API Provider",
"request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
"error": { "error": {
"invalid_auth": "Invalid API key. Please check your OpenAI API key and try again.", "history_storage_error": "Failed to initialize history storage. Check permissions.",
"cannot_connect": "Failed to connect to API. Please check your internet connection and API endpoint.", "history_rotation_error": "Error during history file rotation.",
"unknown": "Unexpected error occurred. Please check the logs for more details.", "history_file_access_error": "Cannot access history storage directory.",
"already_exists": "This API key is already configured in another integration.", "name_exists": "An instance with this name already exists",
"invalid_model": "Selected model is not available. Please choose a different model.", "invalid_name": "Invalid instance name",
"rate_limit": "API rate limit exceeded. Please try again later or increase the request interval.", "invalid_auth": "Authentication failed - check your API key",
"context_length": "Input too long for selected model. Try reducing max tokens or using a model with larger context.", "api_key_required": "API key is required when changing provider or endpoint",
"api_error": "OpenAI API error. Please check the logs for details.", "invalid_api_key": "Invalid API key - please verify your credentials",
"timeout": "API response timeout. Request took too long to complete.", "cannot_connect": "Failed to connect to API service",
"queue_full": "Request queue is full. Please try again later." "invalid_model": "Selected model is not available",
"rate_limit": "Rate limit exceeded",
"context_length": "Context length exceeded",
"rate_limit_exceeded": "API rate limit exceeded",
"maintenance": "Service is under maintenance",
"invalid_response": "Invalid API response received",
"api_error": "API service error occurred",
"timeout": "Request timed out",
"invalid_instance": "Invalid instance specified",
"unknown": "Unexpected error occurred",
"empty": "Name cannot be empty",
"name_too_long": "Name must be 50 characters or less"
}, },
"abort": { "abort": {
"already_configured": "This OpenAI integration is already configured", "already_configured": "Instance already configured"
"auth_failed": "Authentication failed. Please verify your API key.",
"invalid_endpoint": "Invalid API endpoint URL provided"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "HA Text AI Options", "title": "Select Provider",
"description": "Adjust your OpenAI integration settings. Changes will apply to future requests only.", "description": "Choose the AI provider for this instance. The integration will reload after saving changes.",
"data": { "data": {
"temperature": { "api_provider": "API Provider"
"name": "Temperature", }
"description": "Controls response creativity (0-2). Low values for focused responses, high for creative ones." },
}, "settings": {
"max_tokens": { "title": "Connection & Model Settings",
"name": "Max Tokens", "description": "Configure API credentials and model parameters. Changes will take effect after the integration reloads.",
"description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens." "data": {
}, "api_key": "API Key",
"request_interval": { "api_endpoint": "API Endpoint URL",
"name": "Request Interval", "model": "AI model",
"description": "Minimum time between API requests in seconds. Increase if experiencing rate limits." "temperature": "Response creativity (0-2)",
} "max_tokens": "Maximum response length (1-100000)",
"request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatible)",
"anthropic": "Anthropic (compatible)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Ask Question (HA Text AI)",
"description": "Send a question to the AI model and receive a detailed response. This service now returns response data directly, eliminating the need for separate text sensors and the 255-character limitation. The response will also be stored in the conversation history.",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to use"
},
"question": {
"name": "Question",
"description": "Your question or prompt for the AI assistant"
},
"context_messages": {
"name": "Context Messages",
"description": "Number of previous messages to include in context (1-20)"
},
"system_prompt": {
"name": "System Prompt",
"description": "Optional system prompt to set context for this specific question"
},
"model": {
"name": "Model",
"description": "Select AI model to use (optional, overrides default setting)"
},
"temperature": {
"name": "Temperature",
"description": "Controls response creativity (0.0-2.0)"
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum length of the response (1-100000 tokens)"
},
"structured_output": {
"name": "Structured Output",
"description": "Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema."
},
"json_schema": {
"name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
},
"disable_thinking": {
"name": "Disable Thinking",
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored questions and responses from the conversation history",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to clear history for"
}
}
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history with optional filtering and sorting",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to get history from"
},
"limit": {
"name": "Limit",
"description": "Number of conversations to return (1-100)"
},
"filter_model": {
"name": "Filter Model",
"description": "Filter conversations by specific AI model"
},
"start_date": {
"name": "Start Date",
"description": "Filter conversations starting from this date/time"
},
"include_metadata": {
"name": "Include Metadata",
"description": "Include additional information like tokens used, response time, etc."
},
"sort_order": {
"name": "Sort Order",
"description": "Sort order for results (newest or oldest first)"
}
}
},
"set_system_prompt": {
"name": "Set System Prompt",
"description": "Set default system behavior instructions for all future conversations",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to set system prompt for"
},
"prompt": {
"name": "System Prompt",
"description": "Instructions that define how the AI should behave and respond"
} }
} }
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"last_response": { "ha_text_ai": {
"name": "Last Response", "name": "{name}",
"state": {
"ready": "Ready",
"processing": "Processing",
"error": "Error",
"disconnected": "Disconnected",
"rate_limited": "Rate Limited",
"maintenance": "Maintenance",
"initializing": "Initializing",
"retrying": "Retrying"
},
"state_attributes": { "state_attributes": {
"last_updated": {
"name": "Last Updated",
"description": "Timestamp of the last AI response"
},
"question": { "question": {
"name": "Last Question", "name": "Last Question"
"description": "Most recent question asked"
}, },
"response": { "response": {
"name": "AI Response", "name": "Last Response"
"description": "Latest response from the AI"
}, },
"model": { "model": {
"name": "Current Model", "name": "Current Model"
"description": "AI model currently in use"
}, },
"temperature": { "temperature": {
"name": "Temperature Setting", "name": "Temperature"
"description": "Current temperature parameter"
}, },
"max_tokens": { "max_tokens": {
"name": "Max Tokens Setting", "name": "Max Tokens"
"description": "Current maximum tokens limit"
},
"total_responses": {
"name": "Total Responses",
"description": "Number of responses since last reset"
}, },
"system_prompt": { "system_prompt": {
"name": "System Prompt", "name": "System Prompt"
"description": "Current system instructions for the AI"
}, },
"response_time": { "response_time": {
"name": "Response Time", "name": "Last Response Time"
"description": "Time taken to generate last response"
}, },
"queue_size": { "total_responses": {
"name": "Queue Size", "name": "Total Responses"
"description": "Current size of request queue"
},
"api_status": {
"name": "API Status",
"description": "Current API connection status"
}, },
"error_count": { "error_count": {
"name": "Error Count", "name": "Error Count"
"description": "Number of errors since last reset"
}, },
"last_error": { "last_error": {
"name": "Last Error", "name": "Last Error"
"description": "Description of the last error encountered" },
"api_status": {
"name": "API Status"
},
"tokens_used": {
"name": "Total Tokens Used"
},
"average_response_time": {
"name": "Average Response Time"
},
"last_request_time": {
"name": "Last Request Time"
},
"is_processing": {
"name": "Processing Status"
},
"is_rate_limited": {
"name": "Rate Limited Status"
},
"is_maintenance": {
"name": "Maintenance Status"
},
"api_version": {
"name": "API Version"
},
"endpoint_status": {
"name": "Endpoint Status"
},
"performance_metrics": {
"name": "Performance Metrics"
},
"history_size": {
"name": "History Size"
},
"uptime": {
"name": "Uptime"
},
"total_tokens": {
"name": "Total Tokens"
},
"prompt_tokens": {
"name": "Prompt Tokens"
},
"completion_tokens": {
"name": "Completion Tokens"
},
"successful_requests": {
"name": "Successful Requests"
},
"failed_requests": {
"name": "Failed Requests"
},
"average_latency": {
"name": "Average Latency"
},
"max_latency": {
"name": "Maximum Latency"
},
"min_latency": {
"name": "Minimum Latency"
},
"last_model": {
"name": "Last Used Model"
},
"last_timestamp": {
"name": "Last Response Time"
},
"instance_name": {
"name": "Instance Name"
},
"normalized_name": {
"name": "Normalized Name"
},
"last_error": {
"name": "Last Error"
},
"conversation_history": {
"name": "Conversation History"
} }
} }
} }
} }
},
"services": {
"ask_question": {
"name": "Ask Question",
"description": "Send a question to the AI model and receive a detailed response. The response will be stored in conversation history.",
"fields": {
"question": {
"name": "Question",
"description": "Your question or prompt for the AI. Be specific for better results."
},
"model": {
"name": "Model",
"description": "AI model to use (optional, overrides default settings)."
},
"temperature": {
"name": "Temperature",
"description": "Response creativity level (0-2, optional)."
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum response length (optional)."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored conversation history. This action cannot be undone."
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history, including questions, responses, and timestamps.",
"fields": {
"limit": {
"name": "Limit",
"description": "Number of recent conversations to return (default 10)."
},
"filter_model": {
"name": "Filter by Model",
"description": "Retrieve only conversations using a specific AI model."
}
}
},
"set_system_prompt": {
"name": "Set System Prompt",
"description": "Configure AI behavior by setting a system prompt.",
"fields": {
"prompt": {
"name": "Prompt",
"description": "Instructions defining AI behavior and response style."
},
"clear_prompt": {
"name": "Clear Prompt",
"description": "Remove current system prompt before setting new one."
}
}
}
} }
} }
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "Configuración del proveedor",
"description": "Proporciona los detalles de conexión para tu proveedor de IA elegido.",
"data": {
"name": "Nombre de la instancia (por ejemplo, 'Asistente GPT', 'Ayudante Claude')",
"api_key": "Clave API para autenticación",
"model": "Modelo de IA a utilizar",
"api_endpoint": "URL del endpoint de API personalizado (opcional)",
"temperature": "Creatividad de la respuesta (0-2, menor = más enfocado)",
"max_tokens": "Longitud máxima de la respuesta (1-100000 tokens)",
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "Configurar instancia de IA de texto de HA",
"description": "Configura una nueva instancia de asistente de IA con tu proveedor seleccionado.",
"data": {
"name": "Nombre de la instancia (por ejemplo, 'Asistente GPT', 'Ayudante Claude')",
"api_key": "Clave API para autenticación",
"model": "Modelo de IA a utilizar",
"temperature": "Creatividad de la respuesta (0-2, menor = más enfocado)",
"max_tokens": "Longitud máxima de la respuesta (1-100000 tokens)",
"api_endpoint": "URL del endpoint de API personalizado (opcional)",
"api_provider": "Proveedor de API",
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "Error al inicializar el almacenamiento del historial. Verifica los permisos.",
"history_rotation_error": "Error durante la rotación del archivo de historial.",
"history_file_access_error": "No se puede acceder al directorio de almacenamiento del historial.",
"name_exists": "Ya existe una instancia con este nombre",
"invalid_name": "Nombre de instancia no válido",
"invalid_auth": "La autenticación falló - verifica tu clave API",
"api_key_required": "Se requiere la clave API al cambiar de proveedor o endpoint",
"invalid_api_key": "Clave API no válida - verifica tus credenciales",
"cannot_connect": "Error al conectar con el servicio de API",
"invalid_model": "El modelo seleccionado no está disponible",
"rate_limit": "Límite de tasa excedido",
"context_length": "Longitud del contexto excedida",
"rate_limit_exceeded": "Límite de tasa de API excedido",
"maintenance": "El servicio está en mantenimiento",
"invalid_response": "Respuesta de API no válida recibida",
"api_error": "Ocurrió un error en el servicio de API",
"timeout": "Se agotó el tiempo de la solicitud",
"invalid_instance": "Instancia no válida especificada",
"unknown": "Ocurrió un error inesperado",
"empty": "El nombre no puede estar vacío",
"name_too_long": "El nombre debe tener 50 caracteres o menos"
},
"abort": {
"already_configured": "Instancia ya configurada"
}
},
"options": {
"step": {
"init": {
"title": "Seleccionar proveedor",
"description": "Elige el proveedor de IA para esta instancia. La integración se recargará después de guardar los cambios.",
"data": {
"api_provider": "Proveedor de API"
}
},
"settings": {
"title": "Configuración de conexión y modelo",
"description": "Configura las credenciales de API y los parámetros del modelo. Los cambios tendrán efecto después de recargar la integración.",
"data": {
"api_key": "Clave API",
"api_endpoint": "URL del endpoint de API",
"model": "Modelo de IA",
"temperature": "Creatividad de la respuesta (0-2)",
"max_tokens": "Longitud máxima de la respuesta (1-100000)",
"request_interval": "Intervalo mínimo de solicitud (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatible)",
"anthropic": "Anthropic (compatible)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Hacer Pregunta (HA Text AI)",
"description": "Envía una pregunta al modelo de IA y recibe una respuesta detallada. Este servicio ahora devuelve datos de respuesta directamente, eliminando la necesidad de sensores de texto separados y la limitación de 255 caracteres. La respuesta también se almacenará en el historial de conversación.",
"fields": {
"instance": {
"name": "Instancia",
"description": "Nombre de la instancia de IA de Texto de HA a utilizar"
},
"question": {
"name": "Pregunta",
"description": "Tu pregunta o solicitud para el asistente de IA"
},
"context_messages": {
"name": "Mensajes de Contexto",
"description": "Número de mensajes anteriores a incluir en el contexto (1-20)"
},
"system_prompt": {
"name": "Indicaciones del Sistema",
"description": "Indicaciones opcionales para establecer contexto para esta pregunta específica"
},
"model": {
"name": "Modelo",
"description": "Selecciona el modelo de IA a utilizar (opcional, anula la configuración predeterminada)"
},
"temperature": {
"name": "Temperatura",
"description": "Controla la creatividad de la respuesta (0.0-2.0)"
},
"max_tokens": {
"name": "Máx. Tokens",
"description": "Longitud máxima de la respuesta (1-100000 tokens)"
},
"structured_output": {
"name": "Salida Estructurada",
"description": "Habilitar modo de salida JSON estructurada. Cuando está habilitado, la IA responderá con JSON válido que coincida con el esquema proporcionado."
},
"json_schema": {
"name": "Esquema JSON",
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado."
},
"disable_thinking": {
"name": "Desactivar Thinking",
"description": "Desactivar el modo thinking/reasoning para esta solicitud. Anula la configuración de la integración."
}
}
},
"clear_history": {
"name": "Borrar Historial",
"description": "Elimina todas las preguntas y respuestas almacenadas del historial de conversación",
"fields": {
"instance": {
"name": "Instancia",
"description": "Nombre de la instancia de IA de Texto de HA para borrar el historial"
}
}
},
"get_history": {
"name": "Obtener Historial",
"description": "Recupera el historial de conversación con filtrado y ordenación opcionales",
"fields": {
"instance": {
"name": "Instancia",
"description": "Nombre de la instancia de IA de Texto de HA para obtener historial"
},
"limit": {
"name": "Límite",
"description": "Número de conversaciones a devolver (1-100)"
},
"filter_model": {
"name": "Filtrar Modelo",
"description": "Filtrar conversaciones por modelo de IA específico"
},
"start_date": {
"name": "Fecha de Inicio",
"description": "Filtrar conversaciones a partir de esta fecha/hora"
},
"include_metadata": {
"name": "Incluir Metadatos",
"description": "Incluir información adicional como tokens utilizados, tiempo de respuesta, etc."
},
"sort_order": {
"name": "Orden de Clasificación",
"description": "Orden de clasificación para los resultados (más recientes o más antiguos primero)"
}
}
},
"set_system_prompt": {
"name": "Establecer Indicaciones del Sistema",
"description": "Establecer instrucciones de comportamiento del sistema predeterminadas para todas las futuras conversaciones",
"fields": {
"instance": {
"name": "Instancia",
"description": "Nombre de la instancia de IA de Texto de HA para establecer indicaciones del sistema"
},
"prompt": {
"name": "Indicaciones del Sistema",
"description": "Instrucciones que definen cómo debe comportarse y responder la IA"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Listo",
"processing": "Procesando",
"error": "Error",
"disconnected": "Desconectado",
"rate_limited": "Limitado por tasa",
"maintenance": "Mantenimiento",
"initializing": "Inicializando",
"retrying": "Reintentando"
},
"state_attributes": {
"question": {
"name": "Última Pregunta"
},
"response": {
"name": "Última Respuesta"
},
"model": {
"name": "Modelo Actual"
},
"temperature": {
"name": "Temperatura"
},
"max_tokens": {
"name": "Máx. Tokens"
},
"system_prompt": {
"name": "Indicaciones del Sistema"
},
"response_time": {
"name": "Último Tiempo de Respuesta"
},
"total_responses": {
"name": "Total de Respuestas"
},
"error_count": {
"name": "Conteo de Errores"
},
"last_error": {
"name": "Último Error"
},
"api_status": {
"name": "Estado de API"
},
"tokens_used": {
"name": "Total de Tokens Usados"
},
"average_response_time": {
"name": "Tiempo de Respuesta Promedio"
},
"last_request_time": {
"name": "Último Tiempo de Solicitud"
},
"is_processing": {
"name": "Estado de Procesamiento"
},
"is_rate_limited": {
"name": "Estado Limitado por Tasa"
},
"is_maintenance": {
"name": "Estado de Mantenimiento"
},
"api_version": {
"name": "Versión de API"
},
"endpoint_status": {
"name": "Estado del Endpoint"
},
"performance_metrics": {
"name": "Métricas de Rendimiento"
},
"history_size": {
"name": "Tamaño del Historial"
},
"uptime": {
"name": "Tiempo de Actividad"
},
"total_tokens": {
"name": "Total de Tokens"
},
"prompt_tokens": {
"name": "Tokens de Solicitud"
},
"completion_tokens": {
"name": "Tokens de Finalización"
},
"successful_requests": {
"name": "Solicitudes Exitosas"
},
"failed_requests": {
"name": "Solicitudes Fallidas"
},
"average_latency": {
"name": "Latencia Promedio"
},
"max_latency": {
"name": "Latencia Máxima"
},
"min_latency": {
"name": "Latencia Mínima"
},
"last_model": {
"name": "Último modelo utilizado"
},
"last_timestamp": {
"name": "Hora de la última respuesta"
},
"instance_name": {
"name": "Nombre de instancia"
},
"normalized_name": {
"name": "Nombre normalizado"
},
"conversation_history": {
"name": "Historial de conversación"
}
}
}
}
}
}
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "प्रदाता सेटिंग्स",
"description": "आपके द्वारा चुने गए एआई प्रदाता के लिए कनेक्शन विवरण प्रदान करें।",
"data": {
"name": "उदाहरण का नाम (जैसे, 'जीपीटी सहायक', 'क्लॉड सहायक')",
"api_key": "प्रमाणीकरण के लिए एपीआई कुंजी",
"model": "उपयोग करने के लिए एआई मॉडल",
"api_endpoint": "कस्टम एपीआई एंडपॉइंट यूआरएल (वैकल्पिक)",
"temperature": "प्रतिक्रिया की रचनात्मकता (0-2, कम = अधिक केंद्रित)",
"max_tokens": "प्रतिक्रिया की अधिकतम लंबाई (1-100000 टोकन)",
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "एचए टेक्स्ट एआई उदाहरण कॉन्फ़िगर करें",
"description": "अपने चुने हुए प्रदाता के साथ एक नया एआई सहायक उदाहरण सेट करें।",
"data": {
"name": "उदाहरण का नाम (जैसे, 'जीपीटी सहायक', 'क्लॉड सहायक')",
"api_key": "प्रमाणीकरण के लिए एपीआई कुंजी",
"model": "उपयोग करने के लिए एआई मॉडल",
"temperature": "प्रतिक्रिया की रचनात्मकता (0-2, कम = अधिक केंद्रित)",
"max_tokens": "प्रतिक्रिया की अधिकतम लंबाई (1-100000 टोकन)",
"api_endpoint": "कस्टम एपीआई एंडपॉइंट यूआरएल (वैकल्पिक)",
"api_provider": "एपीआई प्रदाता",
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "इतिहास भंडारण प्रारंभ करने में विफल। अनुमतियों की जांच करें।",
"history_rotation_error": "इतिहास फ़ाइल घुमाने के दौरान त्रुटि।",
"history_file_access_error": "इतिहास भंडारण निर्देशिका तक पहुंच नहीं है।",
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
"invalid_name": "अमान्य उदाहरण नाम",
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
"api_key_required": "प्रदाता या endpoint बदलते समय API कुंजी आवश्यक है",
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
"rate_limit": "रेट सीमा पार",
"context_length": "संदर्भ लंबाई पार",
"rate_limit_exceeded": "एपीआई रेट सीमा पार",
"maintenance": "सेवा रखरखाव में है",
"invalid_response": "अमान्य एपीआई प्रतिक्रिया प्राप्त हुई",
"api_error": "एपीआई सेवा में त्रुटि हुई",
"timeout": "अनुरोध समय सीमा समाप्त",
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
"unknown": "अप्रत्याशित त्रुटि हुई",
"empty": "नाम खाली नहीं हो सकता",
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
},
"abort": {
"already_configured": "उदाहरण पहले से कॉन्फ़िगर किया गया है"
}
},
"options": {
"step": {
"init": {
"title": "प्रदाता चुनें",
"description": "इस उदाहरण के लिए एआई प्रदाता चुनें। परिवर्तन सहेजने के बाद एकीकरण पुनः लोड होगा।",
"data": {
"api_provider": "एपीआई प्रदाता"
}
},
"settings": {
"title": "कनेक्शन और मॉडल सेटिंग्स",
"description": "एपीआई क्रेडेंशियल और मॉडल पैरामीटर कॉन्फ़िगर करें। एकीकरण पुनः लोड होने के बाद परिवर्तन प्रभावी होंगे।",
"data": {
"api_key": "एपीआई कुंजी",
"api_endpoint": "एपीआई एंडपॉइंट यूआरएल",
"model": "एआई मॉडल",
"temperature": "प्रतिक्रिया की रचनात्मकता (0-2)",
"max_tokens": "प्रतिक्रिया की अधिकतम लंबाई (1-100000)",
"request_interval": "न्यूनतम अनुरोध अंतराल (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (अनुकूलित)",
"anthropic": "Anthropic (अनुकूलित)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "प्रश्न पूछें (HA Text AI)",
"description": "AI मॉडल को प्रश्न भेजें और विस्तृत उत्तर प्राप्त करें। यह सेवा अब प्रत्यक्ष रूप से प्रतिक्रिया डेटा वापस करती है, अलग टेक्स्ट सेंसर की आवश्यकता और 255 वर्ण की सीमा को समाप्त करती है। प्रतिक्रिया को बातचीत के इतिहास में भी संग्रहीत किया जाएगा।",
"fields": {
"instance": {
"name": "उदाहरण",
"description": "उपयोग करने के लिए एचए टेक्स्ट एआई उदाहरण का नाम"
},
"question": {
"name": "प्रश्न",
"description": "आपका प्रश्न या एआई सहायक के लिए प्रॉम्प्ट"
},
"context_messages": {
"name": "संदर्भ संदेश",
"description": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)"
},
"system_prompt": {
"name": "सिस्टम प्रॉम्प्ट",
"description": "इस विशेष प्रश्न के लिए संदर्भ सेट करने के लिए वैकल्पिक सिस्टम प्रॉम्प्ट"
},
"model": {
"name": "मॉडल",
"description": "उपयोग करने के लिए एआई मॉडल का चयन करें (वैकल्पिक, डिफ़ॉल्ट सेटिंग को ओवरराइड करता है)"
},
"temperature": {
"name": "तापमान",
"description": "प्रतिक्रिया की रचनात्मकता को नियंत्रित करता है (0.0-2.0)"
},
"max_tokens": {
"name": "अधिकतम टोकन",
"description": "प्रतिक्रिया की अधिकतम लंबाई (1-100000 टोकन)"
},
"structured_output": {
"name": "संरचित आउटपुट",
"description": "JSON संरचित आउटपुट मोड सक्षम करें। सक्षम होने पर, AI प्रदान किए गए स्कीमा से मेल खाने वाले वैध JSON के साथ प्रतिक्रिया देगा।"
},
"json_schema": {
"name": "JSON स्कीमा",
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
},
"disable_thinking": {
"name": "Thinking बंद करें",
"description": "इस अनुरोध के लिए thinking/reasoning मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।"
}
}
},
"clear_history": {
"name": "इतिहास साफ करें",
"description": "बातचीत के इतिहास से सभी संग्रहीत प्रश्नों और प्रतिक्रियाओं को हटाएं",
"fields": {
"instance": {
"name": "उदाहरण",
"description": "इतिहास साफ़ करने के लिए एचए टेक्स्ट एआई उदाहरण का नाम"
}
}
},
"get_history": {
"name": "इतिहास प्राप्त करें",
"description": "वैकल्पिक फ़िल्टरिंग और छंटाई के साथ बातचीत का इतिहास प्राप्त करें",
"fields": {
"instance": {
"name": "उदाहरण",
"description": "इतिहास प्राप्त करने के लिए एचए टेक्स्ट एआई उदाहरण का नाम"
},
"limit": {
"name": "सीमा",
"description": "वापस करने के लिए बातचीत की संख्या (1-100)"
},
"filter_model": {
"name": "फिल्टर मॉडल",
"description": "विशिष्ट एआई मॉडल द्वारा बातचीत को फ़िल्टर करें"
},
"start_date": {
"name": "शुरुआत की तारीख",
"description": "इस दिन/समय से शुरू होने वाली बातचीत को फ़िल्टर करें"
},
"include_metadata": {
"name": "मेटाडेटा शामिल करें",
"description": "उपयोग किए गए टोकन, प्रतिक्रिया समय आदि जैसी अतिरिक्त जानकारी शामिल करें।"
},
"sort_order": {
"name": "छंटाई क्रम",
"description": "परिणामों के लिए छंटाई क्रम (नवीनतम या सबसे पुराना पहले)"
}
}
},
"set_system_prompt": {
"name": "सिस्टम प्रॉम्प्ट सेट करें",
"description": "सभी भविष्य की बातचीत के लिए डिफ़ॉल्ट सिस्टम व्यवहार निर्देश सेट करें",
"fields": {
"instance": {
"name": "उदाहरण",
"description": "सिस्टम प्रॉम्प्ट सेट करने के लिए एचए टेक्स्ट एआई उदाहरण का नाम"
},
"prompt": {
"name": "सिस्टम प्रॉम्प्ट",
"description": "निर्देश जो यह परिभाषित करते हैं कि एआई को कैसे व्यवहार करना चाहिए और प्रतिक्रिया देनी चाहिए"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "तैयार",
"processing": "प्रसंस्करण",
"error": "त्रुटि",
"disconnected": "असंयुक्त",
"rate_limited": "रेट सीमित",
"maintenance": "रखरखाव",
"initializing": "प्रारंभिककरण",
"retrying": "पुनः प्रयास कर रहा है"
},
"state_attributes": {
"question": {
"name": "अंतिम प्रश्न"
},
"response": {
"name": "अंतिम प्रतिक्रिया"
},
"model": {
"name": "वर्तमान मॉडल"
},
"temperature": {
"name": "तापमान"
},
"max_tokens": {
"name": "अधिकतम टोकन"
},
"system_prompt": {
"name": "सिस्टम प्रॉम्प्ट"
},
"response_time": {
"name": "अंतिम प्रतिक्रिया का समय"
},
"total_responses": {
"name": "कुल प्रतिक्रियाएं"
},
"error_count": {
"name": "त्रुटियों की संख्या"
},
"last_error": {
"name": "अंतिम त्रुटि"
},
"api_status": {
"name": "एपीआई स्थिति"
},
"tokens_used": {
"name": "कुल उपयोग किए गए टोकन"
},
"average_response_time": {
"name": "औसत प्रतिक्रिया समय"
},
"last_request_time": {
"name": "अंतिम अनुरोध का समय"
},
"is_processing": {
"name": "प्रसंस्करण स्थिति"
},
"is_rate_limited": {
"name": "रेट सीमित स्थिति"
},
"is_maintenance": {
"name": "रखरखाव स्थिति"
},
"api_version": {
"name": "एपीआई संस्करण"
},
"endpoint_status": {
"name": "एंडपॉइंट स्थिति"
},
"performance_metrics": {
"name": "प्रदर्शन मैट्रिक्स"
},
"history_size": {
"name": "इतिहास का आकार"
},
"uptime": {
"name": "अपटाइम"
},
"total_tokens": {
"name": "कुल टोकन"
},
"prompt_tokens": {
"name": "प्रॉम्प्ट टोकन"
},
"completion_tokens": {
"name": "पूर्णता टोकन"
},
"successful_requests": {
"name": "सफल अनुरोध"
},
"failed_requests": {
"name": "विफल अनुरोध"
},
"average_latency": {
"name": "औसत विलंबता"
},
"max_latency": {
"name": "अधिकतम विलंबता"
},
"min_latency": {
"name": "न्यूनतम विलंबता"
},
"last_model": {
"name": "अंतिम उपयोग किया गया मॉडल"
},
"last_timestamp": {
"name": "अंतिम प्रतिक्रिया समय"
},
"instance_name": {
"name": "इंस्टेंस नाम"
},
"normalized_name": {
"name": "सामान्यीकृत नाम"
},
"conversation_history": {
"name": "वार्तालाप इतिहास"
}
}
}
}
}
}
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "Impostazioni fornitore",
"description": "Fornisci i dettagli di connessione per il tuo fornitore di AI scelto.",
"data": {
"name": "Nome dell'istanza (es. 'Assistente GPT', 'Aiuto Claude')",
"api_key": "Chiave API per l'autenticazione",
"model": "Modello AI da utilizzare",
"api_endpoint": "URL dell'endpoint API personalizzato (opzionale)",
"temperature": "Creatività della risposta (0-2, più basso = più focalizzato)",
"max_tokens": "Lunghezza massima della risposta (1-100000 token)",
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "Configura istanza AI di testo HA",
"description": "Imposta una nuova istanza di assistente AI con il fornitore selezionato.",
"data": {
"name": "Nome dell'istanza (es. 'Assistente GPT', 'Aiuto Claude')",
"api_key": "Chiave API per l'autenticazione",
"model": "Modello AI da utilizzare",
"temperature": "Creatività della risposta (0-2, più basso = più focalizzato)",
"max_tokens": "Lunghezza massima della risposta (1-100000 token)",
"api_endpoint": "URL dell'endpoint API personalizzato (opzionale)",
"api_provider": "Fornitore API",
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "Impossibile inizializzare la memorizzazione della cronologia. Controlla i permessi.",
"history_rotation_error": "Errore durante la rotazione del file di cronologia.",
"history_file_access_error": "Impossibile accedere alla directory di memorizzazione della cronologia.",
"name_exists": "Esiste già un'istanza con questo nome",
"invalid_name": "Nome dell'istanza non valido",
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API",
"api_key_required": "La chiave API è obbligatoria quando si cambia provider o endpoint",
"invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
"cannot_connect": "Impossibile connettersi al servizio API",
"invalid_model": "Il modello selezionato non è disponibile",
"rate_limit": "Limite di frequenza superato",
"context_length": "Lunghezza del contesto superata",
"rate_limit_exceeded": "Limite di frequenza API superato",
"maintenance": "Il servizio è in manutenzione",
"invalid_response": "Risposta API non valida ricevuta",
"api_error": "Si è verificato un errore nel servizio API",
"timeout": "Richiesta scaduta",
"invalid_instance": "Istanze specificata non valida",
"unknown": "Si è verificato un errore imprevisto",
"empty": "Il nome non può essere vuoto",
"name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
},
"abort": {
"already_configured": "Istanze già configurata"
}
},
"options": {
"step": {
"init": {
"title": "Seleziona fornitore",
"description": "Scegli il fornitore AI per questa istanza. L'integrazione verrà ricaricata dopo aver salvato le modifiche.",
"data": {
"api_provider": "Fornitore API"
}
},
"settings": {
"title": "Impostazioni di connessione e modello",
"description": "Configura le credenziali API e i parametri del modello. Le modifiche avranno effetto dopo il ricaricamento dell'integrazione.",
"data": {
"api_key": "Chiave API",
"api_endpoint": "URL dell'endpoint API",
"model": "Modello AI",
"temperature": "Creatività della risposta (0-2)",
"max_tokens": "Lunghezza massima della risposta (1-100000)",
"request_interval": "Intervallo minimo di richiesta (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatibile)",
"anthropic": "Anthropic (compatibile)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Fai una domanda (HA Text AI)",
"description": "Invia una domanda al modello AI e ricevi una risposta dettagliata. Questo servizio ora restituisce i dati di risposta direttamente, eliminando la necessità di sensori di testo separati e la limitazione di 255 caratteri. La risposta sarà anche memorizzata nella cronologia delle conversazioni.",
"fields": {
"instance": {
"name": "Istanze",
"description": "Nome dell'istanza HA Text AI da utilizzare"
},
"question": {
"name": "Domanda",
"description": "La tua domanda o richiesta per l'assistente AI"
},
"context_messages": {
"name": "Messaggi di contesto",
"description": "Numero di messaggi precedenti da includere nel contesto (1-20)"
},
"system_prompt": {
"name": "Prompt di sistema",
"description": "Prompt di sistema opzionale per impostare il contesto per questa specifica domanda"
},
"model": {
"name": "Modello",
"description": "Seleziona il modello AI da utilizzare (opzionale, sovrascrive l'impostazione predefinita)"
},
"temperature": {
"name": "Temperatura",
"description": "Controlla la creatività della risposta (0.0-2.0)"
},
"max_tokens": {
"name": "Token massimi",
"description": "Lunghezza massima della risposta (1-100000 token)"
},
"structured_output": {
"name": "Output Strutturato",
"description": "Abilita la modalità di output JSON strutturato. Quando abilitato, l'IA risponderà con JSON valido corrispondente allo schema fornito."
},
"json_schema": {
"name": "Schema JSON",
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato."
},
"disable_thinking": {
"name": "Disabilita Thinking",
"description": "Disabilita la modalità thinking/reasoning per questa richiesta. Sovrascrive l'impostazione dell'integrazione."
}
}
},
"clear_history": {
"name": "Cancella cronologia",
"description": "Elimina tutte le domande e risposte memorizzate dalla cronologia delle conversazioni",
"fields": {
"instance": {
"name": "Istanze",
"description": "Nome dell'istanza HA Text AI per cui cancellare la cronologia"
}
}
},
"get_history": {
"name": "Ottieni cronologia",
"description": "Recupera la cronologia delle conversazioni con opzioni di filtro e ordinamento",
"fields": {
"instance": {
"name": "Istanze",
"description": "Nome dell'istanza HA Text AI da cui recuperare la cronologia"
},
"limit": {
"name": "Limite",
"description": "Numero di conversazioni da restituire (1-100)"
},
"filter_model": {
"name": "Filtra modello",
"description": "Filtra le conversazioni per modello AI specifico"
},
"start_date": {
"name": "Data di inizio",
"description": "Filtra le conversazioni a partire da questa data/ora"
},
"include_metadata": {
"name": "Includi metadati",
"description": "Includi informazioni aggiuntive come token utilizzati, tempo di risposta, ecc."
},
"sort_order": {
"name": "Ordine di ordinamento",
"description": "Ordine di ordinamento per i risultati (più recenti o più vecchi per primi)"
}
}
},
"set_system_prompt": {
"name": "Imposta prompt di sistema",
"description": "Imposta le istruzioni di comportamento predefinite per tutte le future conversazioni",
"fields": {
"instance": {
"name": "Istanze",
"description": "Nome dell'istanza HA Text AI per cui impostare il prompt di sistema"
},
"prompt": {
"name": "Prompt di sistema",
"description": "Istruzioni che definiscono come l'AI dovrebbe comportarsi e rispondere"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Pronto",
"processing": "Elaborazione",
"error": "Errore",
"disconnected": "Disconnesso",
"rate_limited": "Limite di frequenza",
"maintenance": "Manutenzione",
"initializing": "Inizializzazione",
"retrying": "Riprova"
},
"state_attributes": {
"question": {
"name": "Ultima domanda"
},
"response": {
"name": "Ultima risposta"
},
"model": {
"name": "Modello attuale"
},
"temperature": {
"name": "Temperatura"
},
"max_tokens": {
"name": "Token massimi"
},
"system_prompt": {
"name": "Prompt di sistema"
},
"response_time": {
"name": "Ultimo tempo di risposta"
},
"total_responses": {
"name": "Risposte totali"
},
"error_count": {
"name": "Conteggio errori"
},
"last_error": {
"name": "Ultimo errore"
},
"api_status": {
"name": "Stato API"
},
"tokens_used": {
"name": "Token totali utilizzati"
},
"average_response_time": {
"name": "Tempo medio di risposta"
},
"last_request_time": {
"name": "Ultimo tempo di richiesta"
},
"is_processing": {
"name": "Stato di elaborazione"
},
"is_rate_limited": {
"name": "Stato limite di frequenza"
},
"is_maintenance": {
"name": "Stato di manutenzione"
},
"api_version": {
"name": "Versione API"
},
"endpoint_status": {
"name": "Stato dell'endpoint"
},
"performance_metrics": {
"name": "Metriche di prestazione"
},
"history_size": {
"name": "Dimensione della cronologia"
},
"uptime": {
"name": "Tempo di attività"
},
"total_tokens": {
"name": "Token totali"
},
"prompt_tokens": {
"name": "Token di prompt"
},
"completion_tokens": {
"name": "Token di completamento"
},
"successful_requests": {
"name": "Richieste riuscite"
},
"failed_requests": {
"name": "Richieste fallite"
},
"average_latency": {
"name": "Latenza media"
},
"max_latency": {
"name": "Latenza massima"
},
"min_latency": {
"name": "Latenza minima"
},
"last_model": {
"name": "Ultimo modello utilizzato"
},
"last_timestamp": {
"name": "Ora dell'ultima risposta"
},
"instance_name": {
"name": "Nome istanza"
},
"normalized_name": {
"name": "Nome normalizzato"
},
"conversation_history": {
"name": "Cronologia conversazione"
}
}
}
}
}
}
+289 -145
View File
@@ -1,192 +1,336 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "provider": {
"title": "Настройка HA Text AI", "title": "Настройки провайдера",
"description": "Настройте интеграцию OpenAI для умного дома. Требуется API ключ OpenAI. Подробнее о получении ключа на platform.openai.com", "description": "Укажите параметры подключения для выбранного провайдера ИИ.",
"data": { "data": {
"api_key": { "name": "Название экземпляра (например, 'GPT Помощник', 'Клод Ассистент')",
"name": "API ключ OpenAI", "api_key": "API-ключ для аутентификации",
"description": "Ваш API ключ с platform.openai.com. Храните его в безопасности." "model": "Модель ИИ для использования",
}, "api_endpoint": "Пользовательский URL-адрес конечной точки API (необязательно)",
"model": { "temperature": "Креативность ответа (0-2, меньше = более сфокусированно)",
"name": "AI Модель", "max_tokens": "Максимальная длина ответа (1-100000 токенов)",
"description": "Выберите модель AI. GPT-3.5-Turbo рекомендуется для большинства задач как оптимальное сочетание возможностей и стоимости." "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
}, "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"temperature": { "context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"name": "Температура", "max_history_size": "Максимальный размер истории разговора (1-100)",
"description": "Контролирует креативность ответов (0-2). Низкие значения (0.1-0.3) для точных ответов, высокие (0.8-2.0) для творческих." "allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
}, "disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
"max_tokens": { }
"name": "Максимум токенов", },
"description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов. Рекомендуется: 512-1024." "user": {
}, "title": "Настройка экземпляра текстового ИИ для Home Assistant",
"api_endpoint": { "description": "Настройте новый экземпляр ИИ-помощника с выбранным провайдером.",
"name": "API Endpoint", "data": {
"description": "URL API OpenAI. Оставьте значение по умолчанию, если не используете собственный endpoint." "name": "Название экземпляра (например, 'GPT Помощник', 'Клод Ассистент')",
}, "api_key": "API-ключ для аутентификации",
"request_interval": { "model": "Модель ИИ для использования",
"name": "Интервал запросов", "temperature": "Креативность ответа (0-2, меньше = более сфокусированно)",
"description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов запросов." "max_tokens": "Максимальная длина ответа (1-100000 токенов)",
} "api_endpoint": "Пользовательский URL-адрес конечной точки API (необязательно)",
"api_provider": "Провайдер API",
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
"error": { "error": {
"invalid_auth": "Неверный API ключ. Проверьте ключ OpenAI и попробуйте снова.", "history_storage_error": "Не удалось инициализировать хранилище истории. Проверьте разрешения.",
"cannot_connect": "Не удалось подключиться к API. Проверьте подключение к интернету и endpoint.", "history_rotation_error": "Ошибка при ротации файла истории.",
"unknown": "Неожиданная ошибка. Проверьте логи для подробностей.", "history_file_access_error": "Невозможно получить доступ к директории хранения истории.",
"already_exists": тот API ключ уже используется в другой интеграции.", "name_exists": кземпляр с таким именем уже существует",
"invalid_model": "Выбранная модель недоступна. Выберите другую модель.", "invalid_name": "Недопустимое имя экземпляра",
"rate_limit": "Превышен лимит API запросов. Попробуйте позже или увеличьте интервал запросов.", "invalid_auth": "Ошибка аутентификации - проверьте API-ключ",
"context_length": "Входные данные слишком длинные для выбранной модели. Уменьшите max_tokens или используйте модель с большим контекстом.", "api_key_required": "Необходимо ввести API-ключ при смене провайдера или эндпоинта",
"api_error": "Ошибка API OpenAI. Проверьте логи для подробностей.", "invalid_api_key": "Недопустимый API-ключ - пожалуйста, проверьте учетные данные",
"timeout": "Превышено время ожидания ответа от API.", "cannot_connect": "Не удалось подключиться к сервису API",
"queue_full": "Очередь запросов переполнена. Попробуйте позже." "invalid_model": "Выбранная модель недоступна",
"rate_limit": "Превышен лимит запросов",
"context_length": "Превышена длина контекста",
"rate_limit_exceeded": "Превышен лимит запросов API",
"maintenance": "Сервис находится на техническом обслуживании",
"invalid_response": "Получен некорректный ответ API",
"api_error": "Произошла ошибка сервиса API",
"timeout": "Время ожидания запроса истекло",
"invalid_instance": "Указан некорректный экземпляр",
"unknown": "Произошла непредвиденная ошибка",
"empty": "Имя не может быть пустым",
"name_too_long": "Имя должно быть не длиннее 50 символов"
}, },
"abort": { "abort": {
"already_configured": та интеграция OpenAI уже настроена", "already_configured": кземпляр уже настроен"
"auth_failed": "Ошибка аутентификации. Проверьте API ключ.",
"invalid_endpoint": "Указан неверный URL API endpoint"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Настройки HA Text AI", "title": "Выбор провайдера",
"description": "Измените настройки интеграции OpenAI. Изменения применятся к будущим запросам.", "description": "Выберите провайдера ИИ для этого экземпляра. Интеграция перезагрузится после сохранения изменений.",
"data": { "data": {
"temperature": { "api_provider": "Провайдер API"
"name": "Температура", }
"description": "Контролирует креативность ответов (0-2). Низкие значения для точных ответов, высокие для творческих." },
}, "settings": {
"max_tokens": { "title": "Настройки подключения и модели",
"name": "Максимум токенов", "description": "Настройте учётные данные API и параметры модели. Изменения вступят в силу после перезагрузки интеграции.",
"description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов." "data": {
}, "api_key": "API-ключ",
"request_interval": { "api_endpoint": "URL конечной точки API",
"name": "Интервал запросов", "model": "Модель ИИ",
"description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов." "temperature": "Креативность ответа (0-2)",
} "max_tokens": "Максимальная длина ответа (1-100000)",
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (совместимый)",
"anthropic": "Anthropic (совместимый)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Задать вопрос (HA Text AI)",
"description": "Отправить вопрос модели ИИ и получить подробный ответ. Сервис теперь возвращает данные ответа напрямую, устраняя необходимость в отдельных текстовых сенсорах и ограничение в 255 символов. Ответ также будет сохранен в истории разговора.",
"fields": {
"instance": {
"name": "Экземпляр",
"description": "Название экземпляра текстового ИИ для использования"
},
"question": {
"name": "Вопрос",
"description": "Ваш вопрос или запрос к ИИ-помощнику"
},
"context_messages": {
"name": "Контекстные сообщения",
"description": "Количество предыдущих сообщений для включения в контекст (1-20)"
},
"system_prompt": {
"name": "Системный промпт",
"description": "Необязательный системный промпт для установки контекста для этого конкретного вопроса"
},
"model": {
"name": "Модель",
"description": "Выберите модель ИИ для использования (необязательно, переопределяет настройки по умолчанию)"
},
"temperature": {
"name": "Температура",
"description": "Управление креативностью ответа (0.0-2.0)"
},
"max_tokens": {
"name": "Максимум токенов",
"description": "Максимальная длина ответа (1-100000 токенов)"
},
"structured_output": {
"name": "Структурированный вывод",
"description": "Включить режим структурированного JSON-вывода. При включении ИИ будет отвечать валидным JSON, соответствующим указанной схеме."
},
"json_schema": {
"name": "JSON Schema",
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
},
"disable_thinking": {
"name": "Отключить thinking",
"description": "Отключить режим thinking/reasoning для этого запроса. Переопределяет настройку интеграции."
}
}
},
"clear_history": {
"name": "Очистить историю",
"description": "Удалить все сохраненные вопросы и ответы из истории разговора",
"fields": {
"instance": {
"name": "Экземпляр",
"description": "Название экземпляра текстового ИИ для очистки истории"
}
}
},
"get_history": {
"name": "Получить историю",
"description": "Получить историю разговора с дополнительной фильтрацией и сортировкой",
"fields": {
"instance": {
"name": "Экземпляр",
"description": "Название экземпляра текстового ИИ для получения истории"
},
"limit": {
"name": "Лимит",
"description": "Количество разговоров для возврата (1-100)"
},
"filter_model": {
"name": "Фильтр модели",
"description": "Фильтрация разговоров по конкретной модели ИИ"
},
"start_date": {
"name": "Начальная дата",
"description": "Фильтрация разговоров, начиная с указанной даты/времени"
},
"include_metadata": {
"name": "Включить метаданные",
"description": "Включить дополнительную информацию, например, использованные токены, время ответа и т.д."
},
"sort_order": {
"name": "Порядок сортировки",
"description": "Порядок сортировки результатов (сначала новые или старые)"
}
}
},
"set_system_prompt": {
"name": "Установить системный промпт",
"description": "Установить инструкции по умолчанию для поведения ИИ во всех будущих разговорах",
"fields": {
"instance": {
"name": "Экземпляр",
"description": "Название экземпляра текстового ИИ для установки системного промпта"
},
"prompt": {
"name": "Системный промпт",
"description": "Инструкции, определяющие, как ИИ должен вести себя и отвечать"
} }
} }
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"last_response": { "ha_text_ai": {
"name": "Последний ответ", "name": "{name}",
"state": {
"ready": "Готов",
"processing": "Обработка",
"error": "Ошибка",
"disconnected": "Отключен",
"rate_limited": "Лимит запросов",
"maintenance": "Техническое обслуживание",
"initializing": "Инициализация",
"retrying": "Повторная попытка"
},
"state_attributes": { "state_attributes": {
"last_updated": {
"name": "Последнее обновление",
"description": "Время последнего ответа AI"
},
"question": { "question": {
"name": "Последний вопрос", "name": "Последний вопрос"
"description": "Последний заданный вопрос"
}, },
"response": { "response": {
"name": "Ответ AI", "name": "Последний ответ"
"description": "Последний ответ от AI"
}, },
"model": { "model": {
"name": "Текущая модель", "name": "Текущая модель"
"description": "Используемая модель AI"
}, },
"temperature": { "temperature": {
"name": "Настройка температуры", "name": "Температура"
"description": "Текущий параметр температуры"
}, },
"max_tokens": { "max_tokens": {
"name": "Лимит токенов", "name": "Максимум токенов"
"description": "Текущий лимит максимальных токенов"
},
"total_responses": {
"name": "Всего ответов",
"description": "Количество ответов с последнего сброса"
}, },
"system_prompt": { "system_prompt": {
"name": "Системный промпт", "name": "Системный промпт"
"description": "Текущие системные инструкции для AI"
}, },
"response_time": { "response_time": {
"name": "Время ответа", "name": "Время последнего ответа"
"description": "Время генерации последнего ответа"
}, },
"queue_size": { "total_responses": {
"name": "Размер очереди", "name": "Всего ответов"
"description": "Текущий размер очереди запросов"
},
"api_status": {
"name": "Статус API",
"description": "Текущий статус подключения к API"
}, },
"error_count": { "error_count": {
"name": "Счётчик ошибок", "name": "Количество ошибок"
"description": "Количество ошибок с последнего сброса"
}, },
"last_error": { "last_error": {
"name": "Последняя ошибка", "name": "Последняя ошибка"
"description": "Описание последней возникшей ошибки" },
"api_status": {
"name": "Статус API"
},
"tokens_used": {
"name": "Всего использовано токенов"
},
"average_response_time": {
"name": "Среднее время ответа"
},
"last_request_time": {
"name": "Время последнего запроса"
},
"is_processing": {
"name": "Статус обработки"
},
"is_rate_limited": {
"name": "Статус лимита запросов"
},
"is_maintenance": {
"name": "Статус обслуживания"
},
"api_version": {
"name": "Версия API"
},
"endpoint_status": {
"name": "Статус конечной точки"
},
"performance_metrics": {
"name": "Показатели производительности"
},
"history_size": {
"name": "Размер истории"
},
"uptime": {
"name": "Время работы"
},
"total_tokens": {
"name": "Всего токенов"
},
"prompt_tokens": {
"name": "Токены промпта"
},
"completion_tokens": {
"name": "Токены завершения"
},
"successful_requests": {
"name": "Успешные запросы"
},
"failed_requests": {
"name": "Неудачные запросы"
},
"average_latency": {
"name": "Средняя задержка"
},
"max_latency": {
"name": "Максимальная задержка"
},
"min_latency": {
"name": "Минимальная задержка"
},
"last_model": {
"name": "Последняя использованная модель"
},
"last_timestamp": {
"name": "Время последнего ответа"
},
"instance_name": {
"name": "Имя экземпляра"
},
"normalized_name": {
"name": "Нормализованное имя"
},
"last_error": {
"name": "Последняя ошибка"
},
"conversation_history": {
"name": "История разговоров"
} }
} }
} }
} }
},
"services": {
"ask_question": {
"name": "Задать вопрос",
"description": "Отправить вопрос модели AI и получить подробный ответ. Ответ сохраняется в истории.",
"fields": {
"question": {
"name": "Вопрос",
"description": "Ваш вопрос или запрос для AI. Будьте конкретны для лучших результатов."
},
"model": {
"name": "Модель",
"description": "Модель AI для использования (необязательно, переопределяет настройки по умолчанию)."
},
"temperature": {
"name": "Температура",
"description": "Уровень креативности ответа (0-2, необязательно)."
},
"max_tokens": {
"name": "Максимум токенов",
"description": "Максимальная длина ответа (необязательно)."
}
}
},
"clear_history": {
"name": "Очистить историю",
"description": "Удалить всю историю разговоров. Это действие нельзя отменить."
},
"get_history": {
"name": "Получить историю",
"description": "Получить историю разговоров, включая вопросы, ответы и временные метки.",
"fields": {
"limit": {
"name": "Лимит",
"description": "Количество последних разговоров для получения (по умолчанию 10)."
},
"filter_model": {
"name": "Фильтр по модели",
"description": "Получить только разговоры с определённой моделью AI."
}
}
},
"set_system_prompt": {
"name": "Установить системный промпт",
"description": "Настроить поведение AI, установив системный промпт.",
"fields": {
"prompt": {
"name": "Промпт",
"description": "Инструкции, определяющие поведение и стиль ответов AI."
},
"clear_prompt": {
"name": "Очистить промпт",
"description": "Удалить текущий системный промпт перед установкой нового."
}
}
}
} }
} }
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "Подешавања провајдера",
"description": "Обезбедите детаље о вези за изабраног AI провајдера.",
"data": {
"name": "Име инстанце (нпр. 'GPT Асистент', 'Claude Помоћник')",
"api_key": "API кључ за аутентификацију",
"model": "AI модел који ће се користити",
"api_endpoint": "Прилагођени URL API крајње тачке (опционо)",
"temperature": "Креативност одговора (0-2, нижа = фокусираније)",
"max_tokens": "Максимална дужина одговора (1-100000 токена)",
"request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
"title": "Конфигуришите HA Text AI инстанцу",
"description": "Подесите нову AI асистент инстанцу са изабраним провајдером.",
"data": {
"name": "Име инстанце (нпр. 'GPT Асистент', 'Claude Помоћник')",
"api_key": "API кључ за аутентификацију",
"model": "AI модел који ће се користити",
"temperature": "Креативност одговора (0-2, нижа = фокусираније)",
"max_tokens": "Максимална дужина одговора (1-100000 токена)",
"api_endpoint": "Прилагођени URL API крајње тачке (опционо)",
"api_provider": "API провајдер",
"request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
}
}
},
"error": {
"history_storage_error": "Неуспела инициализација складишта историје. Проверите дозволе.",
"history_rotation_error": "Грешка током ротације историјских датотека.",
"history_file_access_error": "Немогуће приступити директоријуму складишта историје.",
"name_exists": "Инстанца са овим именом већ постоји",
"invalid_name": "Неважеће име инстанце",
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
"api_key_required": "API кључ је обавезан при промени провајдера или endpoint-а",
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
"cannot_connect": "Неуспело повезивање са API сервисом",
"invalid_model": "Изабрани модел није доступан",
"rate_limit": "Пређена граница захтева",
"context_length": "Дужина контекста пређена",
"rate_limit_exceeded": "Пређена граница API захтева",
"maintenance": "Сервис је у одржавању",
"invalid_response": "Примљен неважећи API одговор",
"api_error": "Дошло је до грешке у API сервису",
"timeout": "Време захтева је истекло",
"invalid_instance": "Неважећа инстанца је назначена",
"unknown": "Дошло је до неочекиване грешке",
"empty": "Име не може бити празно",
"name_too_long": "Име мора бити 50 знакова или мање"
},
"abort": {
"already_configured": "Инстанца је већ конфигурисана"
}
},
"options": {
"step": {
"init": {
"title": "Изаберите провајдера",
"description": "Изаберите AI провајдера за ову инстанцу. Интеграција ће се поново учитати након чувања измена.",
"data": {
"api_provider": "API провајдер"
}
},
"settings": {
"title": "Подешавања везе и модела",
"description": "Конфигуришите API акредитиве и параметре модела. Промене ће ступити на снагу након поновног учитавања интеграције.",
"data": {
"api_key": "API кључ",
"api_endpoint": "URL API крајње тачке",
"model": "AI модел",
"temperature": "Креативност одговора (0-2)",
"max_tokens": "Максимална дужина одговора (1-100000)",
"request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (компатибилан)",
"anthropic": "Anthropic (компатибилан)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Поставите питање (HA Text AI)",
"description": "Пошаљите питање AI моделу и добијте детаљан одговор. Овај сервис сада враћа податке одговора директно, елиминишући потребу за засебним текстуалним сензорима и ограничење од 255 карактера. Одговор ће такође бити сачуван у историји разговора.",
"fields": {
"instance": {
"name": "Инстанца",
"description": "Име HA Text AI инстанце коју ћете користити"
},
"question": {
"name": "Питање",
"description": "Ваше питање или упит за AI асистента"
},
"context_messages": {
"name": "Контекстуалне поруке",
"description": "Број претходних порука које треба укључити у контекст (1-20)"
},
"system_prompt": {
"name": "Системски упит",
"description": "Опционални системски упит за постављање контекста за ово конкретно питање"
},
"model": {
"name": "Модел",
"description": "Изаберите AI модел који ћете користити (опционо, надмашује подразумевану поставку)"
},
"temperature": {
"name": "Температура",
"description": "Контролише креативност одговора (0.0-2.0)"
},
"max_tokens": {
"name": "Максимални токени",
"description": "Максимална дужина одговора (1-100000 токена)"
},
"structured_output": {
"name": "Структурисани излаз",
"description": "Омогући JSON структурисани излаз. Када је омогућено, AI ће одговарати валидним JSON-ом који одговара датој шеми."
},
"json_schema": {
"name": "JSON шема",
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
},
"disable_thinking": {
"name": "Онемогући thinking",
"description": "Онемогући thinking/reasoning режим за овај захтев. Надмашује поставку интеграције."
}
}
},
"clear_history": {
"name": "Обриши историју",
"description": "Избришите све сачуване питања и одговоре из историје разговора",
"fields": {
"instance": {
"name": "Инстанца",
"description": "Име HA Text AI инстанце за коју желите да обришете историју"
}
}
},
"get_history": {
"name": "Добијте историју",
"description": "Повратите историју разговора уз опционално филтрирање и сортирање",
"fields": {
"instance": {
"name": "Инстанца",
"description": "Име HA Text AI инстанце из које желите да добијете историју"
},
"limit": {
"name": "Лимит",
"description": "Број разговора које треба вратити (1-100)"
},
"filter_model": {
"name": "Филтер модел",
"description": "Филтрирајте разговоре по одређеном AI моделу"
},
"start_date": {
"name": "Датум почетка",
"description": "Филтрирајте разговоре који почињу од овог датума/времена"
},
"include_metadata": {
"name": "Укључи метаподатке",
"description": "Укључите додатне информације као што су коришћени токени, време одговора итд."
},
"sort_order": {
"name": "Редослед сортирања",
"description": "Редослед сортирања за резултате (најновији или најстарији први)"
}
}
},
"set_system_prompt": {
"name": "Поставите системски упит",
"description": "Поставите подразумеване инструкције за системско понашање за све будуће разговоре",
"fields": {
"instance": {
"name": "Инстанца",
"description": "Име HA Text AI инстанце за коју желите да поставите системски упит"
},
"prompt": {
"name": "Системски упит",
"description": "Инструкције које дефинишу како AI треба да се понаша и одговара"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Спремно",
"processing": "Обрада",
"error": "Грешка",
"disconnected": "Искључено",
"rate_limited": "Ограничење захтева",
"maintenance": "Одржавање",
"initializing": "Инициализује се",
"retrying": "Покушава поново"
},
"state_attributes": {
"question": {
"name": "Последње питање"
},
"response": {
"name": "Последњи одговор"
},
"model": {
"name": "Тренутни модел"
},
"temperature": {
"name": "Температура"
},
"max_tokens": {
"name": "Максимални токени"
},
"system_prompt": {
"name": "Системски упит"
},
"response_time": {
"name": "Време последњег одговора"
},
"total_responses": {
"name": "Укупно одговора"
},
"error_count": {
"name": "Број грешака"
},
"last_error": {
"name": "Последња грешка"
},
"api_status": {
"name": "Статус API"
},
"tokens_used": {
"name": "Укупно коришћени токени"
},
"average_response_time": {
"name": "Просечно време одговора"
},
"last_request_time": {
"name": "Време последњег захтева"
},
"is_processing": {
"name": "Статус обраде"
},
"is_rate_limited": {
"name": "Статус ограничења захтева"
},
"is_maintenance": {
"name": "Статус одржавања"
},
"api_version": {
"name": "Верзија API"
},
"endpoint_status": {
"name": "Статус крајње тачке"
},
"performance_metrics": {
"name": "Перформансне метрике"
},
"history_size": {
"name": "Величина историје"
},
"uptime": {
"name": "Уптиме"
},
"total_tokens": {
"name": "Укупно токена"
},
"prompt_tokens": {
"name": "Токени упита"
},
"completion_tokens": {
"name": "Токени завршетка"
},
"successful_requests": {
"name": "Успешни захтеви"
},
"failed_requests": {
"name": "Неуспешни захтеви"
},
"average_latency": {
"name": "Просечна латенција"
},
"max_latency": {
"name": "Максимална латенција"
},
"min_latency": {
"name": "Минимална латенција"
},
"last_model": {
"name": "Последњи коришћени модел"
},
"last_timestamp": {
"name": "Време последњег одговора"
},
"instance_name": {
"name": "Назив инстанце"
},
"normalized_name": {
"name": "Нормализовани назив"
},
"conversation_history": {
"name": "Историја разговора"
}
}
}
}
}
}
@@ -0,0 +1,333 @@
{
"config": {
"step": {
"provider": {
"title": "提供者设置",
"description": "提供所选AI提供者的连接详细信息。",
"data": {
"name": "实例名称(例如,'GPT助手''Claude助手'",
"api_key": "用于身份验证的API密钥",
"model": "要使用的AI模型",
"api_endpoint": "自定义API端点URL(可选)",
"temperature": "响应创造力(0-2,越低越专注)",
"max_tokens": "最大响应长度(1-100000个标记)",
"request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
}
},
"user": {
"title": "配置HA文本AI实例",
"description": "使用所选提供者设置新的AI助手实例。",
"data": {
"name": "实例名称(例如,'GPT助手''Claude助手'",
"api_key": "用于身份验证的API密钥",
"model": "要使用的AI模型",
"temperature": "响应创造力(0-2,越低越专注)",
"max_tokens": "最大响应长度(1-100000个标记)",
"api_endpoint": "自定义API端点URL(可选)",
"api_provider": "API提供者",
"request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
}
}
},
"error": {
"history_storage_error": "无法初始化历史存储。检查权限。",
"history_rotation_error": "历史文件轮换时出错。",
"history_file_access_error": "无法访问历史存储目录。",
"name_exists": "具有此名称的实例已存在",
"invalid_name": "无效的实例名称",
"invalid_auth": "身份验证失败 - 检查您的API密钥",
"api_key_required": "更改提供商或端点时需要输入 API 密钥",
"invalid_api_key": "无效的API密钥 - 请验证您的凭据",
"cannot_connect": "无法连接到API服务",
"invalid_model": "所选模型不可用",
"rate_limit": "超出速率限制",
"context_length": "上下文长度超出限制",
"rate_limit_exceeded": "API速率限制超出",
"maintenance": "服务正在维护中",
"invalid_response": "收到无效的API响应",
"api_error": "发生API服务错误",
"timeout": "请求超时",
"invalid_instance": "指定的实例无效",
"unknown": "发生意外错误",
"empty": "名称不能为空",
"name_too_long": "名称必须少于50个字符"
},
"abort": {
"already_configured": "实例已配置"
}
},
"options": {
"step": {
"init": {
"title": "选择提供者",
"description": "选择此实例的AI提供者。保存更改后集成将重新加载。",
"data": {
"api_provider": "API提供者"
}
},
"settings": {
"title": "连接和模型设置",
"description": "配置API凭据和模型参数。更改将在集成重新加载后生效。",
"data": {
"api_key": "API密钥",
"api_endpoint": "API端点URL",
"model": "AI模型",
"temperature": "响应创造力(0-2",
"max_tokens": "最大响应长度(1-100000",
"request_interval": "最小请求间隔(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "要包含在上下文中的先前消息数量(1-20)",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI(兼容)",
"anthropic": "Anthropic(兼容)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "提问 (HA Text AI)",
"description": "向AI模型发送问题并获得详细回答。此服务现在直接返回响应数据,消除了对单独文本传感器的需要和255字符限制。响应也将存储在对话历史中。",
"fields": {
"instance": {
"name": "实例",
"description": "要使用的HA文本AI实例名称"
},
"question": {
"name": "问题",
"description": "您对AI助手的问题或提示"
},
"context_messages": {
"name": "上下文消息",
"description": "要包含在上下文中的先前消息数量(1-20)"
},
"system_prompt": {
"name": "系统提示",
"description": "可选的系统提示,用于为此特定问题设置上下文"
},
"model": {
"name": "模型",
"description": "选择要使用的AI模型(可选,覆盖默认设置)"
},
"temperature": {
"name": "温度",
"description": "控制响应创造力(0.0-2.0"
},
"max_tokens": {
"name": "最大标记数",
"description": "响应的最大长度(1-100000个标记)"
},
"structured_output": {
"name": "结构化输出",
"description": "启用JSON结构化输出模式。启用后,AI将以符合提供的模式的有效JSON进行响应。"
},
"json_schema": {
"name": "JSON模式",
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
},
"disable_thinking": {
"name": "禁用思考",
"description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
}
}
},
"clear_history": {
"name": "清除历史",
"description": "删除对话历史中存储的所有问题和响应",
"fields": {
"instance": {
"name": "实例",
"description": "要清除历史的HA文本AI实例名称"
}
}
},
"get_history": {
"name": "获取历史",
"description": "检索对话历史,可选的过滤和排序",
"fields": {
"instance": {
"name": "实例",
"description": "要获取历史的HA文本AI实例名称"
},
"limit": {
"name": "限制",
"description": "要返回的对话数量(1-100"
},
"filter_model": {
"name": "过滤模型",
"description": "按特定AI模型过滤对话"
},
"start_date": {
"name": "开始日期",
"description": "过滤从此日期/时间开始的对话"
},
"include_metadata": {
"name": "包含元数据",
"description": "包括额外信息,如使用的标记、响应时间等。"
},
"sort_order": {
"name": "排序顺序",
"description": "结果的排序顺序(最新或最旧优先)"
}
}
},
"set_system_prompt": {
"name": "设置系统提示",
"description": "为所有未来的对话设置默认的系统行为指令",
"fields": {
"instance": {
"name": "实例",
"description": "要设置系统提示的HA文本AI实例名称"
},
"prompt": {
"name": "系统提示",
"description": "定义AI应如何行为和响应的指令"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "准备就绪",
"processing": "处理中",
"error": "错误",
"disconnected": "已断开连接",
"rate_limited": "速率限制",
"maintenance": "维护中",
"initializing": "初始化中",
"retrying": "重试中"
},
"state_attributes": {
"question": {
"name": "最后问题"
},
"response": {
"name": "最后响应"
},
"model": {
"name": "当前模型"
},
"temperature": {
"name": "温度"
},
"max_tokens": {
"name": "最大标记数"
},
"system_prompt": {
"name": "系统提示"
},
"response_time": {
"name": "最后响应时间"
},
"total_responses": {
"name": "总响应数"
},
"error_count": {
"name": "错误计数"
},
"last_error": {
"name": "最后错误"
},
"api_status": {
"name": "API状态"
},
"tokens_used": {
"name": "总使用标记数"
},
"average_response_time": {
"name": "平均响应时间"
},
"last_request_time": {
"name": "最后请求时间"
},
"is_processing": {
"name": "处理状态"
},
"is_rate_limited": {
"name": "速率限制状态"
},
"is_maintenance": {
"name": "维护状态"
},
"api_version": {
"name": "API版本"
},
"endpoint_status": {
"name": "端点状态"
},
"performance_metrics": {
"name": "性能指标"
},
"history_size": {
"name": "历史大小"
},
"uptime": {
"name": "正常运行时间"
},
"total_tokens": {
"name": "总标记数"
},
"prompt_tokens": {
"name": "提示标记数"
},
"completion_tokens": {
"name": "完成标记数"
},
"successful_requests": {
"name": "成功请求数"
},
"failed_requests": {
"name": "失败请求数"
},
"average_latency": {
"name": "平均延迟"
},
"max_latency": {
"name": "最大延迟"
},
"min_latency": {
"name": "最小延迟"
},
"last_model": {
"name": "最近使用的模型"
},
"last_timestamp": {
"name": "最近响应时间"
},
"instance_name": {
"name": "实例名称"
},
"normalized_name": {
"name": "规范化名称"
},
"conversation_history": {
"name": "对话历史"
}
}
}
}
}
}
+258
View File
@@ -0,0 +1,258 @@
"""
Utility functions for HA Text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import hashlib
import ipaddress
import logging
import socket
from typing import Any
from urllib.parse import urlparse
import aiohttp
from aiohttp.abc import AbstractResolver
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
def normalize_name(name: str) -> str:
"""Normalize name to conform to HA naming convention using underscores.
If the input collapses to an empty string (all non-alphanumeric or
all underscores), fall back to a short hash of the original so that
downstream entity IDs never end with a trailing underscore.
"""
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
if not normalized:
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
normalized = f"instance_{digest}"
return normalized
def safe_log_data(
data: dict[str, Any],
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
) -> dict[str, Any]:
"""Filter sensitive keys from data for safe logging."""
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
class _RestrictedIPError(ValueError):
"""Raised when an IP address is in a restricted range."""
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Check if an IP address is in a restricted range."""
return (
addr.is_private
or addr.is_reserved
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_unspecified
)
def _is_cloud_metadata_or_unsafe(
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
) -> bool:
"""Block link-local and cloud instance-metadata addresses.
These must be blocked even in allow_local_network mode:
- IPv4 link-local (169.254.0.0/16) covers AWS/GCP/Azure IMDS 169.254.169.254.
- IPv6 link-local (fe80::/10).
- Multicast and unspecified addresses.
Without this check, a self-hosted HA running on a cloud VM could be
tricked into exfiltrating cloud credentials via IMDS.
"""
return addr.is_multicast or addr.is_unspecified or addr.is_link_local
class _PinnedResolver(AbstractResolver):
"""aiohttp resolver that returns pre-validated IPs for a single hostname.
Why: prevents DNS-rebinding attacks. After validate_endpoint has
confirmed the hostname resolves to a safe IP, we pin that IP in the
resolver used by the aiohttp session. aiohttp then skips its own
DNS lookup on each request and uses the pinned IP, closing the
TOCTOU gap between validation and actual HTTP call.
"""
def __init__(
self,
pinned: dict[str, list[tuple[str, int]]],
) -> None:
self._pinned = pinned
async def resolve(
self,
host: str,
port: int = 0,
family: int = socket.AF_INET,
) -> list[dict[str, Any]]:
entries = self._pinned.get(host.lower())
if entries is None:
# Every request on a pinned session must target the validated
# host. Resolving anything else means a request escaped the pin
# (a new call site or a config bug) — fail closed rather than
# fall back to live, unvalidated DNS.
raise OSError(f"Refusing to resolve unpinned host: {host}")
return [
{
"hostname": host,
"host": ip,
"port": port or default_port,
"family": _family_for(ip),
"proto": 0,
"flags": 0,
}
for ip, default_port in entries
]
async def close(self) -> None:
"""Nothing to close; the resolver holds only the pinned map."""
def _family_for(ip: str) -> int:
"""Return AF_INET or AF_INET6 based on the IP literal."""
try:
return socket.AF_INET6 if ":" in ip else socket.AF_INET
except Exception:
return socket.AF_INET
async def resolve_hostname_ips(
hass: HomeAssistant,
hostname: str,
) -> list[str]:
"""Resolve hostname to all its IPs via the event-loop-safe executor.
Returns a list of IP strings (may contain both IPv4 and IPv6).
Raises ValueError on resolution failure.
"""
try:
addrinfos = await hass.async_add_executor_job(
socket.getaddrinfo, hostname, None
)
except socket.gaierror as err:
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
ips = []
seen: set[str] = set()
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
ip = sockaddr[0]
if ip not in seen:
seen.add(ip)
ips.append(ip)
if not ips:
raise ValueError(f"No IPs for hostname: {hostname}")
return ips
def create_pinned_session(
endpoint: str,
resolved_ips: list[str],
) -> aiohttp.ClientSession:
"""Create an isolated aiohttp session with pinned DNS and no cookie jar.
Addresses two issues at once:
- DNS rebinding: aiohttp will reuse the pinned IPs from validate_endpoint
rather than re-resolving the hostname on each request.
- Cookie pollution: DummyCookieJar prevents cookies from leaking between
this integration and other HA components sharing the same domain.
Built directly on aiohttp: HA's async_create_clientsession always
injects its own pooled connector and rejects a caller-supplied one,
so a custom resolver cannot go through the helper. The caller owns
the session and must close it. Requests must pass
allow_redirects=False so a 3xx response cannot route past the pinned
resolver to an unvalidated host.
"""
parsed = urlparse(endpoint)
hostname = (parsed.hostname or "").lower()
port = parsed.port or (443 if parsed.scheme == "https" else 80)
pinned: dict[str, list[tuple[str, int]]] = {
hostname: [(ip, port) for ip in resolved_ips]
}
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pinned))
return aiohttp.ClientSession(
connector=connector,
cookie_jar=aiohttp.DummyCookieJar(),
)
async def validate_endpoint(
hass: HomeAssistant,
endpoint: str,
*,
allow_local: bool = False,
) -> tuple[str, list[str]]:
"""Validate API endpoint URL for security and pin resolved IPs.
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
When allow_local is True, permits private IPs and HTTP scheme for self-hosted proxies.
Uses async DNS resolution to avoid blocking the event loop.
Returns: (validated_endpoint_without_trailing_slash, resolved_ips).
The resolved IPs are intended for pinning in aiohttp resolver via
create_pinned_session(), closing the DNS-rebinding TOCTOU gap.
Raises:
ValueError: If the endpoint fails validation.
"""
parsed = urlparse(endpoint)
if allow_local:
if parsed.scheme not in ("https", "http"):
raise ValueError("Only HTTPS and HTTP endpoints are allowed")
else:
if parsed.scheme not in ("https",):
raise ValueError("Only HTTPS endpoints are allowed")
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid endpoint URL: no hostname")
resolved_ips: list[str] = []
# Collect and check all resolved IPs (or IP literal directly).
def _collect(ips: list[str]) -> None:
for ip in ips:
if ip not in resolved_ips:
resolved_ips.append(ip)
try:
addr = ipaddress.ip_address(hostname)
_is_ip_literal = True
except ValueError:
addr = None
_is_ip_literal = False
if _is_ip_literal:
_collect([hostname])
else:
try:
addrinfos = await hass.async_add_executor_job(
socket.getaddrinfo, hostname, None
)
except socket.gaierror as err:
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
_collect([sockaddr[0] for (*_, sockaddr) in addrinfos])
if not resolved_ips:
raise ValueError(f"No IPs resolved for hostname: {hostname}")
# Validate each resolved IP against the selected policy.
for ip_str in resolved_ips:
ip_obj = ipaddress.ip_address(ip_str)
if allow_local:
if _is_cloud_metadata_or_unsafe(ip_obj):
raise ValueError(
"Link-local/metadata/multicast addresses are not allowed"
)
else:
if _check_ip_restricted(ip_obj):
raise _RestrictedIPError(
"Private/reserved IP addresses are not allowed"
)
return endpoint.rstrip("/"), resolved_ips
BIN
View File
Binary file not shown.
+2 -6
View File
@@ -1,9 +1,5 @@
{ {
"name": "HA text AI", "name": "HA Text AI",
"render_readme": true, "render_readme": true,
"domains": ["sensor"], "homeassistant": "2024.12.0"
"homeassistant": "2024.11.0",
"icon": "mdi:brain",
"version": "1.0.5",
"documentation": "https://github.com/smkrv/ha-text-ai"
} }
-3
View File
@@ -1,3 +0,0 @@
pytest
pytest-asyncio
homeassistant
+35
View File
@@ -0,0 +1,35 @@
```
custom_components/ha_text_ai/
├── __init__.py
├── api_client.py
├── config_flow.py
├── const.py
├── coordinator.py
├── history.py
├── metrics.py
├── providers.py
├── sensor.py
├── services.yaml
├── strings.json
├── utils.py
├── icons
│ ├── dark_icon.png
│ ├── dark_icon@2x.png
│ ├── dark_logo.png
│ ├── dark_logo@2x.png
│ ├── icon.png
│ ├── icon@2x.png
│ ├── logo.png
│ └── logo@2x.png
├── manifest.json
└── translations
├── de.json
├── en.json
├── es.json
├── hi.json
├── it.json
├── ru.json
├── sr.json
└── zh.json
```