Merge 9894e0386d0b0ae4139f3255a00d26629d42190e into 935817c66f5d6e64838f419692545f06787e485f

This commit is contained in:
mors 2026-10-23 15:07:34 +02:00 committed by GitHub
commit 9699e79292
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 112 additions and 0 deletions

View File

@ -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<JsonSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
min_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
unique_items: Option<bool>,
},
#[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"}}});

View File

@ -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<T: ProtocolRequest + DeserializeOwned>(request: Value) -> ConversationRequest {
serde_json::from_value::<T>(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::<ChatCompletionRequest>(json!({
"model": model, "messages": messages,
"tools": [{"type": "function", "function": function}],
})),
convert::<ResponsesRequest>(json!({
"model": model, "input": "Return labels.",
"tools": [{"type": "function", "name": "label", "parameters": schema, "strict": true}],
})),
convert::<ChatCompletionRequest>(json!({
"model": model, "messages": messages,
"response_format": {"type": "json_schema", "json_schema": {
"name": "labels", "schema": schema, "strict": true,
}},
})),
convert::<ResponsesRequest>(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}"
);
}
}
}