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''' +
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'
- Claude models are accessed through the Claude CLI using your existing OAuth login.
- Run claude auth login to authenticate.
-
+ CLI (OAuth): Uses your existing Claude CLI login — no API key needed.
+ Run claude auth login to authenticate.
+
API Keys:
+ {{ anthropic_key_rows }} +