diff --git a/CLAUDE.md b/CLAUDE.md index a650857..66ad9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 1–4) 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 diff --git a/cortex/llm_client.py b/cortex/llm_client.py index a3ae37e..24a316e 100644 --- a/cortex/llm_client.py +++ b/cortex/llm_client.py @@ -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 diff --git a/cortex/model_registry.py b/cortex/model_registry.py index 82c71f1..557bd8f 100644 --- a/cortex/model_registry.py +++ b/cortex/model_registry.py @@ -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 = { diff --git a/cortex/requirements.txt b/cortex/requirements.txt index 0fa347e..beca167 100644 --- a/cortex/requirements.txt +++ b/cortex/requirements.txt @@ -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 diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index bf25807..36d0962 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -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-key → save/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''' +
+
+ + +
+
+ +
+
''' + if not anthropic_key_rows: + anthropic_key_rows = '

No API keys configured. Add one below or use Claude CLI (OAuth).

' + # ── Model rows (all providers) ──────────────────────────────────────────── _PROVIDER_BADGE = { - "claude_cli": ('Anthropic', "Claude CLI"), - "gemini_api": ('Google', ""), - "local_openai": ('Local', ""), + "claude_cli": ('Anthropic', "Claude CLI"), + "anthropic_api": ('Anthropic', "API Key"), + "gemini_api": ('Google', ""), + "local_openai": ('Local', ""), } model_rows = "" for m in models: @@ -201,6 +224,17 @@ def _render(username: str, success: str = "", error: str = "") -> str: f'
' f'
' ) + elif mtype == "anthropic_api": + key_opts = "".join( + f'' + for c in anthropic_api_keys + ) + extra_fields = ( + f'
' + f'
' + ) else: extra_fields = '' @@ -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) diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md index 3cf537f..c44cbe9 100644 --- a/cortex/static/HELP.md +++ b/cortex/static/HELP.md @@ -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 catalog → uses 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. diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index bc64fd6..5b74d30 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -319,16 +319,44 @@
A
Anthropic
-
Claude via CLI (OAuth) — no API key needed
+
Claude via CLI (OAuth) or direct API key
-

- Claude models are accessed through the Claude CLI using your existing OAuth login. - Run claude auth login to authenticate. -

-
- Checking… + +
+

+ CLI (OAuth): Uses your existing Claude CLI login — no API key needed. + Run claude auth login to authenticate. +

+
+ Checking… +
+ +

API Keys:

+ {{ anthropic_key_rows }} +
+ + Add API key +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
@@ -474,16 +502,22 @@ - + +
@@ -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 = ''; + 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 = ''; GOOGLE_ACCOUNTS.forEach(a => { diff --git a/documentation/MASTER.md b/documentation/MASTER.md index 74be001..848bbd4 100644 --- a/documentation/MASTER.md +++ b/documentation/MASTER.md @@ -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 diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 3fc8e9e..0502797 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -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