use serde_json::{Map, Value};
use super::collect::ToolSchema;
use super::params::ToolParamSchema;
use super::type_expr::{ObjectField, TypeExpr};
pub(crate) fn tool_schema_to_function_schema(tool: &ToolSchema) -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": params_to_json_schema(&tool.params),
},
})
}
pub fn function_schema_from_catalog_row(row: &Value) -> Option<Value> {
let tool: ToolSchema = serde_json::from_value(row.clone()).ok()?;
Some(tool_schema_to_function_schema(&tool))
}
fn params_to_json_schema(params: &[ToolParamSchema]) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for param in params {
let mut schema = type_expr_to_json_schema(¶m.ty);
annotate(
&mut schema,
¶m.description,
param.default.as_ref(),
¶m.examples,
);
properties.insert(param.name.clone(), schema);
if param.required {
required.push(Value::String(param.name.clone()));
}
}
let mut object = Map::new();
object.insert("type".to_string(), Value::String("object".to_string()));
object.insert("properties".to_string(), Value::Object(properties));
if !required.is_empty() {
object.insert("required".to_string(), Value::Array(required));
}
Value::Object(object)
}
fn annotate(schema: &mut Value, description: &str, default: Option<&Value>, examples: &[Value]) {
let Some(object) = schema.as_object_mut() else {
return;
};
if !description.is_empty() {
object.insert(
"description".to_string(),
Value::String(description.to_string()),
);
}
if let Some(default) = default {
object.insert("default".to_string(), default.clone());
}
if !examples.is_empty() {
object.insert("examples".to_string(), Value::Array(examples.to_vec()));
}
}
pub(crate) fn type_expr_to_json_schema(ty: &TypeExpr) -> Value {
match ty {
TypeExpr::Primitive(name) => primitive_schema(name),
TypeExpr::Literal(value) => serde_json::json!({"const": value}),
TypeExpr::Array(item) => serde_json::json!({
"type": "array",
"items": type_expr_to_json_schema(item),
}),
TypeExpr::Union(members) => union_schema(members),
TypeExpr::Intersection(members) => serde_json::json!({
"allOf": members.iter().map(type_expr_to_json_schema).collect::<Vec<_>>(),
}),
TypeExpr::Object(fields) => object_schema(fields),
TypeExpr::Ref(name) => serde_json::json!({
"$ref": format!("#/components/schemas/{name}"),
}),
TypeExpr::Unknown => Value::Object(Map::new()),
}
}
fn primitive_schema(name: &str) -> Value {
match name {
"any" | "unknown" | "void" => Value::Object(Map::new()),
"number" | "integer" | "string" | "boolean" | "null" | "object" | "array" => {
serde_json::json!({"type": name})
}
"bool" => serde_json::json!({"type": "boolean"}),
"int" => serde_json::json!({"type": "integer"}),
"float" => serde_json::json!({"type": "number"}),
other => serde_json::json!({"type": other}),
}
}
fn union_schema(members: &[TypeExpr]) -> Value {
if members
.iter()
.all(|member| matches!(member, TypeExpr::Literal(_)))
{
let values: Vec<Value> = members
.iter()
.filter_map(|member| match member {
TypeExpr::Literal(value) => Some(value.clone()),
_ => None,
})
.collect();
return serde_json::json!({"enum": values});
}
serde_json::json!({
"anyOf": members.iter().map(type_expr_to_json_schema).collect::<Vec<_>>(),
})
}
fn object_schema(fields: &[ObjectField]) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for field in fields {
let mut schema = type_expr_to_json_schema(&field.ty);
annotate(
&mut schema,
field.description.as_deref().unwrap_or_default(),
field.default.as_ref(),
&field.examples,
);
properties.insert(field.name.clone(), schema);
if field.required {
required.push(Value::String(field.name.clone()));
}
}
let mut object = Map::new();
object.insert("type".to_string(), Value::String("object".to_string()));
object.insert("properties".to_string(), Value::Object(properties));
if !required.is_empty() {
object.insert("required".to_string(), Value::Array(required));
}
Value::Object(object)
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(schema: serde_json::Value) -> serde_json::Value {
let mut registry = super::super::components::ComponentRegistry::default();
let ty =
super::super::json_schema::json_schema_to_type_expr(&schema, &schema, &mut registry);
type_expr_to_json_schema(&ty)
}
#[test]
fn preserves_optional_properties_the_caller_never_used() {
let declared = serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"end_line": {"type": "integer"},
},
"required": ["path"],
});
let rendered = round_trip(declared);
assert!(rendered["properties"].get("end_line").is_some());
assert_eq!(rendered["required"], serde_json::json!(["path"]));
}
#[test]
fn renders_a_literal_union_as_an_enum() {
let rendered = round_trip(serde_json::json!({"enum": ["a", "b"]}));
assert_eq!(rendered, serde_json::json!({"enum": ["a", "b"]}));
}
#[test]
fn renders_arrays_with_their_item_type() {
let rendered = round_trip(serde_json::json!({
"type": "array",
"items": {"type": "string"},
}));
assert_eq!(
rendered,
serde_json::json!({"type": "array", "items": {"type": "string"}})
);
}
#[test]
fn catalog_row_round_trips_through_its_serialized_form() {
let tool = ToolSchema {
name: "read_file".to_string(),
description: "Read a file.".to_string(),
params: vec![ToolParamSchema {
name: "path".to_string(),
ty: TypeExpr::Primitive("string".to_string()),
description: "File to read.".to_string(),
required: true,
default: None,
examples: Vec::new(),
}],
compact: false,
};
let row = serde_json::to_value(&tool).unwrap();
let rendered = function_schema_from_catalog_row(&row).expect("catalog row converts");
assert_eq!(rendered["function"]["name"], serde_json::json!("read_file"));
assert_eq!(
rendered["function"]["parameters"]["properties"]["path"]["type"],
serde_json::json!("string")
);
assert_eq!(
rendered["function"]["parameters"]["required"],
serde_json::json!(["path"])
);
}
#[test]
fn rejects_a_row_that_is_not_a_canonical_schema() {
assert!(function_schema_from_catalog_row(&serde_json::json!({"nope": 1})).is_none());
}
}