Skip to content

fix(server): resolve JSON Schema refs and union types in tool arguments - #435

Open
Retloldin wants to merge 1 commit into
FlashML-org:mainfrom
Retloldin:fix/qwen3-coder-json-schema
Open

fix(server): resolve JSON Schema refs and union types in tool arguments#435
Retloldin wants to merge 1 commit into
FlashML-org:mainfrom
Retloldin:fix/qwen3-coder-json-schema

Conversation

@Retloldin

Copy link
Copy Markdown

Summary

This PR fixes incorrect argument type serialization in the qwen3_coder tool-call parser when tool parameter schemas use indirect JSON Schema definitions such as:

  • $ref
  • chained $ref
  • oneOf
  • anyOf
  • nullable unions
  • referenced objects
  • referenced arrays

Previously, qwen3_coder determined the type of a tool parameter primarily from the parameter's direct type field.

Schemas such as:

{
  "limit": {
    "$ref": "#/$defs/Limit"
  }
}

therefore had no directly available type.

The parser fell back to treating the value as a string, even if the referenced schema declared an integer, object, or array.

For example:

{
  "$defs": {
    "Limit": {
      "type": "integer"
    }
  }
}

could incorrectly result in:

{
  "limit": "10"
}

instead of:

{
  "limit": 10
}

This becomes especially problematic when using MCP tools or other tools generated from JSON Schema, where $ref, $defs, oneOf, and anyOf are common.

The resulting OpenAI-compatible function.arguments payload can be valid JSON while still containing incorrect JSON types, causing downstream schema validation to fail with errors.

Root cause

The parameter configuration used by the tool-call parser only retained the contents of properties.

As a consequence, root-level schema information such as $defs was no longer available when determining the effective type of individual parameters.

Additionally, parameter type detection relied on the equivalent of:

prop.get("type", "string")

This means schemas using:

{
  "$ref": "#/$defs/Foo"
}

or:

{
  "oneOf": [
    {"type": "integer"},
    {"type": "null"}
  ]
}

were effectively treated as strings.

For structured values this was particularly harmful.

A referenced object such as:

{
  "options": {
    "$ref": "#/$defs/SearchOptions"
  }
}

could previously be serialized as:

{
  "options": "{\"limit\":10,\"language\":\"en\"}"
}

instead of:

{
  "options": {
    "limit": 10,
    "language": "en"
  }
}

Changes

This PR adds JSON Schema normalization/resolution before qwen3_coder decides how a parameter value should be serialized.

The implementation handles local JSON Pointer references such as:

#/$defs/Foo

and preserves access to the root schema so referenced definitions can be resolved.

The effective parameter schema is normalized before tool argument conversion.

The new behavior includes support for:

$ref

{
  "limit": {
    "$ref": "#/$defs/Limit"
  }
}

with:

{
  "$defs": {
    "Limit": {
      "type": "integer"
    }
  }
}

is resolved as an integer.

Chained $ref

References pointing to another reference are followed until the effective schema is found.

For example:

LimitAlias -> Limit -> integer

is correctly resolved as an integer.

Referenced objects

Object schemas referenced through $ref are treated as objects rather than JSON-encoded strings.

Referenced arrays

Array schemas referenced through $ref preserve their array representation.

oneOf and anyOf

Simple union schemas are normalized when they contain a single effective non-null type.

For example:

{
  "oneOf": [
    {"type": "integer"},
    {"type": "null"}
  ]
}

is treated as an integer for tool argument parsing.

The same behavior applies to equivalent anyOf schemas.

Nullable type arrays

Schemas such as:

{
  "type": ["object", "null"]
}

can resolve to their non-null effective type.

Ambiguous schemas

Schemas for which a single concrete type cannot safely be inferred use loose JSON parsing instead of automatically coercing the argument to a string.

Explicit string schemas continue to remain strings.

This is important so values that look like JSON but are intentionally declared as strings are not unexpectedly converted.

