From 9894e0386d0b0ae4139f3255a00d26629d42190e Mon Sep 17 00:00:00 2001 From: inciarmors Date: Fri, 23 Oct 2026 15:07:05 +0200 Subject: [PATCH] fix(schema): preserve strict array length and uniqueness constraints --- deepseek-recipe/src/protocol/json_schema.rs | 32 ++++++++ .../tests/strict_array_constraints.rs | 80 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 deepseek-recipe/tests/strict_array_constraints.rs diff --git a/deepseek-recipe/src/protocol/json_schema.rs b/deepseek-recipe/src/protocol/json_schema.rs index f00a97d..a6b8d6b 100644 --- a/deepseek-recipe/src/protocol/json_schema.rs +++ b/deepseek-recipe/src/protocol/json_schema.rs @@ -2,6 +2,7 @@ //! //! Strict schemas are deserialized into this type and normalized: every object //! lists all of its properties as required and forbids additional properties. +//! Array length and uniqueness constraints are preserved during normalization. //! Non-strict schemas are only validated; see [`validate_schema`]. use deepseek_recipe_macros::UntaggedDeserialize; @@ -127,6 +128,12 @@ pub(crate) enum JsonSchemaType { items: Box, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + unique_items: Option, }, #[serde(rename_all = "camelCase")] Null { @@ -316,6 +323,31 @@ mod tests { ); } + #[test] + fn normalize_preserves_nested_array_constraints_and_normalizes_item_objects() { + let normalized = normalize(json!({ + "type": "object", + "properties": {"groups": { + "type": "array", "minItems": 1, "maxItems": 2, "uniqueItems": true, + "items": { + "type": "array", "minItems": 0, "maxItems": 0, "uniqueItems": false, + "items": {"type": "object", "properties": {"name": {"type": "string"}}}, + }, + }}, + })) + .expect("the schema normalizes"); + let groups = &normalized["properties"]["groups"]; + assert_eq!(groups["minItems"], 1); + assert_eq!(groups["maxItems"], 2); + assert_eq!(groups["uniqueItems"], true); + let items = &groups["items"]; + assert_eq!(items["minItems"], 0); + assert_eq!(items["maxItems"], 0); + assert_eq!(items["uniqueItems"], false); + assert_eq!(items["items"]["required"], json!(["name"])); + assert_eq!(items["items"]["additionalProperties"], false); + } + #[test] fn normalize_rejects_inconsistent_required_lists() { let base = json!({"type": "object", "properties": {"a": {"type": "string"}}}); diff --git a/deepseek-recipe/tests/strict_array_constraints.rs b/deepseek-recipe/tests/strict_array_constraints.rs new file mode 100644 index 0000000..73c2ffd --- /dev/null +++ b/deepseek-recipe/tests/strict_array_constraints.rs @@ -0,0 +1,80 @@ +use deepseek_recipe::openai::{ChatCompletionRequest, ResponsesRequest}; +use deepseek_recipe::request::{ + ConversationRequest, ConversionOptions, OutputConstraint, ProtocolRequest, +}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +fn convert(request: Value) -> ConversationRequest { + serde_json::from_value::(request) + .expect("the request deserializes") + .convert(ConversionOptions::default()) + .expect("the request converts") +} + +#[test] +fn strict_array_constraints_survive_protocol_conversion() { + let schema = json!({ + "type": "object", + "properties": { + "labels": { + "type": "array", "items": {"type": "string"}, + "minItems": 2, "maxItems": 3, "uniqueItems": true, + }, + }, + }); + let model = "DeepSeek-V4.1-Flash"; + let messages = json!([{"role": "user", "content": "Return JSON labels."}]); + let function = json!({"name": "label", "parameters": schema, "strict": true}); + let requests = [ + convert::(json!({ + "model": model, "messages": messages, + "tools": [{"type": "function", "function": function}], + })), + convert::(json!({ + "model": model, "input": "Return labels.", + "tools": [{"type": "function", "name": "label", "parameters": schema, "strict": true}], + })), + convert::(json!({ + "model": model, "messages": messages, + "response_format": {"type": "json_schema", "json_schema": { + "name": "labels", "schema": schema, "strict": true, + }}, + })), + convert::(json!({ + "model": model, "input": "Return JSON labels.", + "text": {"format": {"type": "json_schema", "name": "labels", "schema": schema, "strict": true}}, + })), + ]; + + for request in requests { + let normalized = match request.inference_options.output_constraint.unwrap() { + OutputConstraint::ToolCalls { tools, .. } => { + assert_eq!( + request.conversation.tools[0].parameters, + tools[0].parameters + ); + tools[0].parameters.clone() + } + OutputConstraint::Json { + schema: Some(schema), + } => schema, + other => panic!("unexpected constraint: {other:?}"), + }; + let validator = jsonschema::validator_for(&normalized).expect("valid normalized schema"); + for labels in [json!(["a", "b"]), json!(["a", "b", "c"])] { + assert!(validator.is_valid(&json!({"labels": labels}))); + } + for labels in [ + json!([]), + json!(["a"]), + json!(["a", "b", "c", "d"]), + json!(["a", "a"]), + ] { + assert!( + !validator.is_valid(&json!({"labels": labels})), + "normalized schema accepted invalid labels {labels}: {normalized}" + ); + } + } +}