Skip to content

fix(serve): a non-object json_schema answered 500 "engine failed" - #1587

Merged
JustVugg merged 1 commit into
JustVugg:devfrom
kevin9327:fix/serve-json-schema-non-object
Sep 17, 2026
Merged

JustVugg merged 1 commit into
JustVugg:devfrom
kevin9327:fix/serve-json-schema-non-object

Conversation

@kevin9327

@kevin9327 kevin9327 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Symptom

A request whose response_format.json_schema is not an object gets HTTP 500 on /v1/chat/completions and /v1/completions. Examples: a JSON-encoded string, a list, or a number. The body blames the engine. The request below is one of those payloads, and the response and log line were captured from a real coli serve on the tiny Qwen3.6 fixture:

$ curl -s localhost:8000/v1/chat/completions -d '{"model":"qwen3.6-colibri","messages":[{"role":"user","content":"x"}],
    "response_format":{"type":"json_schema","json_schema":"{\"schema\":{}}"}}'
{"error":{"message":"The colibri engine failed to process the request.","type":"server_error","param":null,"code":"engine_error"}}

server log: [api] 127.0.0.1 - request failed: 'str' object has no attribute 'get'

The request is malformed, so the answer should be a 400 that names the parameter. OpenAI SDKs retry a 5xx by default, so the malformed request is also resent before the client sees the error.

I found this by sending about 70 malformed payloads to a real coli serve (tiny Qwen3.6 fixture). This was the only one that produced a 5xx. Every other malformed field, including the neighbouring json_schema: {}, response_format: "json" and an empty GBNF grammar, already gets a 400.

Root cause

generation_options does:

schema = (response_format.get("json_schema") or {}).get("schema")

or {} covers a missing or falsy json_schema, but not a truthy one that is not a dict. The resulting AttributeError reaches do_POST's catch-all, which answers 500 engine_error.

Fix

Call .get("schema") only when json_schema is a dict. Anything else reaches the existing 400: "response_format.json_schema.schema must be an object." (param response_format). Valid schemas are forwarded exactly as before.

Tests

test_openai_server.TemplateTest.test_validates_generation_limits gains subtests for json_schema as a string, a list, a number and true. Each must raise APIError with status 400 and param response_format.

Fail-before on unfixed dev:

ERROR: test_validates_generation_limits (...) (json_schema='{"schema": {}}')
AttributeError: 'str' object has no attribute 'get'
ERROR: test_validates_generation_limits (...) (json_schema=[{...}])
AttributeError: 'list' object has no attribute 'get'
ERROR: test_validates_generation_limits (...) (json_schema=5)
AttributeError: 'int' object has no attribute 'get'
ERROR: test_validates_generation_limits (...) (json_schema=True)
AttributeError: 'bool' object has no attribute 'get'
FAILED (errors=4, skipped=1)

Verification

  • tests.test_openai_server: 171 tests OK (1 skipped), in the cp949 locale and with PYTHONUTF8=1.
  • Every test module that imports openai_server (18 modules; test_fp8_repack_full_family left out because it needs torch here): 375 tests OK, 26 skipped.
  • End to end, Windows, coli serve on the tiny Qwen3.6 fixture (tools/make_qwen36_tiny.py + convert_qwen36.py). The same malformed-payload run now answers 400 for json_schema as a string or a list on /v1/chat/completions, and as a string on /v1/completions. Across all 62 cases of the first run: 52 × 400, 10 × 200, no 5xx, and no request failed line in the server log.

🤖 Generated with Claude Code

generation_options read `(response_format.get("json_schema") or {})
.get("schema")`. A truthy json_schema that is not an object (a JSON
string, a list, a number) raised AttributeError, which do_POST turns
into HTTP 500 "The colibri engine failed to process the request." on
both /v1/chat/completions and /v1/completions. OpenAI SDKs retry a 5xx,
and the message blames the engine for a malformed request.

Take .get only from a dict, so the existing 400
"`response_format.json_schema.schema` must be an object." covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JustVugg
JustVugg merged commit 2a63860 into JustVugg:dev Sep 17, 2026
28 checks passed
JustVugg pushed a commit that referenced this pull request Sep 18, 2026
…iled"

generation_options() already refuses a `tool_choice` function object that
carries no name, with a 400. It never got the chance: the line above reads
`(choice.get("function") or {}).get("name")`, so writing the name where the
object goes -- {"type": "function", "function": "search"} instead of
{"function": {"name": "search"}} -- raises AttributeError, and do_POST's
catch-all answers HTTP 500 "The colibri engine failed to process the request."
OpenAI SDKs retry a 5xx, against an engine that was never asked anything.

The five renderers that read the forced tool (GLM-5.2, GLM-5.3, Kimi, DeepSeek
V4 and V4.1) carry the same expression, and on /v1/chat/completions the prompt
is rendered before generation_options runs, so they crash first; /v1/completions
reaches the validator directly and crashed there. Both endpoints, every arch.

Read the member, then check it -- the same shape as the json_schema fix
(#1587) -- through one helper, so the existing 400 covers all six call sites.
The legacy {"type": "function", "name": ...} spelling and a well-formed
function object are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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