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 main.py ← App entry point, router registration
config.py ← All settings (pydantic-settings, reads .env) config.py ← All settings (pydantic-settings, reads .env)
persona.py ← Two-level identity: user + persona, path resolution, ContextVars 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 orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff
context_loader.py ← Builds system prompt from persona files (tier 14) context_loader.py ← Builds system prompt from persona files (tier 14)
session_store.py ← In-memory + file session persistence 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 - **Orchestrated tasks** go to `POST /orchestrate` — returns a job_id, result is polled
### LLM Backends ### 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 - `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) - 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 ### Tool Strategy
- Orchestrator tools live in `cortex/tools/` — separate from the `ae_*` MCP tools - 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 # Map from registry model type → dispatch function key
_TYPE_TO_BACKEND = { _TYPE_TO_BACKEND = {
"claude_cli": "claude", "claude_cli": "claude",
"gemini_cli": "gemini", "gemini_cli": "gemini",
"gemini_api": "gemini", # gemini_api falls back to CLI in this context "gemini_api": "gemini", # gemini_api falls back to CLI in this context
"local_openai": "local", "local_openai": "local",
"anthropic_api": "anthropic_api",
} }
# Explicit UI toggle values (kept for backward compat) # Explicit UI toggle values (kept for backward compat)
_EXPLICIT_BACKENDS = ("claude", "gemini", "local") _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( async def complete(
@@ -123,6 +124,8 @@ async def _dispatch(
return await _gemini(system_prompt, messages) return await _gemini(system_prompt, messages)
if backend == "local": if backend == "local":
return await _local(system_prompt, messages, model_cfg, attachment=attachment) 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) return await _claude(system_prompt, messages, model_cfg)
@@ -254,6 +257,51 @@ async def _local(
return text.strip() 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: async def _gemini(system_prompt: str, messages: list[dict]) -> str:
# Gemini CLI spawns MCP child processes that keep stdout pipes open after responding. # 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 # 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_cli" — Gemini CLI subprocess
"gemini_api" — Gemini API (google-genai SDK); account_id → api_key from providers.google "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[] "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): Built-in model IDs (always resolvable without a registry entry):
"claude_cli" — resolves to the default Claude CLI model "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) logger.warning("model %s references missing account_id %s", model_id, account_id)
return dict(model) 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": if model_type == "claude_cli":
return dict(model) return dict(model)
@@ -606,6 +617,72 @@ def remove_google_account(username: str, account_id: str) -> bool:
return len(data["providers"]["google"]["accounts"]) < before 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 ───────────────────────────────────────────────────────── # ── Write API — Hosts ─────────────────────────────────────────────────────────
def save_host(username: str, host_id: str | None, 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" provider: "anthropic" | "google"
account_id: Google only — references providers.google.accounts[].id 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) 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 [] tags = tags or []
entry: dict = { entry: dict = {

View File

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

View File

@@ -2,17 +2,19 @@
Model Registry settings — providers, hosts, models, and role assignments. Model Registry settings — providers, hosts, models, and role assignments.
Routes: Routes:
GET /settings/models → settings page (canonical) GET /settings/models → settings page (canonical)
GET /settings/local → redirect to /settings/models GET /settings/local → redirect to /settings/models
POST /settings/local/host → save/create a local host POST /settings/local/host → save/create a local host
POST /settings/local/host/{id}/remove → remove a host (and its models) 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 → save/create a Google account
POST /settings/local/google-account/{id}/remove → remove 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/anthropic-keysave/create an Anthropic API key
POST /settings/local/models/{id}/edit → edit an existing model entry POST /settings/local/anthropic-key/{id}/remove → remove an Anthropic API key
POST /settings/local/models/{id}/remove → remove a model POST /settings/local/models/add → add a model (any provider)
POST /api/models/role → AJAX: set a role assignment POST /settings/local/models/{id}/edit → edit an existing model entry
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON) 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 json as _json
import logging import logging
@@ -141,11 +143,32 @@ def _render(username: str, success: str = "", error: str = "") -> str:
for h in hosts 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) ──────────────────────────────────────────── # ── Model rows (all providers) ────────────────────────────────────────────
_PROVIDER_BADGE = { _PROVIDER_BADGE = {
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"), "claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""), "anthropic_api": ('<span class="pbadge pb-anthropic">Anthropic</span>', "API Key"),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""), "gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
} }
model_rows = "" model_rows = ""
for m in models: 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'<div class="field"><label>Google Account</label>'
f'<select name="account_id">{acct_opts}</select></div>' 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: else:
extra_fields = '<input type="hidden" name="credential_id" value="cli">' 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() html = (_STATIC / "local_llm.html").read_text()
replacements = { replacements = {
"{{ username }}": username, "{{ username }}": username,
"{{ google_account_rows }}": google_account_rows, "{{ google_account_rows }}": google_account_rows,
"{{ host_rows }}": host_rows, "{{ anthropic_key_rows }}": anthropic_key_rows,
"{{ model_rows }}": model_rows, "{{ host_rows }}": host_rows,
"{{ host_options }}": host_options, "{{ model_rows }}": model_rows,
"{{ role_rows }}": role_rows, "{{ host_options }}": host_options,
"{{ role_data_js }}": role_data_js, "{{ role_rows }}": role_rows,
"{{ role_config_data_js }}": role_config_data_js, "{{ role_data_js }}": role_data_js,
"{{ tool_categories_js }}": tool_categories_js, "{{ role_config_data_js }}": role_config_data_js,
"{{ google_accounts_js }}": google_accounts_js, "{{ tool_categories_js }}": tool_categories_js,
"{{ google_catalog_js }}": google_catalog_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, "{{ anthropic_catalog_js }}": anthropic_catalog_js,
"{{ has_hosts }}": has_hosts, "{{ has_hosts }}": has_hosts,
} }
for key, val in replacements.items(): for key, val in replacements.items():
html = html.replace(key, val) 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.")) 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) @router.post("/settings/local/host", include_in_schema=False)
async def save_host( async def save_host(
request: Request, request: Request,
@@ -562,7 +623,7 @@ async def edit_model(
reg.save_cloud_model(username, model_id, "google", model_name, label, reg.save_cloud_model(username, model_id, "google", model_name, label,
account_id=account_id or None, context_k=context_k, tags=tag_list, account_id=account_id or None, context_k=context_k, tags=tag_list,
max_rounds=max_rounds_, tools=tools_bool) 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, reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list, credential_id=credential_id or "cli", context_k=context_k, tags=tag_list,
max_rounds=max_rounds_, tools=tools_bool) 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. 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: **Google (Gemini):** Add one entry per API key you want to use:
1. Scroll to **Cloud Providers → Google** → click **+ Add Google account** 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 | | **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) | | **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. 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 class="provider-icon pi-anthropic">A</div>
<div> <div>
<div class="provider-title">Anthropic</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>
</div> </div>
<p class="section-note" style="margin-bottom:0">
Claude models are accessed through the Claude CLI using your existing OAuth login. <div style="margin-bottom:1rem">
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate. <p class="section-note" style="margin-bottom:0.3rem">
</p> <strong>CLI (OAuth):</strong> Uses your existing Claude CLI login — no API key needed.
<div id="claude-auth-status" class="auth-status" style="margin-top:0.6rem"> Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
<span class="dot"></span><span id="claude-auth-msg">Checking…</span> </p>
<div id="claude-auth-status" class="auth-status">
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
</div>
</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>
<div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem"> <div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem">
@@ -474,16 +502,22 @@
<!-- ANTHROPIC fields --> <!-- ANTHROPIC fields -->
<div id="pf-anthropic" style="display:none"> <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"> <div class="field">
<label>Claude model</label> <label>Claude model</label>
<select id="add-claude-model"></select> <select id="add-claude-model"></select>
</div> </div>
<p class="section-note" style="margin-top:-0.25rem">Uses Claude CLI (OAuth)</p>
</div> </div>
<!-- Hidden: cloud model name (set by JS from catalog pickers) --> <!-- 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" 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 --> <!-- Shared fields -->
<div class="field-row" style="margin-top:0.75rem"> <div class="field-row" style="margin-top:0.75rem">
@@ -559,6 +593,7 @@
const ROLE_CONFIG_DATA = {{ role_config_data_js }}; const ROLE_CONFIG_DATA = {{ role_config_data_js }};
const TOOL_CATEGORIES = {{ tool_categories_js }}; const TOOL_CATEGORIES = {{ tool_categories_js }};
const GOOGLE_ACCOUNTS = {{ google_accounts_js }}; const GOOGLE_ACCOUNTS = {{ google_accounts_js }};
const ANTHROPIC_API_KEYS = {{ anthropic_keys_js }};
const GOOGLE_CATALOG = {{ google_catalog_js }}; const GOOGLE_CATALOG = {{ google_catalog_js }};
const ANTHROPIC_CATALOG = {{ anthropic_catalog_js }}; const ANTHROPIC_CATALOG = {{ anthropic_catalog_js }};
const HAS_HOSTS = {{ has_hosts }}; const HAS_HOSTS = {{ has_hosts }};
@@ -742,6 +777,10 @@
el.style.display = key === p ? '' : 'none'; el.style.display = key === p ? '' : 'none';
} }
fetchBtn.style.display = p === 'local' ? '' : '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 geminiSel = document.getElementById('add-gemini-model');
const claudeSel = document.getElementById('add-claude-model'); const claudeSel = document.getElementById('add-claude-model');
const gAcctSel = document.getElementById('add-google-account'); 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(geminiSel, GOOGLE_CATALOG, 'id', 'label');
populateSelect(claudeSel, ANTHROPIC_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) { if (GOOGLE_ACCOUNTS.length) {
gAcctSel.innerHTML = '<option value="">— select account —</option>'; gAcctSel.innerHTML = '<option value="">— select account —</option>';
GOOGLE_ACCOUNTS.forEach(a => { 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 | | 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 | | 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 **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] 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 - [x] Message-level delete: "confirm delete / cancel" inline in the actions bar — 2026-05-12
### [UI] File attachments in chat ### [UI] File attachments in chat ✅ — 2026-05-12
Upload an image or document inline and have it flow into context. Natural workflow Upload an image or document inline and have it flow into context.
("here's this PDF, summarize it"); local backend already supports multimodal via Open WebUI. - [x] Attachment button (paperclip) in input area; hidden file input
- [ ] Add attachment button to input area (paperclip icon, hidden file input) - [x] Images sent as base64 inline_data (Gemini API) or image blocks (Claude/local)
- [ ] Client: encode file as base64 or multipart; send alongside message text - [x] Text/code files read as UTF-8, injected as fenced code block in message
- [ ] Server: accept file in `POST /chat`; route to appropriate backend - [x] Thumbnail/filename shown above sent message in UI
- 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
### [Auth] Encrypted sessions ### [Auth] Encrypted sessions
Allow users to opt-in to per-session encryption so session logs on disk cannot be Allow users to opt-in to per-session encryption so session logs on disk cannot be