BerriAI/litellm · error · ValueError

user_custom_key_generate must be a coroutine

Error message

user_custom_key_generate must be a coroutine

What it means

LiteLLM Proxy supports an optional custom auth hook (custom_key_generate) loaded from your custom_auth module; before every key generation it is invoked to allow/reject the request. The proxy checks the hook with inspect.iscoroutinefunction() and raises this ValueError if the function was defined without async def, because it is always awaited. This is a server-side configuration bug, not a client input problem, and surfaces as a 500 on the first /key/generate call after startup.

Source

Thrown at litellm/proxy/management_endpoints/key_management_endpoints.py:1707

        if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0):
            raise HTTPException(
                status_code=400,
                detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"},
            )
        if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0):
            raise HTTPException(
                status_code=400,
                detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
            )

        custom_key_generate_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = (
            user_custom_key_generate
        )
        if custom_key_generate_hook is not None:
            if inspect.iscoroutinefunction(custom_key_generate_hook):
                result: Final = await custom_key_generate_hook(data)
            else:
                raise ValueError("user_custom_key_generate 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)

        _check_allowed_routes_caller_permission(
            allowed_routes=data.allowed_routes,
            user_api_key_dict=user_api_key_dict,
            allowed_routes_was_provided="allowed_routes" in data.model_fields_set,
        )
        _check_passthrough_routes_caller_permission(
            data=data,
            user_api_key_dict=user_api_key_dict,
        )

        # For non-admin internal users: auto-assign caller's user_id if not provided
        # This prevents creating unbound keys with no user association (LIT-1884)
        _is_proxy_admin: Final = (

View on GitHub (pinned to 77b7c6c40c)

Solutions

  1. Change the hook to an async function: async def custom_key_generate(data: GenerateRequest) -> dict in your custom auth module
  2. Ensure any decorator wrapping it is itself async def and returns the coroutine intact
  3. Restart the LiteLLM proxy after fixing the module so the hook is re-imported
  4. Verify with a quick import test: python -c "import inspect, my_module; print(inspect.iscoroutinefunction(my_module.custom_key_generate))" should print True

Example fix

# custom_auth.py (before)
def custom_key_generate(data):
    return {"decision": True}

# after
async def custom_key_generate(data):
    # async logic, e.g. await some_check(data)
    return {"decision": True}
Defensive patterns

Strategy: type-guard

Validate before calling

# in custom_auth.py, fail fast at import time
import inspect

async def custom_key_generate(data):
    return {"decision": True}

assert inspect.iscoroutinefunction(custom_key_generate), "custom_key_generate must be async"

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/generate", json=payload)
except httpx.HTTPStatusError as e:
    if e.response.status_code == 500 and "must be a coroutine" in e.response.text:
        raise RuntimeError("proxy custom_auth hook is sync -- fix module and restart proxy") from e
    raise

Prevention

When it happens

Trigger: general_settings.custom_auth points at a module defining def custom_key_generate(data): (sync); the hook is a lambda or partial that hides its coroutine nature; the hook is defined async but wrapped in a sync decorator that returns a plain function; server started, first POST /key/generate triggers the check.

Common situations: Copy-pasting an old sync custom auth example from docs/LLM output; refactoring the auth module and dropping the async keyword; using functools.wraps around a wrapper that is not itself async; defining custom_key_update correctly as async but forgetting custom_key_generate.

Related errors


AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18). Data as JSON: /api/errors/53e7b7fa06a8f3d4. Report an issue: GitHub.