Skip to content

[Bugfix] Fix Qwen3CoderToolParser anyOf/oneOf type resolution for nullable params#37831

Merged
chaunceyjiang merged 14 commits intovllm-project:mainfrom
AAISSJ:fix/qwen3coder-anyof-type-resolution
Apr 1, 2026
Merged

[Bugfix] Fix Qwen3CoderToolParser anyOf/oneOf type resolution for nullable params#37831
chaunceyjiang merged 14 commits intovllm-project:mainfrom
AAISSJ:fix/qwen3coder-anyof-type-resolution

Conversation

@AAISSJ
Copy link
Copy Markdown
Contributor

@AAISSJ AAISSJ commented Mar 23, 2026

Purpose

Fix incorrect type resolution for anyOf/oneOf schemas, type-as-array patterns, and $ref schemas in Qwen3CoderToolParser._convert_param_value.

The previous fix (#36032) hardcoded param_type = "object" for all anyOf schemas. While this solved the double-encoding issue for object-typed anyOf params, it breaks nullable parameters with non-object types — they get routed through json.loads instead of their correct conversion branch (int(), float(), etc.).

This PR also subsumes the $ref handling proposed in #37652, integrated cleanly into the same _resolve_param_type helper.

Root Cause

Pydantic v2 emits anyOf for every Optional[T] field:

count: Optional[int] = 5
# becomes:
{"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 5}

With the #36032 fix, this anyOf is resolved to param_type = "object", so the value "5" goes through json.loads("5") — works by accident for integers, but for strings it would try to parse a plain string as JSON and fail or behave unexpectedly.

Similarly, Pydantic model inputs produce $ref schemas (e.g. {"$ref": "#/$defs/ToolInput"}) with no "type" field, which also fell through to "string".

Fix

Two new helper methods to keep type resolution logic clean and DRY:

_first_non_null_type(type_value) — static method that extracts the first non-null type from either a scalar ("integer") or a type-as-array (["integer", "null"]). Reused in both top-level type resolution and anyOf variant scanning.

_resolve_param_type(param_def) — resolves the effective type string from a parameter definition. Handles four cases:

  1. Direct "type" field (including type-as-array)
  2. anyOf/oneOf variants (extracts first non-null type from variants)
  3. $ref schemas (treated as "object" to trigger json.loads)
  4. Fallback to "string"

This replaces the previous inline logic with deeply nested if/elif/else blocks, using early returns for clarity.

Affected Cases

Schema #36032 ("object") This PR (actual type)
anyOf[integer, null] wrong — routes to json.loads "integer"int()
anyOf[string, null] wrong — routes to json.loads "string" → as-is ✅
anyOf[array, null] partial — works but semantically wrong "array"json.loads
anyOf[object, null] correct "object"json.loads
anyOf[object, object] (#36032 case) correct "object"json.loads
anyOf[string, integer, null] wrong — routes to json.loads "string" (first non-null) ✅
{"type": ["integer", "null"]} (top-level) not handled "integer"int()
anyOf[{"type": ["integer", "null"]}] (nested) not handled "integer"int()
{"$ref": "#/$defs/Model"} (#37652 case) not handled "object"json.loads

Also handles oneOf (used by FastAPI and other frameworks) and filters null types expressed as both {"type": "null"} (string) and {"type": null} (Python None, seen in some Pydantic outputs).

Test Plan

Added test_extract_tool_calls_anyof_type_conversion covering 7 patterns:

"anyof_int":       {"anyOf": [{"type": "integer"}, {"type": "null"}]}     → 5 (int) ✅
"anyof_str":       {"anyOf": [{"type": "string"}, {"type": "null"}]}      → "hello" (str) ✅
"anyof_array":     {"anyOf": [{"type": "array"}, {"type": "null"}]}       → ["a","b","c"] (list) ✅
"anyof_obj":       {"anyOf": [{"type": "object"}, {"type": "null"}]}      → {"key":"value"} (dict) ✅
"type_as_array":   {"type": ["integer", "null"]}                          → 42 (int) ✅
"multi_non_null":  {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]}
                                                                          → "some text" (str, first non-null) ✅
"ref_param":       {"$ref": "#/$defs/ToolInput"}                          → {"city":"Paris"} (dict) ✅

Existing test suite passes with no regressions:

tests/tool_parsers/test_qwen3coder_tool_parser.py — 19 passed (+ 1 new = 20 total)
tests/tool_parsers/test_qwen3xml_tool_parser.py — 9 passed, 8 xfailed

Follow-up

Qwen3XMLToolParser._get_param_type has the same issue — it uses properties[param_name].get("type", "string") which falls back to "string" for anyOf schemas. This PR intentionally scopes the fix to Qwen3CoderToolParser only. A follow-up PR for the XML parser will be submitted once this is merged.

@mergify mergify bot added qwen Related to Qwen models tool-calling bug Something isn't working labels Mar 23, 2026
@github-actions
Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

Just a reminder: PRs would not trigger full CI run by default.

Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

🚀

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request provides a crucial bugfix for tool parameter type resolution in Qwen3CoderToolParser, specifically for anyOf/oneOf schemas representing nullable types. The change correctly extracts the actual non-null type instead of hardcoding it as "object", which resolves issues with non-object nullable parameters. My review identifies a remaining edge case where the new logic does not handle a valid JSON Schema construct where the type field is an array (e.g., {"type": ["integer", "null"]}). I've provided a suggestion to make the type extraction more robust to cover this scenario.

@AAISSJ AAISSJ force-pushed the fix/qwen3coder-anyof-type-resolution branch from aa6a797 to 82f5622 Compare March 23, 2026 02:13
AAISSJ added 6 commits March 23, 2026 11:18
…lable params

The previous fix (vllm-project#36032) hardcoded param_type = "object" for all anyOf
schemas. This breaks nullable parameters with non-object types (e.g.
Optional[int], Optional[str], Optional[list]) because they get routed
through json.loads instead of their correct conversion branch (int(),
float(), etc.).

Pydantic v2 emits anyOf for every Optional[T] field:
  Optional[int] → {"anyOf": [{"type": "integer"}, {"type": "null"}]}

This fix extracts the first non-null type from anyOf/oneOf variants,
so each parameter hits its correct type conversion path.

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
Address review feedback: JSON Schema allows type to be an array
(e.g. {"type": ["integer", "null"]}). The previous code would
stringify the array, leading to unrecognized param_type. Now we
extract the first non-null element from type arrays as well.

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
- Handle {"type": ["integer", "null"]} at the top-level type field,
  not just inside anyOf/oneOf variants
- Add test_extract_tool_calls_anyof_type_conversion covering:
  anyOf[integer|null], anyOf[string|null], anyOf[array|null],
  anyOf[object|null], and top-level type-as-array

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
The anyOf type conversion is implemented in Qwen3CoderToolParser,
not Qwen3XMLToolParser. Use the coder parser fixture directly
instead of the parametrized fixture (which only runs xml).

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
- Extract duplicated non-null type extraction into _first_non_null_type
  static method, reused for both top-level type-as-array and anyOf variants
- Extract _resolve_param_type to flatten nesting in _convert_param_value
- Add multi non-null type test case: anyOf[string, integer, null] verifies
  "first non-null type wins" policy

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
Parameters using $ref (e.g. Pydantic model inputs) have no "type" field.
Treat them as "object" to route through json.loads, matching the behavior
proposed in vllm-project#37652 but integrated into the _resolve_param_type helper.

Added ref_param test case to verify $ref → object → json.loads path.

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
@AAISSJ AAISSJ force-pushed the fix/qwen3coder-anyof-type-resolution branch from 82f5622 to 5fd2434 Compare March 23, 2026 02:19
@chaunceyjiang chaunceyjiang self-assigned this Mar 23, 2026
resolved = self._first_non_null_type(param_def["type"])
return resolved or "string"

if "anyOf" in param_def or "oneOf" in param_def:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Thanks. Could you write an end-to-end test for this?

Copy link
Copy Markdown
Contributor Author

@AAISSJ AAISSJ Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, @chaunceyjiang

I've added a streaming e2e test (test_extract_tool_calls_anyof_type_conversion_streaming) that exercises the full pipeline: tokenize → incremental decode → extract_tool_calls_streaming with anyOf nullable schemas. Both non-streaming and streaming paths now have coverage for type resolution.

Update: Also added $ref coverage to the streaming e2e test — the filters parameter uses {"$ref": "#/$defs/SearchFilters"} to verify that $ref schemas are correctly resolved to "object" in the streaming pipeline.

Please let me know if there's anything else you'd like me to address.

  • streaming e2e test commit: 4e076bd
  • $ref streaming coverage commit: 22cdf04

AAISSJ added 2 commits March 23, 2026 13:24
Covers the full streaming pipeline (tokenize → incremental decode →
extract_tool_calls_streaming) with anyOf nullable schemas to verify
that string/integer/boolean types are correctly resolved and returned.

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
The anyOf streaming test only covered string, integer, and boolean
params. Add a $ref parameter (filters) to verify that object type
resolution works correctly in the full streaming pipeline.

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
@AAISSJ AAISSJ force-pushed the fix/qwen3coder-anyof-type-resolution branch from fed85bc to 22cdf04 Compare March 23, 2026 05:51
@schoennenbeck
Copy link
Copy Markdown
Contributor

I'd be in favor of using this more comprehensive PR instead of mine (#37652).

@chaunceyjiang
Copy link
Copy Markdown
Collaborator

Thanks~ @schoennenbeck @AAISSJ

Please fix the pre-commit errors.

Signed-off-by:  <>
Signed-off-by: AAISSJ <maze0717@g.skku.edu>
@AAISSJ AAISSJ force-pushed the fix/qwen3coder-anyof-type-resolution branch from b963182 to d552b46 Compare March 23, 2026 08:28
@AAISSJ
Copy link
Copy Markdown
Contributor Author

AAISSJ commented Mar 23, 2026

Thanks~ @schoennenbeck @AAISSJ

Please fix the pre-commit errors.

Thanks, @chaunceyjiang @schoennenbeck
Could you add the ready label so pre-commit CI can run? The linting fix is in the latest commit (d552b46).

@chaunceyjiang chaunceyjiang added the ready ONLY add when PR is ready to merge/full CI is needed label Mar 23, 2026
@AAISSJ AAISSJ requested a review from chaunceyjiang March 24, 2026 23:53
@AAISSJ
Copy link
Copy Markdown
Contributor Author

AAISSJ commented Mar 25, 2026

Hi, @chaunceyjiang
Can you approve this PR ?

@schoennenbeck
Copy link
Copy Markdown
Contributor

@chaunceyjiang Would be great to get this merged before the next release. Qwen3.5-family are some of the most capable free models and currently in vllm they are incompatible with up to date versions of pydantic-ai.

@chaunceyjiang chaunceyjiang enabled auto-merge (squash) April 1, 2026 12:22
Copy link
Copy Markdown
Collaborator

@chaunceyjiang chaunceyjiang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks

@chaunceyjiang chaunceyjiang merged commit 582340f into vllm-project:main Apr 1, 2026
47 checks passed
@ProExpertProg
Copy link
Copy Markdown
Collaborator

khluu added a commit to khluu/vllm that referenced this pull request Apr 1, 2026
chaunceyjiang pushed a commit that referenced this pull request Apr 1, 2026
@schoennenbeck
Copy link
Copy Markdown
Contributor

@AAISSJ I can tackle this. But since this is your PR I thought I'd ask first.

@bbrowning
Copy link
Copy Markdown
Contributor

It looks like we had an ordering issue between #38189 and this one. CI didn't catch this because nothing forced CI to re-run between when 38189 merged and when this merged, so this one had stale valid CI checks making it look good to merge.

yzong-rh pushed a commit to yzong-rh/vllm that referenced this pull request Apr 3, 2026
…lable params (vllm-project#37831)

Signed-off-by: AAISSJ <maze0717@g.skku.edu>
Signed-off-by: <>
Co-authored-by: 세덩 <saison@sedeong-ui-MacBookAir.local>
yzong-rh pushed a commit to yzong-rh/vllm that referenced this pull request Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working qwen Related to Qwen models ready ONLY add when PR is ready to merge/full CI is needed tool-calling

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants