Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/tinyinference-llm/src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion crates/tinyinference-llm/src/prompt_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentBlock>, cleaned: String) -> Vec<ContentBlock> {
///
/// 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<ContentBlock>, cleaned: String) -> Vec<ContentBlock> {
let mut out = Vec::with_capacity(content.len());
let mut inserted = false;
for block in content {
Expand Down
26 changes: 20 additions & 6 deletions crates/tinyinference-llm/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -148,7 +148,19 @@ pub struct ToolDelta {
pub content_index: Option<usize>,
}

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<()> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose shared helpers from a supported public crate

This public export, together with the new cache::canonical_value and prompt_tools::replace_text_blocks exports, is explicitly intended for consumption by TinyAgents, but repository policy designates only tinyinference-core and tinyinference-local as public crates and assigns provider-neutral inference, cache, message, and tool-call APIs to core. Depending on these helpers through tinyinference-llm creates a new unsupported cross-repository API boundary; place the normalized helpers/types behind the core public surface instead (or update the repository architecture policy as part of the change).

AGENTS.md reference: AGENTS.md:L12-L18

Useful? React with 👍 / 👎.

if schema.is_null() || schema.as_object().is_some_and(serde_json::Map::is_empty) {
return Ok(());
}
Expand All @@ -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)
)));
}
}
Expand All @@ -190,20 +203,21 @@ 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)
)));
}
}
if let Some(items_schema) = schema.get("items")
&& 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(())
Expand Down
96 changes: 96 additions & 0 deletions crates/tinyinference-llm/tests/tool_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>".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![]
);
}
Loading