fix(protocol): check_function_call_usage raises raw KeyError on malformed tool_choice dict - #3514
Conversation
…rmed tool_choice dict ChatCompletionRequest.tool_choice is typed as Optional[Union[Literal["none","auto"], Dict]] -- the Dict case has no nested field-level schema, so pydantic accepts any dict shape. When tool_choice is a dict missing "type" or with a "function" sub-object missing "name" (both schema-valid per the type hint, just semantically incomplete), check_function_call_usage indexed those keys directly and raised a raw KeyError instead of the documented BadRequestError. Every public MLCEngine/AsyncMLCEngine chat-completion method's docstring documents "Raises: e : BadRequestError -- BadRequestError is raised when the request is invalid," and check_function_call_usage is called directly (no try/except) from engine_base.process_chat_completion_request. A caller written to the documented contract -- catching BadRequestError to produce a graceful error response -- would let this KeyError propagate uncaught instead. Fix: use .get() for the "type" lookup and validate the "function" sub-object (must be a dict containing "name") before indexing into it, raising the existing BadRequestError type with a descriptive message in both cases. No change to the valid-request code path. Added tests/python/protocol/test_openai_api_protocol.py (pytest.mark.unittest, no GPU/TVM build needed) covering: missing "name", missing "type", and the existing valid-tool-choice path. Locally verified red/green with an importlib-based harness that loads the protocol module directly (pydantic/shortuuid/fastapi only) since this environment doesn't have TVM built to import the full mlc_llm package -- both crash cases raised a raw KeyError against unfixed code and the documented BadRequestError after the fix; the valid-tool-choice path is unaffected. ruff check + ruff format: clean. Duplicate-work check: open PR mlc-ai#3190 (long-standing, approved, unmerged) touches this same function but only removes two blank lines around it -- it does not add the missing-key guards this PR adds, so there's no functional overlap, just a possible trivial rebase if mlc-ai#3190 lands first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request improves the validation of tool_choice in the OpenAI API protocol implementation by safely retrieving fields and ensuring tool_choice.function is a dictionary containing a 'name' field, preventing potential KeyError exceptions. It also adds unit tests to verify this behavior. The reviewer pointed out that checking len(tool_choice_function) > 1 is redundant and potentially buggy, as it counts the number of keys in the dictionary rather than the number of tools, and suggested removing this check.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| tool_choice_function = self.tool_choice.get("function") | ||
| if not isinstance(tool_choice_function, dict) or "name" not in tool_choice_function: | ||
| raise BadRequestError("tool_choice.function must be an object with a 'name' field") | ||
|
|
||
| if len(tool_choice_function) > 1: | ||
| raise BadRequestError("Only one tool is supported when tool_choice is specified") |
There was a problem hiding this comment.
The check len(tool_choice_function) > 1 counts the number of keys in the tool_choice_function dictionary (e.g., {"name": "foo"} has a length of 1). It does not check the number of tools specified, as tool_choice_function is already a single dictionary representing one function choice. If a client sends any extra or custom keys in the function object, this check will raise a BadRequestError with a misleading message ("Only one tool is supported when tool_choice is specified"). Since tool_choice as a dictionary can only specify a single function anyway, this check is redundant and should be removed.
| tool_choice_function = self.tool_choice.get("function") | |
| if not isinstance(tool_choice_function, dict) or "name" not in tool_choice_function: | |
| raise BadRequestError("tool_choice.function must be an object with a 'name' field") | |
| if len(tool_choice_function) > 1: | |
| raise BadRequestError("Only one tool is supported when tool_choice is specified") | |
| tool_choice_function = self.tool_choice.get("function") | |
| if not isinstance(tool_choice_function, dict) or "name" not in tool_choice_function: | |
| raise BadRequestError("tool_choice.function must be an object with a 'name' field") |
Problem
ChatCompletionRequest.tool_choiceis typed asOptional[Union[Literal["none", "auto"], Dict]]— theDictcase has nonested field-level schema, so pydantic accepts any dict shape at request
parse time.
check_function_call_usage()then indexes into that dictdirectly:
A request body like
tool_choice: {"type": "function", "function": {}}(missing
"name") ortool_choice: {"function": {"name": "foo"}}(missing"type") is schema-valid per the type hint, but crashes with a rawKeyErrorinstead of the intendedBadRequestError.Why it matters
Every public
MLCEngine/AsyncMLCEnginechat-completion method documents:check_function_call_usage()is called directly (notry/except) fromengine_base.process_chat_completion_request, which every one of thosedocumented methods calls. A caller written to the documented contract —
catching
BadRequestErrorto produce a graceful 400-style error response —lets this
KeyErrorpropagate uncaught instead.Repro (bare, before fix)
Fix
Use
.get("type")for the type check, and validate thefunctionsub-object (must be a dict containing
"name") before indexing into it —raising the existing
BadRequestErrorwith a descriptive message in bothnew-guard cases. The valid-request code path is unchanged.
Testing
Added
tests/python/protocol/test_openai_api_protocol.py(
pytest.mark.unittest— no GPU/TVM build required) covering: missing"name", missing"type", and the existing valid-tool_choicepath.This local environment doesn't have TVM built (a full native build wasn't
feasible for verifying a pure-Python protocol-parsing fix), so I
independently verified red→green with an importlib-based harness that
loads
mlc_llm/protocol/*.pydirectly (pydantic/shortuuid/fastapionly, stubbing out the parent
mlc_llmpackage to avoid its TVM import) —functionally equivalent to the pytest cases above:
tool_choicecases raised a rawKeyErrorBadRequestError; the valid-choicepath still correctly sets
conv_template.use_function_calling = Trueruff checkandruff format --check: clean.Duplicate-work check
Open PR #3190 (long-standing, approved, unmerged — created 2025-03-26)
touches this same function while adding structural-tag-based tool call
support, but its diff only removes two blank lines around
check_function_call_usage; it doesn't add the missing-key guards this PRadds. No functional overlap — just a possible trivial rebase if #3190
lands first.
Disclosure: I used AI assistance (Claude) to find this bug and draft the
fix and tests. I independently traced the call chain from the documented
BadRequestErrorcontract down to the unguarded dict access, reproducedboth crash cases and the valid-path behavior against the actual module
code (via the importlib harness described above, since a full TVM build
wasn't available), and take responsibility for the change's correctness.