Skip to content

fix: reject non-dict items in the messages array with a clean 400 - #152

Open
shrdgn wants to merge 1 commit into
mainfrom
claude/vibrant-archimedes-glp7i7
Open

fix: reject non-dict items in the messages array with a clean 400#152
shrdgn wants to merge 1 commit into
mainfrom
claude/vibrant-archimedes-glp7i7

Conversation

@shrdgn

@shrdgn shrdgn commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What & why

chat_completions in openfusion/server.py validated that messages is a non-empty list, but never checked that each item in it is an object. That check exists inconsistently elsewhere in the codebase: router.py, cache.py, and estimate.py all guard message access with isinstance(message, dict), but panel.py, pipeline.py, ranked.py, and synthesize.py call message.get(...) directly on every item with no such guard.

A request like:

{"model": "openfusion", "messages": [{"role": "user", "content": "hi"}, "oops"]}

reaches those unguarded call sites and raises an AttributeError: 'str' object has no attribute 'get'. The endpoint's catch-all except Exception turns that into a generic UpstreamError, which defaults to HTTP 502 — the client gets an opaque "upstream" failure for what is really a malformed request that should be a clean 400 invalid_request_error, consistent with the existing messages is required and must be a non-empty array check right above it.

This PR adds the missing isinstance(message, dict) check next to the existing list/non-empty check, so malformed items are rejected at the request boundary before reaching any fusion strategy.

How it was tested

  • ruff check . passes
  • pytest -q passes (491 passed, no live network)
  • New behavior has a test (test_rejects_non_dict_message_item in tests/test_server_pass_through.py)
  • Docs updated if config / request surface / defaults changed — N/A, no config/request-surface change, just tightened validation of an already-invalid input
  • No secrets, prompts, or response bodies added to logs or metrics
  • Quality/cost claims backed by a reproducible bench/run.py number — N/A, not a quality/cost change

Notes for reviewers

Found while auditing the codebase for inconsistent input validation; scoped to just the one missing guard rather than touching panel.py/pipeline.py/ranked.py/synthesize.py, since validating at the API boundary makes the per-module guards unnecessary defense-in-depth rather than required fixes.


Generated by Claude Code

server.py validated that messages is a non-empty list but not that each
item is a dict, unlike router.py/cache.py/estimate.py which all guard
with isinstance(message, dict). A malformed item (e.g. a bare string)
reached panel.py/pipeline.py/ranked.py/synthesize.py, which call
message.get(...) without guarding and crash with AttributeError -- caught
by the endpoint's catch-all and surfaced as a 502 UpstreamError instead
of a proper 400 InvalidRequestError.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants