fix(server): resolve JSON Schema refs and union types in tool arguments - #435
fix(server): resolve JSON Schema refs and union types in tool arguments#435Retloldin wants to merge 1 commit into
Conversation
|
Ran this against our deployment's parser ( A cyclic 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 guardWith 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 it — One note on a case I got wrong, so nobody chases it: I first asserted a dangling We want this fix — our users hit 🤖 Generated with Claude Code |
Summary
This PR fixes incorrect argument type serialization in the
qwen3_codertool-call parser when tool parameter schemas use indirect JSON Schema definitions such as:$ref$refoneOfanyOfPreviously,
qwen3_coderdetermined the type of a tool parameter primarily from the parameter's directtypefield.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, andanyOfare common.The resulting OpenAI-compatible
function.argumentspayload 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
$defswas no longer available when determining the effective type of individual parameters.Additionally, parameter type detection relied on the equivalent of:
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_coderdecides how a parameter value should be serialized.The implementation handles local JSON Pointer references such as:
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
$refReferences pointing to another reference are followed until the effective schema is found.
For example:
is correctly resolved as an integer.
Referenced objects
Object schemas referenced through
$refare treated as objects rather than JSON-encoded strings.Referenced arrays
Array schemas referenced through
$refpreserve their array representation.oneOfandanyOfSimple 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
anyOfschemas.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
inputSchemadefinitions generated by libraries such as Pydantic, Zod, TypeBox, or other JSON Schema generators.These schemas commonly make use of
$defs,$ref,oneOf, andanyOf.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:
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_codertool-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 —
$refto integerSchema:
{ "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 —
$refto objectSchema:
{ "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
$refto integerThe same referenced integer schema was tested using:
{ "stream": true }The streamed
function.argumentschunks 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
$refto objectA 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
$refto arraySchema:
{ "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
$refSchema relationship:
Result:
{ "limit": 25 }Status: PASS
This verifies recursive local
$refresolution.Test 10 —
anyOf(integer, null)Schema:
{ "count": { "anyOf": [ { "type": "integer" }, { "type": "null" } ] } }Result:
{ "count": 42 }Status: PASS
Test summary
$ref→ integer$ref→ objectoneOf(integer, null)$ref→ array$refanyOf(integer, null)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:
$defsIt also preserves the existing behavior for parameters that directly specify their JSON Schema
type.