feat: Anthropic SDK backend — API key alternative to Claude CLI OAuth

Adds `anthropic_api` model type so users can authenticate with a direct
Anthropic API key instead of (or alongside) the CLI OAuth session.

- model_registry.py: `anthropic_api` type; `save/get/remove_anthropic_api_key()`
  mirroring the Google account pattern; `save_cloud_model()` now picks type
  based on credential type (cli → claude_cli, api_key → anthropic_api);
  `_resolve_model()` merges api_key from the credential entry
- llm_client.py: `_anthropic_api()` backend (AsyncAnthropic SDK); dispatch
  and fallback wiring; usage tracking
- routers/local_llm.py: Anthropic API key management routes
  (POST /settings/local/anthropic-key, /anthropic-key/{id}/remove);
  `anthropic_api` badge and edit-form credential selector
- static/local_llm.html: Anthropic Cloud Provider block now shows API key
  management (add/remove); Add Model → Anthropic tab has credential selector
  (CLI vs API key)
- requirements.txt: enable anthropic>=0.40.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-13 21:30:56 -04:00
parent 70665fadff
commit a92fd90f0d
9 changed files with 309 additions and 63 deletions

View File

@@ -22,7 +22,7 @@ Cortex_and_Inara_dev/
main.py ← App entry point, router registration
config.py ← All settings (pydantic-settings, reads .env)
persona.py ← Two-level identity: user + persona, path resolution, ContextVars
llm_client.py ← Claude CLI + Gemini CLI subprocess backends
llm_client.py ← Claude CLI + Gemini CLI subprocess backends + Anthropic SDK direct
orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff
context_loader.py ← Builds system prompt from persona files (tier 14)
session_store.py ← In-memory + file session persistence
@@ -139,9 +139,10 @@ http://localhost:8000/docs
- **Orchestrated tasks** go to `POST /orchestrate` — returns a job_id, result is polled
### LLM Backends
- `llm_client.py` manages Claude CLI (`claude --print`) and Gemini CLI (`gemini -p`) subprocesses
- `llm_client.py` manages Claude CLI (`claude --print`), Gemini CLI (`gemini -p`), and Anthropic SDK (`anthropic_api` type) subprocesses/calls
- `orchestrator_engine.py` uses the Gemini **API** (google-genai SDK) — completely separate from the Gemini CLI
- Claude OAuth token is read live from `~/.claude/.credentials.json` (never rely on stale env var)
- `anthropic_api` backend: user-configured API key from `providers.anthropic.credentials` in `model_registry.json` — uses `anthropic.AsyncAnthropic`
### Tool Strategy
- Orchestrator tools live in `cortex/tools/` — separate from the `ae_*` MCP tools

View File

