Compare commits

..
2 Commits
Author SHA1 Message Date
inference-bot 97ebe69553 portal: /admin/librechat/keys — dedicated-token feed of member chat keys for the sandbox's key auto-load 2026-10-18 23:15:38 -06:00
inference-bot 7f04920327 broker: retire the old LiteLLM key before re-creating a same-name key
LiteLLM requires unique key aliases, so re-creating a key with an existing
(email, name) pair 400s ('alias already exists') — breaking key rotation
from the dashboard. The stored LiteLLM hash lets us retire the old key
first; the ON CONFLICT upsert then tracks the new key.

Surfaced by the LibreChat sandbox work: the portal chat-key rotation for
an SSO member failed with exactly this 400.
2026-10-17 18:00:49 -06:00
+66
View File
@@ -1658,6 +1658,32 @@ async def broker_create_api_key(email: str, name: str) -> dict:
team_id = await litellm_get_or_create_team(email, balance) team_id = await litellm_get_or_create_team(email, balance)
alias = _api_key_alias(email, name) alias = _api_key_alias(email, name)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Rotation support: LiteLLM requires unique key aliases, so a re-create
# with an existing (email, name) must retire the old key first. Without
# this, rotating a key from the dashboard 400s ("alias already exists").
conn = get_db()
row = conn.execute(
"SELECT hash FROM member_api_keys WHERE email = ? AND name = ?",
(email, name),
).fetchone()
conn.close()
if row and row[0]:
old_hash = row[0]
try:
del_res = await client.post(
f"{LITELLM_BASE}/key/delete",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
json={"keys": [old_hash]},
)
if del_res.status_code == 200:
logger.info("Rotated API key %s for %s (old key retired)", name, email)
else:
logger.warning(
"Old key retire failed for %s/%s: %s %s",
email, name, del_res.status_code, del_res.text[:200],
)
except Exception as exc: # noqa: BLE001 - rotation is best-effort
logger.warning("Old key retire errored for %s/%s: %s", email, name, exc)
r = await client.post( r = await client.post(
f"{LITELLM_BASE}/key/generate", f"{LITELLM_BASE}/key/generate",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
@@ -3021,6 +3047,46 @@ async def admin_sweep(request: Request):
return result return result
def _verify_sync_token(request: Request) -> None:
"""Authenticate the LibreChat key-sync feed via X-Sync-Token.
Dedicated machine secret (least privilege): it only ever exposes the
members' own chat keys, never the admin API. The same secret is set on
the LibreChat sandbox app (LIBRECHAT_SYNC_TOKEN in /app/data/env).
"""
expected = os.environ.get("LIBRECHAT_SYNC_TOKEN", "")
if not expected:
raise HTTPException(503, "Sync token not configured")
provided = request.headers.get("x-sync-token", "")
if not secrets.compare_digest(provided, expected):
raise HTTPException(401, "Invalid sync token")
@app.get("/admin/librechat/keys")
@limiter.limit("30/minute")
async def admin_librechat_keys(request: Request):
"""Key feed for the LibreChat sandbox's per-member key auto-load.
Returns every ACTIVE member's portal-provisioned chat key so the sandbox
can seed LibreChat's user-key store and members never see an API-key
prompt (chat bills to the member's own LiteLLM team/budget). Auth is the
dedicated X-Sync-Token header — NOT the admin token.
"""
_verify_sync_token(request)
conn = get_db()
rows = conn.execute(
"SELECT email, key_token FROM members WHERE active = 1 AND key_token != ''"
).fetchall()
conn.close()
return {
"members": [
{"email": email, "key": key_token}
for email, key_token in rows
]
}
@app.get("/") @app.get("/")
async def index(): async def index():
return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"} return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}