Why this matters for MCP

MCP servers frequently expose tool inputSchema definitions generated by libraries such as Pydantic, Zod, TypeBox, or other JSON Schema generators.

These schemas commonly make use of $defs, $ref, oneOf, and anyOf.

Before this fix, a model could produce a semantically correct tool call, but FreeToken could change its types while translating the Qwen XML tool-call representation into OpenAI-compatible function.arguments.

For example, the model could effectively generate:

<parameter=options>
{"limit": 10, "language": "en"}
</parameter>

but the downstream API could receive:

{
  "options": "{\"limit\": 10, \"language\": \"en\"}"
}

This PR preserves the intended JSON structure and types.


Validation

The fix was tested against a running FreeToken server on using Qwen3.6-35B-A3B-FP8 with the qwen3_coder tool-call parser.

Both non-streaming and streaming OpenAI-compatible responses were validated.

Test 1 — Direct integer type

Schema:

{
  "limit": {
    "type": "integer"
  }
}

Result:

{
  "query": "foo=bar",
  "limit": 10
}

Status: PASS

This verifies that existing direct-type behavior remains unchanged.


Test 2 — $ref to integer

Schema:

{
  "properties": {
    "limit": {
      "$ref": "#/$defs/Limit"
    }
  },
  "$defs": {
    "Limit": {
      "type": "integer"
    }
  }
}

Before the fix:

{
  "query": "foo=bar",
  "limit": "10"
}

After the fix:

{
  "query": "foo=bar",
  "limit": 10
}

Status: PASS


Test 3 — $ref to object

Schema:

{
  "properties": {
    "options": {
      "$ref": "#/$defs/SearchOptions"
    }
  },
  "$defs": {
    "SearchOptions": {
      "type": "object",
      "properties": {
        "limit": {
          "type": "integer"
        },
        "language": {
          "type": "string"
        }
      }
    }
  }
}

Before the fix:

{
  "query": "foo=bar",
  "options": "{\"limit\": 10, \"language\": \"en\"}"
}

After the fix:

{
  "query": "foo=bar",
  "options": {
    "limit": 10,
    "language": "en"
  }
}

Status: PASS


Test 4 — oneOf(integer, null)

Schema:

{
  "value": {
    "oneOf": [
      {
        "type": "integer"
      },
      {
        "type": "null"
      }
    ]
  }
}

Before the fix:

{
  "value": "123"
}

After the fix:

{
  "value": 123
}

Status: PASS


Test 5 — Streaming $ref to integer

The same referenced integer schema was tested using:

{
  "stream": true
}

The streamed function.arguments chunks reconstructed to:

{
  "query": "foo=bar",
  "limit": 10
}

The integer is emitted without quotes.

Final response correctly ends with:

{
  "finish_reason": "tool_calls"
}

Status: PASS


Test 6 — Streaming $ref to object

A referenced object was tested with streaming enabled.

The streamed chunks reconstructed to:

{
  "query": "foo=bar",
  "options": {
    "limit": 10,
    "language": "en"
  }
}

The object is no longer emitted as an escaped JSON string.

Status: PASS


Test 7 — Streaming oneOf(integer, null)

The union schema was tested with streaming enabled.

The resulting streamed arguments reconstructed to:

{
  "value": 123
}

instead of:

{
  "value": "123"
}

Status: PASS


Test 8 — Streaming $ref to array

Schema:

{
  "properties": {
    "ids": {
      "$ref": "#/$defs/Ids"
    }
  },
  "$defs": {
    "Ids": {
      "type": "array",
      "items": {
        "type": "integer"
      }
    }
  }
}

The streamed arguments reconstructed to:

{
  "ids": [1, 2, 3]
}

instead of a JSON-encoded string.

Status: PASS


Test 9 — Chained $ref

Schema relationship:

limit
  -> #/$defs/LimitAlias
  -> #/$defs/Limit
  -> integer

Result:

{
  "limit": 25
}

