BerriAI/litellm · error · ValueError
user_custom_key_update must be a coroutine
Error message
user_custom_key_update must be a coroutine
What it means
Key updates support a separate custom hook (custom_key_update, wired as user_custom_key_update) that runs after permission checks and before the update is applied. LiteLLM requires it to be a coroutine function; a sync def triggers this ValueError when the first /key/update call reaches the hook, surfacing as a 500. Note it is distinct from custom_key_generate -- one can be async while the other is sync by mistake.
Source
Thrown at litellm/proxy/management_endpoints/key_management_endpoints.py:2288
prisma_client=prisma_client,
)
# Check team member permissions
if prisma_client is not None:
await TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint(
user_api_key_dict=user_api_key_dict,
route=KeyManagementRoutes.KEY_UPDATE,
prisma_client=prisma_client,
existing_key_row=existing_key_row,
user_api_key_cache=user_api_key_cache,
)
# Custom key update hook
if user_custom_key_update is not None:
if inspect.iscoroutinefunction(user_custom_key_update):
result: Final = await user_custom_key_update(update_key_request)
else:
raise ValueError("user_custom_key_update must be a coroutine")
decision: Final = result.get("decision", True)
message: Final = result.get("message", "Authentication Failed - Custom Auth Rule")
if not decision:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=message)
# Enforce upperbound key params on update (don't fill defaults)
_enforce_upperbound_key_params(update_key_request, fill_defaults=False)
# Get team object and check team limits if team_id is provided
team_obj: LiteLLM_TeamTableCachedObj | None = None
if update_key_request.team_id is not None:
team_obj = await get_team_object(
team_id=update_key_request.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
View on GitHub (pinned to 77b7c6c40c)
Solutions
- Change it to async def custom_key_update(update_key_request) -> dict
- Keep any decorators async through the chain so iscoroutinefunction() sees True
- Restart the proxy to reload the module
- Add a startup assertion in your auth module: assert inspect.iscoroutinefunction(custom_key_update)
Example fix
# custom_auth.py (before)
def custom_key_update(data):
return {"decision": True}
# after
async def custom_key_update(data):
return {"decision": True} Defensive patterns
Strategy: type-guard
Validate before calling
import inspect, custom_auth
fn = getattr(custom_auth, "custom_key_update", None)
if fn is not None:
assert inspect.iscoroutinefunction(fn), "custom_key_update must be async def" Type guard
import inspect
def is_async_hook(fn) -> bool:
return callable(fn) and inspect.iscoroutinefunction(fn) Try / catch
try:
r = await client.post("/key/update", json=payload)
except httpx.HTTPStatusError as e:
if e.response.status_code == 500 and "user_custom_key_update must be a coroutine" in e.response.text:
raise RuntimeError("convert custom_key_update to async def and restart proxy") from e
raise Prevention
- Define every hook async: custom_auth, custom_key_generate, custom_key_update
- Assert iscoroutinefunction for all hooks at module import / proxy startup
- Smoke-test /key/update (not just /key/generate) after deploying custom auth changes
When it happens
Trigger: custom_auth module defines def custom_key_update(request): (no async); the hook is a staticmethod/reference whose coroutine nature was lost through wrapping; you added the update hook recently and only tested /key/generate, so the bug first appears on update calls.
Common situations: Asymmetric refactor: custom_key_generate migrated to async, custom_key_update forgotten; example code copied from an outdated blog answer showing sync hooks; decorator-based hooks where the outer wrapper isn't async.
Related errors
- user_custom_key_generate must be a coroutine
- Invalid mode: {custom_auth_settings['mode']}
- Custom prompt management does not support async compile prom
- No response from fallbacks. Got none. Turn on `litellm.set_v
- max_budget must be a non-negative finite number. Received: {
AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18).
Data as JSON: /api/errors/5d614f2fcc94166b.
Report an issue: GitHub.