odysseus-dev/odysseus · error · HTTPException

Body must be a JSON object

Error message

Body must be a JSON object

What it means

400 raised at routes/model_routes.py:2379 in the bulk hidden/pinned update handler when `await request.json()` succeeds but the parsed top-level value is not a JSON object (dict). The handler deliberately reads the raw body rather than a typed Pydantic model so it can patch 'hidden' and 'pinned_models' independently, which means shape validation is manual: any JSON array, string, number, null, or bare true/false as the body triggers this.

Source

Thrown at routes/model_routes.py:2379

    @router.patch("/model-endpoints/{ep_id}/models")
    async def update_hidden_models(ep_id: str, request: Request):
        """Bulk update hidden and/or pinned model lists for an endpoint.

        Expects JSON body with optional keys:
          {"hidden": ["model-id-1", ...], "pinned_models": ["deploy-id", ...]}
        Each key is updated only when present, so callers can patch one list
        without clobbering the other.
        """
        require_admin(request)
        db = SessionLocal()
        try:
            ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
            if not ep:
                raise HTTPException(404, "Endpoint not found")
            body = await request.json()
            if not isinstance(body, dict):
                raise HTTPException(400, "Body must be a JSON object")
            if "hidden" in body:
                hidden = body.get("hidden")
                if not isinstance(hidden, list):
                    raise HTTPException(400, "hidden must be a list of model IDs")
                base = _normalize_base(ep.base_url)
                kind = _effective_endpoint_kind(ep, base)
                if _picker_requires_pinning(base, kind):
                    # Compatibility for older/admin UI paths that still submit
                    # the previous hide-list shape. API pickers are allow-lists:
                    # convert "unchecked models" into an explicit pinned list so
                    # Settings summary, /api/models, and chat agree.
                    selected = _visible_models(_cached_model_ids(ep), hidden, None)
                    ep.pinned_models = json.dumps(selected)
                    ep.hidden_models = None
                else:
                    ep.hidden_models = json.dumps(hidden) if hidden else None
            # Accept either "pinned" or "pinned_models" for the manual IDs list.
            if "pinned_models" in body or "pinned" in body:

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Send an object with the documented keys: {"hidden": [...]} and/or {"pinned_models": [...]}; omit a key to leave that list untouched.
  2. If you only have the array, wrap it client-side before sending.
  3. Check for double-encoding: JSON.stringify once, not twice.
  4. Verify Content-Type: application/json so the body parses as JSON at all.

Example fix

# before (wrong shape)
await client.patch(f"/model-endpoints/{ep}/models-visibility", json=["gpt-4o"])

# after
await client.patch(f"/model-endpoints/{ep}/models-visibility", json={"hidden": ["gpt-4o"]})
Defensive patterns

Strategy: type-guard

Validate before calling

if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
  throw new TypeError('visibility payload must be a JSON object');
}

Type guard

function isVisibilityBody(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

Try / catch

try { await api.patchVisibility(epId, body); }
catch (e) { if (e.status === 400) console.error('wrap arrays: {"hidden": [...]}, not bare arrays'); throw e; }

Prevention

When it happens

Trigger: POSTing `["model-a","model-b"]` directly instead of `{"hidden": [...]}`. Sending a bare JSON string/number, or 'null'. A client double-encoding: body is a JSON-encoded string containing JSON. Note malformed JSON raises earlier (json.JSONDecodeError -> 500/422 path), so this 400 specifically means valid-JSON-wrong-shape.

Common situations: Frontend refactors that send the array directly because 'hidden' is the only field. curl tests with `--data '[...]'` instead of `--data '{"hidden":[...]}'`. A proxy or serializer wrapping/rewriting the payload.

Related errors


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