Status: PASS

This verifies recursive local $ref resolution.


Test 10 — anyOf(integer, null)

Schema:

{
  "count": {
    "anyOf": [
      {
        "type": "integer"
      },
      {
        "type": "null"
      }
    ]
  }
}

Result:

{
  "count": 42
}

Status: PASS


Test summary

Case Non-streaming Streaming
Direct integer PASS PASS
$ref → integer PASS PASS
$ref → object PASS PASS
oneOf(integer, null) PASS PASS
$ref → array PASS PASS
Chained $ref PASS N/A
anyOf(integer, null) PASS N/A

All tested values now preserve their intended JSON types inside OpenAI-compatible function.arguments.

Scope

This PR is intentionally focused on JSON Schema resolution and argument type serialization in qwen3_coder.

It does not attempt to address unrelated Qwen reasoning/tool-call parsing behavior, such as cases where a model emits <tool_call> before closing a <think> block. That should be handled independently to keep this change focused and easier to review.

Expected impact

The change should improve compatibility with:

  • MCP tools
  • OpenAI-compatible tool clients
  • JSON Schema generated by Pydantic/Zod/TypeBox-style libraries
  • schemas using $defs
  • nullable schemas
  • structured object and array arguments
  • streaming and non-streaming Qwen tool calls

It also preserves the existing behavior for parameters that directly specify their JSON Schema type.

@Retloldin Retloldin changed the title fix(qwen3_coder): resolve JSON Schema refs and union types in tool arguments fix(server): resolve JSON Schema refs and union types in tool arguments Sep 10, 2026
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Sep 11, 2026
@gdevenyi

Copy link
Copy Markdown

Ran this against our deployment's parser (--tool-call-parser qwen3_coder in production), and wrote the table test the PR is missing. Nine of ten cases pass — every indirect form you documented works. One crashes.

tests/server/test_function_call_parser_refs.py  9 passed, 1 failed
FAILED test_self_referential_ref_terminates - RecursionError: maximum recursion depth exceeded

A cyclic $ref takes the parser down. _resolve_local_ref recurses on its own output:

merged = dict(node)
merged.update({k: v for k, v in schema.items() if k != "$ref"})
if "$ref" in merged:
    return self._resolve_local_ref(merged, root_schema)   # no cycle guard

With {"A": {"$ref": "#/$defs/A"}} the merged dict is identical every pass, so it recurses until the interpreter gives up. This is not a contrived schema — Pydantic emits exactly this shape for any recursive model (a tree node, a nested filter, a comment with replies), and it reaches the parser straight from model_json_schema(). A client that registers one such tool crashes the request path on every call.

A seen-set fixes it, and the natural fallback is what you already do for a dangling ref — return the schema unresolved:

def _resolve_local_ref(self, schema, root_schema, _seen=None):
    ...
    _seen = _seen or set()
    if ref in _seen:
        return schema          # cycle: stop where a dangling ref stops
    _seen.add(ref)
    ...
    if "$ref" in merged:
        return self._resolve_local_ref(merged, root_schema, _seen)

The test is yours if you want ittests/server/test_function_call_parser_refs.py, a parametrized table over direct type / single $ref / $ref chain / oneOf / anyOf / nullable union / $ref to array / $ref to object, plus the two edge cases. It lives next to the existing tests/server/test_function_call_parser.py, which is where I would have expected 78 lines of new schema resolution to land — the branchiness here is exactly what a table catches cheaply.

One note on a case I got wrong, so nobody chases it: I first asserted a dangling $ref falls back to string. It does not — it JSON-parses, so 5 comes back as an integer. That is a defensible choice and the existing behaviour; I changed my test to assert only that it does not raise. Worth a line in the docstring either way, since "unresolvable" and "untyped" now behave differently from each other.

We want this fix — our users hit $ref schemas through normal Pydantic tooling — so I am happy to carry the cycle guard downstream if you would rather keep this PR to the feature.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

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