@@ -33,15 +33,16 @@ async def cleanup() -> None:
# Map from registry model type → dispatch function key
_TYPE_TO_BACKEND = {
"claude_cli": "claude",
"gemini_cli": "gemini",
"gemini_api": "gemini", # gemini_api falls back to CLI in this context
"local_openai": "local",
"claude_cli": "claude",
"gemini_cli": "gemini",
"gemini_api": "gemini", # gemini_api falls back to CLI in this context
"local_openai": "local",
"anthropic_api": "anthropic_api",
}
# Explicit UI toggle values (kept for backward compat)
_EXPLICIT_BACKENDS = ("claude", "gemini", "local")
_FALLBACK = {"claude": "gemini", "gemini": "claude", "local": "claude"}
_FALLBACK = {"claude": "gemini", "gemini": "claude", "local": "claude", "anthropic_api": "claude"}
async def complete(
@@ -123,6 +124,8 @@ async def _dispatch(
return await _gemini(system_prompt, messages)
if backend == "local":
return await _local(system_prompt, messages, model_cfg, attachment=attachment)
if backend == "anthropic_api":
return await _anthropic_api(system_prompt, messages, model_cfg)
return await _claude(system_prompt, messages, model_cfg)
@@ -254,6 +257,51 @@ async def _local(
return text.strip()
async def _anthropic_api(system_prompt: str, messages: list[dict], model_cfg: dict | None) -> str:
"""Direct Anthropic API backend using the anthropic SDK."""
try:
import anthropic
except ImportError:
raise RuntimeError("anthropic SDK not installed — run: pip install 'anthropic>=0.40.0'")
cfg = model_cfg or {}
api_key = cfg.get("api_key", "")
model_name = cfg.get("model_name") or settings.default_model
if not api_key:
raise RuntimeError("No Anthropic API key — add one at /settings/models")
client = anthropic.AsyncAnthropic(api_key=api_key)
msgs = [{"role": m["role"], "content": m["content"]} for m in messages]
kwargs: dict = {
"model": model_name,
"max_tokens": 4096,
"messages": msgs,
}
if system_prompt:
kwargs["system"] = system_prompt
resp = await client.messages.create(**kwargs)
text = resp.content[0].text if resp.content else ""
if not text.strip():
raise RuntimeError("Anthropic API returned an empty response")
if resp.usage:
import usage_tracker
from persona import _user
asyncio.create_task(usage_tracker.record(
username=_user.get(),
backend="anthropic_api",
model_name=model_name,
prompt_tokens=resp.usage.input_tokens,
completion_tokens=resp.usage.output_tokens,
))
return text.strip()
async def _gemini(system_prompt: str, messages: list[dict]) -> str:
# Gemini CLI spawns MCP child processes that keep stdout pipes open after responding.
# start_new_session=True puts the whole tree in its own process group so

View File

@@ -57,6 +57,7 @@ Types:
"gemini_cli" — Gemini CLI subprocess
"gemini_api" — Gemini API (google-genai SDK); account_id → api_key from providers.google
"local_openai" — OpenAI-compatible endpoint; host_id → api_url/api_key from hosts[]
"anthropic_api" — Anthropic SDK direct; credential_id → api_key from providers.anthropic.credentials
Built-in model IDs (always resolvable without a registry entry):
"claude_cli" — resolves to the default Claude CLI model
@@ -353,6 +354,16 @@ def _resolve_model(registry: dict, model_id: str) -> dict | None:
logger.warning("model %s references missing account_id %s", model_id, account_id)
return dict(model)
if model_type == "anthropic_api":
credential_id = model.get("credential_id")
if credential_id:
creds = registry.get("providers", {}).get("anthropic", {}).get("credentials", [])
cred = next((c for c in creds if c["id"] == credential_id), None)
if cred and cred.get("api_key"):
return {**model, "api_key": cred["api_key"]}
logger.warning("model %s references missing/keyless credential_id %s", model_id, credential_id)
return dict(model)
if model_type == "claude_cli":
return dict(model)
@@ -606,6 +617,72 @@ def remove_google_account(username: str, account_id: str) -> bool:
return len(data["providers"]["google"]["accounts"]) < before
# ── Write API — Anthropic API keys ───────────────────────────────────────────
def get_anthropic_api_keys(username: str) -> list[dict]:
"""Return Anthropic API key credentials (type='api_key') with key masked for display."""
registry = _load(username)
creds = registry.get("providers", {}).get("anthropic", {}).get("credentials", [])
return [
{
"id": c["id"],
"label": c.get("label", ""),
"hint": (c.get("api_key") or "")[:8] + "" if c.get("api_key") else "no key",
}
for c in creds
if c.get("type") == "api_key"
]
def save_anthropic_api_key(username: str, key_id: str | None,
label: str, api_key: str) -> str:
"""Create or update an Anthropic API key credential. Returns the credential ID."""
data = _load(username)
creds = data["providers"]["anthropic"]["credentials"]
if key_id:
for c in creds:
if c["id"] == key_id and c.get("type") == "api_key":
c["label"] = label.strip() or c.get("label", "API Key")
if api_key.strip():
c["api_key"] = api_key.strip()
_save(username, data)
return key_id
key_id = secrets.token_hex(4)
creds.append({
"id": key_id,
"label": label.strip() or "API Key",
"type": "api_key",
"api_key": api_key.strip(),
})
_save(username, data)
return key_id
def remove_anthropic_api_key(username: str, key_id: str) -> bool:
"""Remove an Anthropic API key credential. Clears model entries that reference it."""
data = _load(username)
creds = data["providers"]["anthropic"]["credentials"]
before = len(creds)
data["providers"]["anthropic"]["credentials"] = [
c for c in creds if c["id"] != key_id
]
removed_model_ids = {
m["id"] for m in data.get("models", [])
if m.get("credential_id") == key_id
}
data["models"] = [m for m in data.get("models", []) if m["id"] not in removed_model_ids]
for role_cfg in data.get("roles", {}).values():
for key in PRIORITY_KEYS:
if role_cfg.get(key) in removed_model_ids:
role_cfg[key] = None
_save(username, data)
return len(data["providers"]["anthropic"]["credentials"]) < before
# ── Write API — Hosts ─────────────────────────────────────────────────────────
def save_host(username: str, host_id: str | None,
@@ -716,11 +793,19 @@ def save_cloud_model(username: str, model_id: str | None,
provider: "anthropic" | "google"
account_id: Google only — references providers.google.accounts[].id
credential_id: Anthropic only — e.g. "cli"
credential_id: Anthropic only — "cli" for OAuth CLI, or a hex ID for an API key credential
"""
_TYPE = {"google": "gemini_api", "anthropic": "claude_cli"}
entry_type = _TYPE.get(provider, "gemini_api")
data = _load(username)
# Determine model type from credential (anthropic only)
if provider == "anthropic":
creds = data.get("providers", {}).get("anthropic", {}).get("credentials", [])
cred = next((c for c in creds if c["id"] == credential_id), None) if credential_id else None
entry_type = "anthropic_api" if (cred and cred.get("type") == "api_key") else "claude_cli"
elif provider == "google":
entry_type = "gemini_api"
else:
entry_type = "claude_cli"
tags = tags or []
entry: dict = {

View File

@@ -31,5 +31,5 @@ pywebpush>=2.0.0
# MariaDB / MySQL connector — used by ae_db_query orchestrator tool
pymysql>=1.1.0
# anthropic SDK not needed — using claude CLI subprocess for auth
# anthropic>=0.40.0
# Anthropic SDK — direct API key backend (alternative to CLI OAuth)
anthropic>=0.40.0

View File

@@ -2,17 +2,19 @@
Model Registry settings — providers, hosts, models, and role assignments.
Routes:
GET /settings/models → settings page (canonical)
GET /settings/local → redirect to /settings/models
POST /settings/local/host → save/create a local host
POST /settings/local/host/{id}/remove → remove a host (and its models)
POST /settings/local/google-account → save/create a Google account
GET /settings/models → settings page (canonical)
GET /settings/local → redirect to /settings/models
POST /settings/local/host → save/create a local host
POST /settings/local/host/{id}/remove → remove a host (and its models)
POST /settings/local/google-account → save/create a Google account
POST /settings/local/google-account/{id}/remove → remove a Google account
POST /settings/local/models/add add a model (any provider)
POST /settings/local/models/{id}/edit → edit an existing model entry
POST /settings/local/models/{id}/remove → remove a model
POST /api/models/role → AJAX: set a role assignment
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
POST /settings/local/anthropic-keysave/create an Anthropic API key
POST /settings/local/anthropic-key/{id}/remove → remove an Anthropic API key
POST /settings/local/models/add → add a model (any provider)
POST /settings/local/models/{id}/edit → edit an existing model entry
POST /settings/local/models/{id}/remove → remove a model
POST /api/models/role → AJAX: set a role assignment
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
"""
import json as _json
import logging
@@ -141,11 +143,32 @@ def _render(username: str, success: str = "", error: str = "") -> str:
for h in hosts
)
# ── Anthropic API key rows ────────────────────────────────────────────────
anthropic_api_keys = reg.get_anthropic_api_keys(username)
anthropic_keys_js = _json.dumps(anthropic_api_keys)
anthropic_key_rows = ""
for c in anthropic_api_keys:
hint = c.get("hint", "no key")
anthropic_key_rows += f'''
<div class="account-row">
<div>
<span class="account-label">{c.get("label") or "API Key"}</span>
<span class="account-hint">{hint}</span>
</div>
<form method="POST" action="/settings/local/anthropic-key/{c["id"]}/remove"
onsubmit="return confirm('Remove this Anthropic API key?')">
<button type="submit" class="btn-link danger">Remove</button>
</form>
</div>'''
if not anthropic_key_rows:
anthropic_key_rows = '<p class="empty-note">No API keys configured. Add one below or use Claude CLI (OAuth).</p>'
# ── Model rows (all providers) ────────────────────────────────────────────
_PROVIDER_BADGE = {
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"anthropic_api": ('<span class="pbadge pb-anthropic">Anthropic</span>', "API Key"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
}
model_rows = ""
for m in models:
@@ -201,6 +224,17 @@ def _render(username: str, success: str = "", error: str = "") -> str:
f'<div class="field"><label>Google Account</label>'
f'<select name="account_id">{acct_opts}</select></div>'
)
elif mtype == "anthropic_api":
key_opts = "".join(
f'<option value="{c["id"]}"'
f'{" selected" if c["id"] == m.get("credential_id") else ""}>'
f'{c.get("label","API Key")} ({c.get("hint","")})</option>'
for c in anthropic_api_keys
)
extra_fields = (
f'<div class="field"><label>API Key</label>'
f'<select name="credential_id">{key_opts or "<option value=\"\">No API keys configured</option>"}</select></div>'
)
else:
extra_fields = '<input type="hidden" name="credential_id" value="cli">'
@@ -379,19 +413,21 @@ def _render(username: str, success: str = "", error: str = "") -> str:
html = (_STATIC / "local_llm.html").read_text()
replacements = {
"{{ username }}": username,
"{{ google_account_rows }}": google_account_rows,
"{{ host_rows }}": host_rows,
"{{ model_rows }}": model_rows,
"{{ host_options }}": host_options,
"{{ role_rows }}": role_rows,
"{{ role_data_js }}": role_data_js,
"{{ role_config_data_js }}": role_config_data_js,
"{{ tool_categories_js }}": tool_categories_js,
"{{ google_accounts_js }}": google_accounts_js,
"{{ google_catalog_js }}": google_catalog_js,
"{{ username }}": username,
"{{ google_account_rows }}": google_account_rows,
"{{ anthropic_key_rows }}": anthropic_key_rows,
"{{ host_rows }}": host_rows,
"{{ model_rows }}": model_rows,
"{{ host_options }}": host_options,
"{{ role_rows }}": role_rows,
"{{ role_data_js }}": role_data_js,
"{{ role_config_data_js }}": role_config_data_js,
"{{ tool_categories_js }}": tool_categories_js,
"{{ google_accounts_js }}": google_accounts_js,
"{{ anthropic_keys_js }}": anthropic_keys_js,
"{{ google_catalog_js }}": google_catalog_js,
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
"{{ has_hosts }}": has_hosts,
"{{ has_hosts }}": has_hosts,
}
for key, val in replacements.items():
html = html.replace(key, val)
@@ -442,6 +478,31 @@ async def remove_google_account(request: Request, account_id: str):
return HTMLResponse(_render(username, success="Google account removed."))
@router.post("/settings/local/anthropic-key", include_in_schema=False)
async def save_anthropic_api_key(
request: Request,
key_id: str = Form(""),
label: str = Form(""),
api_key: str = Form(""),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_key.strip() and not key_id.strip():
return HTMLResponse(_render(username, error="API key is required."))
reg.save_anthropic_api_key(username, key_id or None, label, api_key)
return HTMLResponse(_render(username, success="Anthropic API key saved."))
@router.post("/settings/local/anthropic-key/{key_id}/remove", include_in_schema=False)
async def remove_anthropic_api_key(request: Request, key_id: str):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
reg.remove_anthropic_api_key(username, key_id)
return HTMLResponse(_render(username, success="Anthropic API key removed."))
@router.post("/settings/local/host", include_in_schema=False)
async def save_host(
request: Request,
@@ -562,7 +623,7 @@ async def edit_model(
reg.save_cloud_model(username, model_id, "google", model_name, label,
account_id=account_id or None, context_k=context_k, tags=tag_list,
max_rounds=max_rounds_, tools=tools_bool)
elif mtype == "claude_cli":
elif mtype in ("claude_cli", "anthropic_api"):
reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list,
max_rounds=max_rounds_, tools=tools_bool)

View File

@@ -229,7 +229,9 @@ Configure which AI models are available and which handles each task type.
Do this before adding models — models need a provider account or local host to attach to.
**Anthropic (Claude):** Nothing to configure. Claude uses your existing CLI OAuth session. If Claude isn't working, run `claude auth login` in a terminal.
**Anthropic (Claude):** Two options:
- **CLI (OAuth):** Nothing to configure — uses your existing `claude auth login` session. If Claude isn't working, run `claude auth login` in a terminal.
- **Direct API key:** Scroll to **Cloud Providers → Anthropic** → click **+ Add API key**. Enter a label and your `sk-ant-…` key from [console.anthropic.com/keys](https://console.anthropic.com/keys). When you add a model using an API key credential, it routes through the Anthropic SDK instead of the CLI.
**Google (Gemini):** Add one entry per API key you want to use:
1. Scroll to **Cloud Providers → Google** → click **+ Add Google account**
@@ -258,7 +260,7 @@ Scroll to **Add Model**. Select the provider tab, fill in the details, click **A
|---|---|
| **Local** | Select a host (from Step 1) → enter model name, or use **Fetch from host** to pick from a live list |
| **Google** | Select a Gemini model from the catalog → select a Google account (from Step 1) |
| **Anthropic** | Select a Claude model from the cataloguses your CLI session automatically |
| **Anthropic** | Select a credential (CLI OAuth or an API key added in Step 1) → select a Claude model from the catalog |
The label and context window size auto-fill from the catalog — edit them if you want. Tags are optional.

View File

@@ -319,16 +319,44 @@
<div class="provider-icon pi-anthropic">A</div>
<div>
<div class="provider-title">Anthropic</div>
<div class="provider-subtitle">Claude via CLI (OAuth) — no API key needed</div>
<div class="provider-subtitle">Claude via CLI (OAuth) or direct API key</div>
</div>
</div>
<p class="section-note" style="margin-bottom:0">
Claude models are accessed through the Claude CLI using your existing OAuth login.
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
</p>
<div id="claude-auth-status" class="auth-status" style="margin-top:0.6rem">
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
<div style="margin-bottom:1rem">
<p class="section-note" style="margin-bottom:0.3rem">
<strong>CLI (OAuth):</strong> Uses your existing Claude CLI login — no API key needed.
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
</p>
<div id="claude-auth-status" class="auth-status">
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
</div>
</div>
<p class="section-note" style="margin-bottom:0.4rem"><strong>API Keys:</strong></p>
{{ anthropic_key_rows }}
<details style="margin-top:0.5rem">
<summary>+ Add API key</summary>
<div>
<form method="POST" action="/settings/local/anthropic-key">
<input type="hidden" name="key_id" value="">
<div class="field-row">
<div class="field">
<label>Label <span style="color:#475569;font-weight:400">(e.g. Personal, Work)</span></label>
<input type="text" name="label" placeholder="Personal"
autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label>API Key</label>
<input type="password" name="api_key" placeholder="sk-ant-…"
autocomplete="new-password" data-1p-ignore data-lpignore="true"
data-form-type="other" required>
</div>
</div>
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
</form>
</div>
</details>
</div>
<div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem">
@@ -474,16 +502,22 @@
<!-- ANTHROPIC fields -->
<div id="pf-anthropic" style="display:none">
<div class="field">
<label>Credential</label>
<select id="add-anthropic-cred">
<!-- populated by JS from ANTHROPIC_API_KEYS -->
</select>
</div>
<div class="field">
<label>Claude model</label>
<select id="add-claude-model"></select>
</div>
<p class="section-note" style="margin-top:-0.25rem">Uses Claude CLI (OAuth)</p>
</div>
<!-- Hidden: cloud model name (set by JS from catalog pickers) -->
<input type="hidden" id="cloud-model-name" name="cloud_model_name" value="">
<input type="hidden" name="credential_id" value="cli">
<!-- credential_id is set by JS when Anthropic tab is active -->
<input type="hidden" id="add-credential-id" name="credential_id" value="cli">
<!-- Shared fields -->
<div class="field-row" style="margin-top:0.75rem">
@@ -559,6 +593,7 @@
const ROLE_CONFIG_DATA = {{ role_config_data_js }};
const TOOL_CATEGORIES = {{ tool_categories_js }};
const GOOGLE_ACCOUNTS = {{ google_accounts_js }};
const ANTHROPIC_API_KEYS = {{ anthropic_keys_js }};
const GOOGLE_CATALOG = {{ google_catalog_js }};
const ANTHROPIC_CATALOG = {{ anthropic_catalog_js }};
const HAS_HOSTS = {{ has_hosts }};
@@ -742,6 +777,10 @@
el.style.display = key === p ? '' : 'none';
}
fetchBtn.style.display = p === 'local' ? '' : 'none';
// Sync credential_id when switching to/from Anthropic
if (p === 'anthropic') {
credIdInput.value = anthropicCredSel.value || 'cli';
}
});
});
@@ -758,13 +797,27 @@
});
}
const geminiSel = document.getElementById('add-gemini-model');
const claudeSel = document.getElementById('add-claude-model');
const gAcctSel = document.getElementById('add-google-account');
const geminiSel = document.getElementById('add-gemini-model');
const claudeSel = document.getElementById('add-claude-model');
const gAcctSel = document.getElementById('add-google-account');
const anthropicCredSel = document.getElementById('add-anthropic-cred');
const credIdInput = document.getElementById('add-credential-id');
populateSelect(geminiSel, GOOGLE_CATALOG, 'id', 'label');
populateSelect(claudeSel, ANTHROPIC_CATALOG, 'id', 'label');
// Populate Anthropic credential selector (CLI + any configured API keys)
anthropicCredSel.innerHTML = '<option value="cli">Claude CLI (OAuth)</option>';
ANTHROPIC_API_KEYS.forEach(k => {
const opt = document.createElement('option');
opt.value = k.id;
opt.textContent = (k.label || 'API Key') + (k.hint ? ` (${k.hint})` : '');
anthropicCredSel.appendChild(opt);
});
anthropicCredSel.addEventListener('change', () => {
credIdInput.value = anthropicCredSel.value || 'cli';
});
if (GOOGLE_ACCOUNTS.length) {
gAcctSel.innerHTML = '<option value="">— select account —</option>';
GOOGLE_ACCOUNTS.forEach(a => {

View File

@@ -43,7 +43,7 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
| Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option |
| Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link |
**65 orchestrator tools** across 17 domain modules — added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM).
**65 orchestrator tools** across 17 domain modules — added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM); Anthropic API key backend (`anthropic_api` model type via Anthropic SDK — alternative to CLI OAuth).
**Active users / personas:** scott/inara, holly/tina, brian/wintermute

View File

@@ -151,16 +151,12 @@ ability to act on HA via the REST API.
- [x] Inline "Delete this session? [Delete] [Cancel]" reveal on `×` click in `app.js` — 2026-05-12
- [x] Message-level delete: "confirm delete / cancel" inline in the actions bar — 2026-05-12
### [UI] File attachments in chat
Upload an image or document inline and have it flow into context. Natural workflow
("here's this PDF, summarize it"); local backend already supports multimodal via Open WebUI.
- [ ] Add attachment button to input area (paperclip icon, hidden file input)
- [ ] Client: encode file as base64 or multipart; send alongside message text
- [ ] Server: accept file in `POST /chat`; route to appropriate backend
- Claude: `content` array with `image` blocks (base64 or URL)
- Gemini: `parts` array with `inline_data`
- Local (Open WebUI): `content` array with image_url items
- [ ] UI: show thumbnail/filename above the sent message
### [UI] File attachments in chat ✅ — 2026-05-12
Upload an image or document inline and have it flow into context.
- [x] Attachment button (paperclip) in input area; hidden file input
- [x] Images sent as base64 inline_data (Gemini API) or image blocks (Claude/local)
- [x] Text/code files read as UTF-8, injected as fenced code block in message
- [x] Thumbnail/filename shown above sent message in UI
### [Auth] Encrypted sessions
Allow users to opt-in to per-session encryption so session logs on disk cannot be