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

@@ -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-keysave/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'''
<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) ────────────────────────────────────────────
_PROVIDER_BADGE = {
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
"anthropic_api": ('<span class="pbadge pb-anthropic">Anthropic</span>', "API Key"),
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
}
model_rows = ""
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'<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:
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()
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)