odysseus-dev/odysseus · error · HTTPException

API token requires chat scope

Error message

API token requires chat scope

What it means

HTTP 403 raised by require_models_scope for companion model-inventory routes. When the request authenticates with a bearer API token (request.state.api_token truthy), the token's scopes are checked and the 'chat' scope (companion.pairing.COMPANION_SCOPE) must be present. Session-authenticated requests skip the check entirely.

Source

Thrown at companion/routes.py:65

    A caller sees a row when it is their own, or when it is a legacy null-owner
    ("shared") row. A caller must NEVER see another owner's row. Mirrors the
    `owner_filter` rule used elsewhere, expressed as a pure predicate so it can
    be tested directly and used as a defensive in-Python check alongside the
    SQL filter.
    """
    return row_owner is None or row_owner == owner


def require_models_scope(request: Request) -> None:
    """Require the companion chat scope for bearer-token model inventory."""
    if not getattr(request.state, "api_token", False):
        return
    scopes = getattr(request.state, "api_token_scopes", None) or []
    if isinstance(scopes, str):
        scopes = [scope.strip() for scope in scopes.split(",")]
    scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()}
    if _pairing.COMPANION_SCOPE not in scope_set:
        raise HTTPException(403, "API token requires chat scope")


def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
    """Mint a pairing token AND invalidate the auth middleware's in-memory token
    cache, so the new token is accepted on the very next request without a server
    restart. Returns (token_id, raw_token); the raw token is shown once.

    `invalidate` is the app's request.app.state.invalidate_token_cache callable
    (passed in so this stays a pure, testable unit).
    """
    token_id, raw_token = _pairing.mint_token(owner)
    if callable(invalidate):
        invalidate()
    return token_id, raw_token


def setup_companion_routes() -> APIRouter:
    router = APIRouter(prefix="/api/companion", tags=["companion"])

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Re-mint the pairing token including the chat scope (mint_pairing_token mints the companion scope)
  2. Or call the endpoint with the browser session (cookie auth) instead of the bearer token
  3. Verify the stored scopes string for the token contains 'chat' (comma/space separated entries are parsed)

Example fix

# before
curl -H 'Authorization: Bearer <token-without-chat-scope>' http://host/companion/models
# after — mint a token with the chat scope, then use it
token_id, raw = mint_pairing_token(owner, invalidate=app.state.invalidate_token_cache)
curl -H f'Authorization: Bearer {raw}' http://host/companion/models
Defensive patterns

Strategy: validation

Validate before calling

scopes = (token_scopes or '').split(',') if isinstance(token_scopes, str) else (token_scopes or [])
if 'chat' not in {s.strip() for s in scopes if s.strip()}:
    raise PermissionError('Re-mint token with chat scope')

Try / catch

try:
    require_models_scope(request)
except HTTPException as e:
    if e.status_code == 403:
        return JSONResponse({'error': 'token lacks chat scope'}, status_code=403)
    raise

Prevention

When it happens

Trigger: Calling a companion route guarded by require_models_scope with a pairing/API token that was minted without the 'chat' scope, or whose scope string is malformed so 'chat' is not in the parsed set.

Common situations: Using an old token minted before scopes were introduced; minting a token with only other scopes; passing a custom Authorization header where a session cookie would have sufficed.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/a3cb0afbeefbccbb. Report an issue: GitHub.