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

@@ -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