{"record":{"id":"a6e950b903dbeb95","repo":"odysseus-dev/odysseus","slug":"body-must-be-a-json-object","errorCode":null,"errorMessage":"Body must be a JSON object","messagePattern":"Body must be a JSON object","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"error","filePath":"routes/model_routes.py","lineNumber":2379,"sourceCode":"\n    @router.patch(\"/model-endpoints/{ep_id}/models\")\n    async def update_hidden_models(ep_id: str, request: Request):\n        \"\"\"Bulk update hidden and/or pinned model lists for an endpoint.\n\n        Expects JSON body with optional keys:\n          {\"hidden\": [\"model-id-1\", ...], \"pinned_models\": [\"deploy-id\", ...]}\n        Each key is updated only when present, so callers can patch one list\n        without clobbering the other.\n        \"\"\"\n        require_admin(request)\n        db = SessionLocal()\n        try:\n            ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()\n            if not ep:\n                raise HTTPException(404, \"Endpoint not found\")\n            body = await request.json()\n            if not isinstance(body, dict):\n                raise HTTPException(400, \"Body must be a JSON object\")\n            if \"hidden\" in body:\n                hidden = body.get(\"hidden\")\n                if not isinstance(hidden, list):\n                    raise HTTPException(400, \"hidden must be a list of model IDs\")\n                base = _normalize_base(ep.base_url)\n                kind = _effective_endpoint_kind(ep, base)\n                if _picker_requires_pinning(base, kind):\n                    # Compatibility for older/admin UI paths that still submit\n                    # the previous hide-list shape. API pickers are allow-lists:\n                    # convert \"unchecked models\" into an explicit pinned list so\n                    # Settings summary, /api/models, and chat agree.\n                    selected = _visible_models(_cached_model_ids(ep), hidden, None)\n                    ep.pinned_models = json.dumps(selected)\n                    ep.hidden_models = None\n                else:\n                    ep.hidden_models = json.dumps(hidden) if hidden else None\n            # Accept either \"pinned\" or \"pinned_models\" for the manual IDs list.\n            if \"pinned_models\" in body or \"pinned\" in body:","sourceCodeStart":2361,"sourceCodeEnd":2397,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/model_routes.py#L2361-L2397","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Send an object with the documented keys: {\"hidden\": [...]} and/or {\"pinned_models\": [...]}; omit a key to leave that list untouched.","If you only have the array, wrap it client-side before sending.","Check for double-encoding: JSON.stringify once, not twice.","Verify Content-Type: application/json so the body parses as JSON at all."],"exampleFix":"# before (wrong shape)\nawait client.patch(f\"/model-endpoints/{ep}/models-visibility\", json=[\"gpt-4o\"])\n\n# after\nawait client.patch(f\"/model-endpoints/{ep}/models-visibility\", json={\"hidden\": [\"gpt-4o\"]})","handlingStrategy":"type-guard","validationCode":"if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {\n  throw new TypeError('visibility payload must be a JSON object');\n}","typeGuard":"function isVisibilityBody(v: unknown): v is Record<string, unknown> {\n  return typeof v === 'object' && v !== null && !Array.isArray(v);\n}","tryCatchPattern":"try { await api.patchVisibility(epId, body); }\ncatch (e) { if (e.status === 400) console.error('wrap arrays: {\"hidden\": [...]}, not bare arrays'); throw e; }","preventionTips":["Always wrap list values in an object with the documented key names.","JSON.stringify exactly once; check for double-encoded bodies in proxy logs.","Keep integration tests that assert the exact body shape."],"tags":["fastapi","http-400","json-validation","request-body"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}