JuliusBrussee/caveman · error · TypeError
LiteLLM metadata must be a native dictionary
Error message
LiteLLM metadata must be a native dictionary
What it means
_tag stores the caveman call key inside the request's metadata dict ('metadata', or 'litellm_metadata' for the openai-responses protocol). LiteLLM forwards this dict to provider APIs, so it must be a plain native dict; _tag raises TypeError if it is any other type (list, string, custom mapping, non-JSON-safe object).
Solutions
- Pass metadata as a plain JSON-object dict: metadata={"k": "v"}
- Convert other containers first: metadata=dict(my_mapping) or metadata=my_model.model_dump()
- Check which protocol you are using — for openai-responses populate litellm_metadata, otherwise metadata
- Remove non-JSON-serializable values (datetimes, objects) from the metadata dict
Example fix
// before
completion(scope=scope, metadata=["tag1"])
// after
completion(scope=scope, metadata={"tags": ["tag1"]}) Defensive patterns
Strategy: type-guard
Validate before calling
meta = kwargs.get("metadata") or {}
if not isinstance(meta, dict):
kwargs["metadata"] = dict(meta) if hasattr(meta, "keys") else {} Type guard
def is_plain_dict(v) -> bool:
return isinstance(v, dict) and type(v) is dict Try / catch
try:
result = adapter.completion(scope=scope, metadata=metadata, ...)
except TypeError as e:
if "native dictionary" in str(e):
result = adapter.completion(scope=scope, metadata=dict(metadata or {}), ...) Prevention
- Always pass JSON-object dicts as metadata — never lists, strings, or pydantic models
- Use model_dump()/dataclasses.asdict() to convert structured objects first
- Match the field to the protocol: 'metadata' for chat, 'litellm_metadata' for openai-responses
- Keep metadata values JSON-serializable (str/int/list/dict)
When it happens
Trigger: Passing metadata (or litellm_metadata for Responses-protocol calls) that is not a plain dict: a list, a str, an OrderedDict/custom mapping rejected by plain(), or a JSON-incompatible value, into completion/acompletion/responses kwargs.
Common situations: Reusing provider metadata structures from another SDK; passing pydantic models or dataclasses as metadata; copying metadata from a Responses call into a Chat call (wrong field name) leaving a non-dict value in place.
Understand the failure class
Background: Type mismatch errors: IllegalArgumentException, TypeError and type guards across 150 open-source libraries — this error's family across 150 libraries.
Related errors
- LiteLLM scope must be a trusted Caveman Scope
- Synchronous LiteLLM calls require MiddlewareRuntime
- Agno scope resolver must return a Caveman Scope
- AutoGen requires a stable Caveman Scope for each agent or…
- Expected a native Strands Model
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/6f729570faf42007.
Report an issue: GitHub.
Appendix: source
Thrown at packages/middleware/python/caveman_middleware/litellm.py:130
def _request(self, kwargs):
params = kwargs.get("litellm_params")
params = params if plain(params) else {}
with self._lock:
for metadata in (kwargs.get("litellm_metadata"), kwargs.get("metadata"), params.get("litellm_metadata"), params.get("metadata")):
key = metadata.get(_KEY) if plain(metadata) else None
request = self._requests.get(key) if type(key) is str else None
if request and request.expires > time.monotonic():
return key, request
return None
def _tag(self, kwargs, key, protocol):
# Responses metadata belongs to the provider's public storage contract.
# LiteLLM keeps its own routing/auth metadata in a separate native field.
name = "litellm_metadata" if protocol == "openai-responses" else "metadata"
metadata = kwargs.get(name)
if metadata is not None and not plain(metadata):
raise TypeError("LiteLLM metadata must be a native dictionary")
return {**kwargs, name: {**(metadata or {}), _KEY: key}}
def _report(self, reason, logical_id=None):
return self.runtime.report(None, reason=reason, adapter="litellm",
logical_call_id=logical_id or str(uuid.uuid4()), attempt_id=str(uuid.uuid4()))
def _passive_reason(self, method, kwargs):
if self.runtime.mode == "off":
return "disabled"
if not self._version_supported:
return "unsupported_version"
responses = method in ("responses", "aresponses")
source = kwargs.get("input" if responses else "messages")
if type(source) not in ((list, str) if responses else (list,)):
# Native async preprocessing can normalize an opaque collection.
# Preserve its public-call behavior without claiming ownership.
return "unsupported_shape"
return NoneView on GitHub (pinned to 3ee70a1026)