Skip to content
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"python.analysis.typeCheckingMode": "basic"
}
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ A maintained fork of **Extended OpenAI Conversation** for **Home Assistant** tha
- **Options gear** (model, strategy, effort, max tokens, temp/top‑p, prompt) via `OptionsFlowWithReload`
- **Azure OpenAI** support (Base URL + API version)
- **Async‑safe client** using HA’s shared HTTPX to avoid blocking SSL CA loads
- **Toolbox parity** with upstream (service execution, automations, history, REST/scrape/script/template/composite when configured)
- **Hosted web search** hook for reasoning models with graceful chat fallback
- **Optional MCP bridge** (lazy import; safe no-op when library absent)
- **Tool execution limits** (per-call timeout, depth/call caps, chat log breadcrumbs for start/result)

## Requirements
- Home Assistant **2025.4.0+** recommended
Expand All @@ -36,9 +40,59 @@ A maintained fork of **Extended OpenAI Conversation** for **Home Assistant** tha
Open the integration card → **Configure** (gear):
- **Model** (default `gpt-5`)
- **Model strategy**: `auto` \| `force_chat_completions` \| `force_responses_api`
- **Use Responses API** (for nonreasoning models when strategy is `auto`)
- **Use Responses API** (for non-reasoning models when strategy is `auto`)
- **Reasoning effort**: `minimal` \| `low` \| `medium` \| `high`
- **Max tokens**, **Temperature**, **Top‑p**, **System prompt**
- **Functions YAML**: list of tool specs (defaults cover service/automations/history); extend to add REST/scrape/script/template/composite entries.
- **Tool limits**: max tool calls/iterations, per-call timeout, response size cap.
- **Web search**: enable hosted search, context size (`small`/`medium`/`large` or raw token budget), optional approximate location sharing.
- **MCP bridge**: toggles + timeout/payload caps (requires [`mcp`](https://pypi.org/project/mcp/) to expose external tools).

### Toolbox
- Default toolbox exposes `execute_service`, `add_automation`, and `get_history`
- Extend via **Functions YAML** in options. Each entry:

```yaml
- spec:
name: rest_weather
description: Fetch the daily forecast from the weather API.
parameters:
type: object
properties:
city:
type: string
function:
type: rest
resource: https://api.example.com/forecast
method: GET
headers:
Authorization: "{{ secrets.weather_token }}"
payload_template: "{{ {'city': city} | tojson }}"
```

- `scrape` tool lazily imports `beautifulsoup4` (installed via manifest); if import fails at runtime the tool returns a friendly error without breaking the integration.
- Tool calls are capped per turn and per loop, with start/result breadcrumbs logged to the Assist chat log for auditability.

### Web search
- When `Enable hosted web search` is on, reasoning paths send the official `web_search` tool.
- Context size accepts `small`/`medium`/`large` or an integer budget (auto-mapped to low/medium/high).
- Optionally share approximate location (city/region/country/timezone) using HA config data.
- Chat Completions log a notice when the selected model lacks hosted search support and continue without failing.

### MCP bridge
- Disabled by default; toggle **Enable MCP bridge** to attempt discovery via the optional [`mcp`](https://pypi.org/project/mcp/) SDK.
- When the SDK is unavailable the bridge is a no-op. Future releases can hook into `MCPBridge` for richer behaviour.
- Timeout and payload caps prevent runaway external tool calls.

## Manual validation checklist
- Base conversation returns `ConversationResult` with language, `response_type`, and `continue_conversation` populated.
- `execute_service` honours Assist exposure; non-exposed entities return a readable denial.
- `get_history` responds with a concise summary (≤10 entries per entity).
- `rest` and `scrape` enforce per-call timeout/output caps; missing `beautifulsoup4` yields a graceful error.
- `composite` chains respect the orchestrator depth limit (safe failure message).
- Web search works end-to-end on reasoning models and logs a skip message on unsupported chat models.
- MCP bridge disabled → no change; when enabled (with SDK) tool discovery/execution routes through the orchestrator.
- Tool start/result breadcrumbs appear in the HA chat log for traceability.

### Recommended (long, smart chats)
- Model: `gpt-5`
Expand Down
127 changes: 125 additions & 2 deletions custom_components/extended_openai_conversation/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any

import voluptuous as vol
import yaml
from homeassistant import config_entries
from homeassistant.config_entries import (
ConfigFlow,
Expand All @@ -26,6 +27,30 @@
CONF_TOP_P,
CONF_MAX_TOKENS,
CONF_REASONING_EFFORT,
CONF_FUNCTIONS,
CONF_FUNCTIONS_RAW,
CONF_MAX_TOOL_CALLS,
CONF_MAX_TOOL_CHAIN,
CONF_TOOL_TIMEOUT,
CONF_TOOL_MAX_OUTPUT_CHARS,
CONF_ENABLE_WEB_SEARCH,
CONF_WEB_SEARCH_CONTEXT_SIZE,
CONF_INCLUDE_HOME_LOCATION,
CONF_ENABLE_MCP,
CONF_MCP_TIMEOUT,
CONF_MCP_MAX_PAYLOAD,
DEFAULT_FUNCTIONS,
DEFAULT_MAX_TOOL_CALLS,
DEFAULT_MAX_TOOL_CHAIN,
DEFAULT_TOOL_TIMEOUT,
DEFAULT_TOOL_MAX_OUTPUT_CHARS,
DEFAULT_ENABLE_WEB_SEARCH,
DEFAULT_WEB_SEARCH_CONTEXT_SIZE,
DEFAULT_INCLUDE_HOME_LOCATION,
DEFAULT_ENABLE_MCP,
DEFAULT_MCP_TIMEOUT,
DEFAULT_MCP_MAX_PAYLOAD,
WEB_SEARCH_CONTEXT_SIZE_PRESETS,
DEFAULT_BASE_URL,
DEFAULT_CHAT_MODEL,
DEFAULT_MODEL_STRATEGY,
Expand All @@ -36,6 +61,39 @@
DEFAULT_PROMPT,
)

def _dump_functions_yaml(functions: list[dict[str, Any]] | None) -> str:
if not functions:
return ""
return yaml.safe_dump(functions, sort_keys=False)


def _parse_functions_yaml(raw: str) -> list[dict[str, Any]]:
if not raw.strip():
return []

try:
loaded = yaml.safe_load(raw) or []
except yaml.YAMLError as err:
raise vol.Invalid("invalid_yaml") from err

if not isinstance(loaded, list):
raise vol.Invalid("invalid_yaml")

normalized: list[dict[str, Any]] = []
for item in loaded:
if not isinstance(item, dict):
raise vol.Invalid("invalid_yaml")

spec = item.get("spec")
runtime = item.get("function")
if not isinstance(spec, dict) or not isinstance(runtime, dict):
raise vol.Invalid("invalid_yaml")
if not spec.get("name") or not runtime.get("type"):
raise vol.Invalid("invalid_yaml")
normalized.append({"spec": spec, "function": runtime})

return normalized


class ExtendedOpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Create the config entry."""
Expand Down Expand Up @@ -108,11 +166,47 @@ class EOCOptionsFlow(OptionsFlowWithReload):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
return self.async_create_entry(data=user_input)
raw_functions = user_input.get(CONF_FUNCTIONS_RAW, "")
try:
parsed_functions = _parse_functions_yaml(raw_functions)
except vol.Invalid:
errors[CONF_FUNCTIONS_RAW] = "invalid_functions_yaml"
else:
user_input[CONF_FUNCTIONS] = parsed_functions
user_input[CONF_FUNCTIONS_RAW] = raw_functions.strip()

if not errors:
return self.async_create_entry(data=user_input)

# Build the form with suggested current values.
opts = self.config_entry.options
functions_default = opts.get(CONF_FUNCTIONS)
functions_default_raw = opts.get(CONF_FUNCTIONS_RAW)
if functions_default_raw is None:
functions_default_raw = _dump_functions_yaml(functions_default or DEFAULT_FUNCTIONS)
if user_input and CONF_FUNCTIONS_RAW in user_input:
functions_default_raw = user_input.get(CONF_FUNCTIONS_RAW, functions_default_raw)

max_tool_calls_default = opts.get(CONF_MAX_TOOL_CALLS, DEFAULT_MAX_TOOL_CALLS)
max_tool_chain_default = opts.get(CONF_MAX_TOOL_CHAIN, DEFAULT_MAX_TOOL_CHAIN)
tool_timeout_default = opts.get(CONF_TOOL_TIMEOUT, DEFAULT_TOOL_TIMEOUT)
tool_max_output_default = opts.get(
CONF_TOOL_MAX_OUTPUT_CHARS, DEFAULT_TOOL_MAX_OUTPUT_CHARS
)
enable_search_default = opts.get(
CONF_ENABLE_WEB_SEARCH, DEFAULT_ENABLE_WEB_SEARCH
)
search_context_default = opts.get(
CONF_WEB_SEARCH_CONTEXT_SIZE, DEFAULT_WEB_SEARCH_CONTEXT_SIZE
)
include_location_default = opts.get(
CONF_INCLUDE_HOME_LOCATION, DEFAULT_INCLUDE_HOME_LOCATION
)
enable_mcp_default = opts.get(CONF_ENABLE_MCP, DEFAULT_ENABLE_MCP)
mcp_timeout_default = opts.get(CONF_MCP_TIMEOUT, DEFAULT_MCP_TIMEOUT)
mcp_payload_default = opts.get(CONF_MCP_MAX_PAYLOAD, DEFAULT_MCP_MAX_PAYLOAD)

schema = vol.Schema(
{
Expand All @@ -128,6 +222,35 @@ async def async_step_init(
vol.Optional(CONF_TOP_P, default=opts.get(CONF_TOP_P, DEFAULT_TOP_P)): vol.Coerce(float),
vol.Optional(CONF_MAX_TOKENS, default=opts.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.Coerce(int),
vol.Optional("prompt", default=opts.get("prompt", DEFAULT_PROMPT)): str,
vol.Optional(CONF_FUNCTIONS_RAW, default=functions_default_raw): str,
vol.Optional(
CONF_MAX_TOOL_CALLS, default=max_tool_calls_default
): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)),
vol.Optional(
CONF_MAX_TOOL_CHAIN, default=max_tool_chain_default
): vol.All(vol.Coerce(int), vol.Range(min=1, max=8)),
vol.Optional(CONF_TOOL_TIMEOUT, default=tool_timeout_default): vol.All(
vol.Coerce(float), vol.Range(min=1, max=60)
),
vol.Optional(
CONF_TOOL_MAX_OUTPUT_CHARS, default=tool_max_output_default
): vol.All(vol.Coerce(int), vol.Range(min=256, max=16384)),
vol.Optional(CONF_ENABLE_WEB_SEARCH, default=enable_search_default): bool,
vol.Optional(
CONF_WEB_SEARCH_CONTEXT_SIZE,
default=search_context_default,
): vol.Any(
vol.In(list(WEB_SEARCH_CONTEXT_SIZE_PRESETS.keys())),
vol.All(vol.Coerce(int), vol.Range(min=128, max=4096)),
),
vol.Optional(CONF_INCLUDE_HOME_LOCATION, default=include_location_default): bool,
vol.Optional(CONF_ENABLE_MCP, default=enable_mcp_default): bool,
vol.Optional(CONF_MCP_TIMEOUT, default=mcp_timeout_default): vol.All(
vol.Coerce(float), vol.Range(min=1, max=60)
),
vol.Optional(
CONF_MCP_MAX_PAYLOAD, default=mcp_payload_default
): vol.All(vol.Coerce(int), vol.Range(min=512, max=65536)),
}
)
return self.async_show_form(step_id="init", data_schema=schema)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
Loading