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

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