1use serde_json::{json, Value};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ParamType {
13 String,
14 Integer,
15 Number,
16 Boolean,
17 Array,
18 Object,
19}
20
21impl ParamType {
22 pub fn json_name(&self) -> &'static str {
23 match self {
24 ParamType::String => "string",
25 ParamType::Integer => "integer",
26 ParamType::Number => "number",
27 ParamType::Boolean => "boolean",
28 ParamType::Array => "array",
29 ParamType::Object => "object",
30 }
31 }
32
33 pub fn matches(&self, v: &Value) -> bool {
35 match self {
36 ParamType::String => v.is_string(),
37 ParamType::Integer => v.is_i64() || v.is_u64(),
38 ParamType::Number => v.is_number(),
39 ParamType::Boolean => v.is_boolean(),
40 ParamType::Array => v.is_array(),
41 ParamType::Object => v.is_object(),
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct ToolParameter {
49 pub name: &'static str,
50 pub ty: ParamType,
51 pub required: bool,
52 pub description: &'static str,
53 pub aliases: &'static [&'static str],
55}
56
57impl ToolParameter {
58 pub const fn new(
59 name: &'static str,
60 ty: ParamType,
61 required: bool,
62 description: &'static str,
63 aliases: &'static [&'static str],
64 ) -> Self {
65 ToolParameter {
66 name,
67 ty,
68 required,
69 description,
70 aliases,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct ToolSchema {
78 pub name: &'static str,
79 pub description: &'static str,
80 pub parameters: Vec<ToolParameter>,
81}
82
83impl ToolSchema {
84 pub fn new(
85 name: &'static str,
86 description: &'static str,
87 parameters: Vec<ToolParameter>,
88 ) -> Self {
89 ToolSchema {
90 name,
91 description,
92 parameters,
93 }
94 }
95
96 fn json_schema(&self) -> (Value, Vec<Value>) {
98 let mut props = serde_json::Map::new();
99 let mut required = Vec::new();
100 for p in &self.parameters {
101 props.insert(
102 p.name.to_string(),
103 json!({"type": p.ty.json_name(), "description": p.description}),
104 );
105 if p.required {
106 required.push(Value::String(p.name.to_string()));
107 }
108 }
109 (Value::Object(props), required)
110 }
111
112 pub fn to_openai_tool(&self) -> Value {
114 let (props, required) = self.json_schema();
115 json!({
116 "type": "function",
117 "function": {
118 "name": self.name,
119 "description": self.description,
120 "parameters": {
121 "type": "object",
122 "properties": props,
123 "required": required,
124 }
125 }
126 })
127 }
128
129 pub fn to_anthropic_tool(&self) -> Value {
131 let (props, required) = self.json_schema();
132 json!({
133 "name": self.name,
134 "description": self.description,
135 "input_schema": {
136 "type": "object",
137 "properties": props,
138 "required": required,
139 }
140 })
141 }
142
143 pub fn normalize_params(&self, input: &Value) -> Value {
145 let obj = match input.as_object() {
146 Some(o) => o,
147 None => return input.clone(),
148 };
149 let mut alias_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
151 for p in &self.parameters {
152 alias_map.insert(p.name, p.name);
153 for a in p.aliases {
154 alias_map.insert(a, p.name);
155 }
156 }
157 let mut out = serde_json::Map::new();
158 for (k, v) in obj {
159 let canonical = alias_map.get(k.as_str()).copied().unwrap_or(k.as_str());
160 out.insert(canonical.to_string(), v.clone());
161 }
162 Value::Object(out)
163 }
164
165 pub fn validate(&self, normalized: &Value) -> std::result::Result<(), String> {
168 let obj = normalized.as_object();
169 let mut missing = Vec::new();
171 for p in &self.parameters {
172 if p.required {
173 let present = obj.map(|o| o.contains_key(p.name)).unwrap_or(false);
174 if !present {
175 missing.push(p.name);
176 }
177 }
178 }
179 if !missing.is_empty() {
180 let details: Vec<String> = missing
181 .iter()
182 .map(|m| {
183 let p = self.parameters.iter().find(|p| &p.name == m).unwrap();
184 format!(" ⢠{} ({}): {}", p.name, p.ty.json_name(), p.description)
185 })
186 .collect();
187 return Err(format!(
188 "ā Missing required parameters: {}\n\nš Parameter details:\n{}",
189 missing.join(", "),
190 details.join("\n")
191 ));
192 }
193 if let Some(o) = obj {
195 for p in &self.parameters {
196 if let Some(v) = o.get(p.name) {
197 if !v.is_null() && !p.ty.matches(v) {
198 return Err(format!(
199 "Parameter '{}' expected type {}, got a different type",
200 p.name,
201 p.ty.json_name()
202 ));
203 }
204 }
205 }
206 }
207 Ok(())
208 }
209
210 pub fn documentation(&self) -> String {
212 let mut doc = format!("## {}\n\n{}\n\n### Parameters\n", self.name, self.description);
213 for p in &self.parameters {
214 let req = if p.required { "required" } else { "optional" };
215 let aliases = if p.aliases.is_empty() {
216 String::new()
217 } else {
218 format!(" (aliases: {})", p.aliases.join(", "))
219 };
220 doc.push_str(&format!(
221 "- {}{} ({}, {}): {}\n",
222 p.name,
223 aliases,
224 p.ty.json_name(),
225 req,
226 p.description
227 ));
228 }
229 doc
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn sample() -> ToolSchema {
238 ToolSchema::new(
239 "run_shell",
240 "run a shell command",
241 vec![
242 ToolParameter::new(
243 "command",
244 ParamType::String,
245 true,
246 "the command",
247 &["cmd", "shell", "script", "exec"],
248 ),
249 ToolParameter::new("timeout", ParamType::Integer, false, "seconds", &["time_limit"]),
250 ],
251 )
252 }
253
254 #[test]
255 fn openai_tool_shape() {
256 let t = sample().to_openai_tool();
257 assert_eq!(t["type"], "function");
258 assert_eq!(t["function"]["name"], "run_shell");
259 assert_eq!(t["function"]["parameters"]["properties"]["command"]["type"], "string");
260 assert_eq!(t["function"]["parameters"]["required"][0], "command");
261 }
262
263 #[test]
264 fn anthropic_tool_shape() {
265 let t = sample().to_anthropic_tool();
266 assert_eq!(t["name"], "run_shell");
267 assert_eq!(t["input_schema"]["properties"]["timeout"]["type"], "integer");
268 }
269
270 #[test]
271 fn alias_normalization() {
272 let s = sample();
273 let n = s.normalize_params(&json!({"cmd": "ls", "time_limit": 5}));
274 assert_eq!(n["command"], "ls");
275 assert_eq!(n["timeout"], 5);
276 }
277
278 #[test]
279 fn validate_missing_required() {
280 let s = sample();
281 let n = s.normalize_params(&json!({"timeout": 5}));
282 let err = s.validate(&n).unwrap_err();
283 assert!(err.contains("command"));
284 }
285
286 #[test]
287 fn validate_type_error() {
288 let s = sample();
289 let n = s.normalize_params(&json!({"command": 123}));
290 assert!(s.validate(&n).is_err());
291 }
292
293 #[test]
294 fn validate_ok() {
295 let s = sample();
296 let n = s.normalize_params(&json!({"command": "ls", "timeout": 5}));
297 assert!(s.validate(&n).is_ok());
298 }
299}