diff --git a/crates/tinyinference-llm/src/cache/mod.rs b/crates/tinyinference-llm/src/cache/mod.rs index 70d30de0..35dee1b6 100644 --- a/crates/tinyinference-llm/src/cache/mod.rs +++ b/crates/tinyinference-llm/src/cache/mod.rs @@ -80,7 +80,12 @@ fn fnv1a_hex(data: &[u8]) -> String { /// Recursively sorts the keys of every JSON object so that the serialized form /// is canonical regardless of insertion order. -fn canonical_value(v: Value) -> Value { +/// +/// Public so every cache key derived from JSON — this crate's and a host's — +/// canonicalizes the same way; two copies that drifted would make equal +/// requests hash apart. +#[must_use] +pub fn canonical_value(v: Value) -> Value { match v { Value::Object(map) => { let mut pairs: Vec<(String, Value)> = map.into_iter().collect(); diff --git a/crates/tinyinference-llm/src/prompt_tools/mod.rs b/crates/tinyinference-llm/src/prompt_tools/mod.rs index 6d9373db..8c6d574c 100644 --- a/crates/tinyinference-llm/src/prompt_tools/mod.rs +++ b/crates/tinyinference-llm/src/prompt_tools/mod.rs @@ -325,7 +325,12 @@ pub fn recover_tool_calls(mut response: ModelResponse, tools: &[ToolSchema]) -> /// Rebuilds a content vector keeping every non-text block in place and /// substituting one cleaned text at the position of the first text block. /// An empty `cleaned` emits no text block at all. -fn replace_text_blocks(content: Vec, cleaned: String) -> Vec { +/// +/// Public so a host with its own tool-call recovery (one that mints its own +/// call ids, say) rewrites the visible text exactly as [`recover_tool_calls`] +/// does instead of carrying a copy. +#[must_use] +pub fn replace_text_blocks(content: Vec, cleaned: String) -> Vec { let mut out = Vec::with_capacity(content.len()); let mut inserted = false; for block in content { diff --git a/crates/tinyinference-llm/src/tool.rs b/crates/tinyinference-llm/src/tool.rs index 6601d439..e07f2610 100644 --- a/crates/tinyinference-llm/src/tool.rs +++ b/crates/tinyinference-llm/src/tool.rs @@ -76,7 +76,7 @@ impl ToolSchema { call.name, self.name ))); } - validate_schema_value( + validate_json_value( &self.parameters, &call.arguments, &format!("tool `{}` arguments", self.name), @@ -148,7 +148,19 @@ pub struct ToolDelta { pub content_index: Option, } -fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Result<()> { +/// Validates `value` against the structural subset of JSON Schema that tool +/// arguments are held to: `type` (including unions), object `properties`, +/// `required`, `additionalProperties: false`, array `items`, and `enum`. +/// Unknown keywords are ignored and an empty or null schema imposes nothing. +/// +/// `path` names the value in error messages — `tool \`lookup\` arguments` +/// produces `tool \`lookup\` arguments.limit must be integer, got string`. +/// +/// # Errors +/// +/// Returns [`crate::Error::Validation`] naming the first failing instance +/// path. +pub fn validate_json_value(schema: &Value, value: &Value, path: &str) -> crate::Result<()> { if schema.is_null() || schema.as_object().is_some_and(serde_json::Map::is_empty) { return Ok(()); } @@ -173,7 +185,8 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re } } else if schema.get("type").is_none() { return Err(crate::Error::Validation(format!( - "{path} must be an object with declared fields" + "{path} must be an object with the declared fields, got {}", + json_value_kind(value) ))); } } @@ -190,12 +203,13 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re } for (field, field_schema) in properties { if let Some(field_value) = object.get(field) { - validate_schema_value(field_schema, field_value, &format!("{path}.{field}"))?; + validate_json_value(field_schema, field_value, &format!("{path}.{field}"))?; } } } else if schema.get("type").is_none() { return Err(crate::Error::Validation(format!( - "{path} must be an object with declared fields" + "{path} must be an object with the declared fields, got {}", + json_value_kind(value) ))); } } @@ -203,7 +217,7 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re && let Some(items) = value.as_array() { for (index, item) in items.iter().enumerate() { - validate_schema_value(items_schema, item, &format!("{path}[{index}]"))?; + validate_json_value(items_schema, item, &format!("{path}[{index}]"))?; } } Ok(()) diff --git a/crates/tinyinference-llm/tests/tool_validation.rs b/crates/tinyinference-llm/tests/tool_validation.rs index 76faef19..5928e2d2 100644 --- a/crates/tinyinference-llm/tests/tool_validation.rs +++ b/crates/tinyinference-llm/tests/tool_validation.rs @@ -10,3 +10,99 @@ fn invalid_provider_arguments_fail_even_with_permissive_schema() { let error = schema.validate_call(&call).unwrap_err(); assert!(error.to_string().contains("malformed arguments")); } + +#[test] +fn json_values_are_validated_against_the_structural_subset() { + use tinyinference_llm::tool::validate_json_value; + + let schema = json!({ + "type": "object", + "properties": { + "score": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "mode": { "enum": ["fast", "slow"] } + }, + "required": ["score"], + "additionalProperties": false + }); + + assert!(validate_json_value(&schema, &json!({"score": 3, "tags": ["a"]}), "v").is_ok()); + let cases = [ + (json!({"tags": []}), "v.score is required"), + (json!({"score": "3"}), "v.score must be integer, got string"), + (json!({"score": 1, "extra": 1}), "v.extra is not allowed"), + ( + json!({"score": 1, "tags": [1]}), + "v.tags[0] must be string, got integer", + ), + ( + json!({"score": 1, "mode": "medium"}), + "v.mode must be one of the declared enum values", + ), + ]; + for (value, message) in cases { + let error = validate_json_value(&schema, &value, "v").unwrap_err(); + assert!(error.to_string().contains(message), "{error} vs {message}"); + } + + let untyped = json!({"required": ["id"]}); + let error = validate_json_value(&untyped, &json!(5), "v").unwrap_err(); + assert!( + error + .to_string() + .contains("v must be an object with the declared fields, got integer") + ); + + let union = json!({"type": ["string", "null"]}); + assert!(validate_json_value(&union, &json!(null), "v").is_ok()); + let error = validate_json_value(&union, &json!(1), "v").unwrap_err(); + assert!( + error + .to_string() + .contains("v must be one of string, null, got integer") + ); + + assert!(validate_json_value(&json!({}), &json!(1), "v").is_ok()); + assert!(validate_json_value(&json!({"type": "uuid"}), &json!(1), "v").is_ok()); +} + +#[test] +fn canonical_values_sort_keys_at_every_depth() { + use tinyinference_llm::cache::canonical_value; + + let value = canonical_value(json!({"b": [{"z": 1, "a": 2}], "a": {"y": 1, "x": 2}})); + + assert_eq!( + serde_json::to_string(&value).unwrap(), + r#"{"a":{"x":2,"y":1},"b":[{"a":2,"z":1}]}"# + ); +} + +#[test] +fn replacing_text_blocks_keeps_other_blocks_in_place() { + use tinyinference_llm::ContentBlock; + use tinyinference_llm::prompt_tools::replace_text_blocks; + + let content = vec![ + ContentBlock::Json(json!(1)), + ContentBlock::Text("raw ".into()), + ContentBlock::Json(json!(2)), + ContentBlock::Text("more".into()), + ]; + assert_eq!( + replace_text_blocks(content, "clean".into()), + vec![ + ContentBlock::Json(json!(1)), + ContentBlock::Text("clean".into()), + ContentBlock::Json(json!(2)), + ] + ); + assert_eq!( + replace_text_blocks(vec![], "only".into()), + vec![ContentBlock::Text("only".into())] + ); + assert_eq!( + replace_text_blocks(vec![ContentBlock::Text("x".into())], String::new()), + vec![] + ); +}