1use std::collections::BTreeMap;
17
18use ingot_ir::RecordType;
19use serde_json::{json, Value};
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum ResponseShape {
24 Prose,
26 FreeJson,
29 Schema {
31 schema: Value,
33 wrapped: bool,
36 },
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct UnsupportedResponseType {
42 pub ty: String,
43 pub reason: &'static str,
44}
45
46pub fn response_shape(
48 ty: &str,
49 types: &BTreeMap<String, RecordType>,
50) -> Result<ResponseShape, UnsupportedResponseType> {
51 match ty {
52 "text" | "markdown" => return Ok(ResponseShape::Prose),
53 "json" => return Ok(ResponseShape::FreeJson),
54 "bytes" | "file" => {
55 return Err(UnsupportedResponseType {
56 ty: ty.to_string(),
57 reason: "a model cannot produce binary content directly; \
58 use a tool that writes the file and return its handle",
59 })
60 }
61 _ => {}
62 }
63
64 let schema = type_schema(ty, types)?;
65 let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
66 if is_object {
67 Ok(ResponseShape::Schema {
68 schema,
69 wrapped: false,
70 })
71 } else {
72 Ok(ResponseShape::Schema {
73 schema: json!({
74 "type": "object",
75 "properties": { "value": schema },
76 "required": ["value"],
77 "additionalProperties": false,
78 }),
79 wrapped: true,
80 })
81 }
82}
83
84pub fn type_schema(
86 ty: &str,
87 types: &BTreeMap<String, RecordType>,
88) -> Result<Value, UnsupportedResponseType> {
89 if let Some(element) = ty.strip_suffix("[]") {
90 return Ok(json!({ "type": "array", "items": type_schema(element, types)? }));
91 }
92
93 if matches!(ty, "bytes" | "file") {
96 return Err(UnsupportedResponseType {
97 ty: ty.to_string(),
98 reason: "a model cannot produce binary content directly; \
99 use a tool that writes the file and return its handle",
100 });
101 }
102
103 let scalar = match ty {
104 "string" | "text" | "markdown" => Some(json!({ "type": "string" })),
105 "int" => Some(json!({ "type": "integer" })),
106 "float" => Some(json!({ "type": "number" })),
107 "bool" => Some(json!({ "type": "boolean" })),
108 "json" => Some(json!({})),
111 _ => None,
112 };
113 if let Some(scalar) = scalar {
114 return Ok(scalar);
115 }
116
117 let Some(record) = types.get(ty) else {
118 return Err(UnsupportedResponseType {
119 ty: ty.to_string(),
120 reason: "not a known type; the artifact does not declare this record",
121 });
122 };
123
124 let mut properties = serde_json::Map::new();
125 let mut required = Vec::new();
126 for field in &record.fields {
127 properties.insert(field.name.clone(), type_schema(&field.ty, types)?);
128 required.push(Value::String(field.name.clone()));
129 }
130 Ok(json!({
131 "type": "object",
132 "properties": Value::Object(properties),
133 "required": Value::Array(required),
134 "additionalProperties": false,
137 }))
138}
139
140pub const FILE_HANDLE_FIELD: &str = "path";
147
148pub fn validate(
154 value: &Value,
155 ty: &str,
156 types: &BTreeMap<String, RecordType>,
157) -> Result<(), String> {
158 if let Some(element) = ty.strip_suffix("[]") {
159 let Some(items) = value.as_array() else {
160 return Err(format!("expected `{ty}`, found {}", describe(value)));
161 };
162 for (index, item) in items.iter().enumerate() {
163 validate(item, element, types).map_err(|error| format!("at index {index}: {error}"))?;
164 }
165 return Ok(());
166 }
167
168 if ty == "file" {
169 return validate_file(value);
170 }
171
172 let ok = match ty {
173 "string" | "text" | "markdown" => value.is_string(),
174 "int" => value.is_i64() || value.is_u64(),
175 "float" => value.is_number(),
176 "bool" => value.is_boolean(),
177 "bytes" => value.is_string(),
180 "json" => true,
181 _ => {
182 let Some(record) = types.get(ty) else {
183 return Err(format!("unknown type `{ty}`"));
184 };
185 let Some(object) = value.as_object() else {
186 return Err(format!("expected `{ty}`, found {}", describe(value)));
187 };
188 for field in &record.fields {
189 let Some(field_value) = object.get(&field.name) else {
190 return Err(format!("`{ty}` is missing field `{}`", field.name));
191 };
192 validate(field_value, &field.ty, types)
193 .map_err(|error| format!("in field `{}`: {error}", field.name))?;
194 }
195 return Ok(());
196 }
197 };
198
199 if ok {
200 Ok(())
201 } else {
202 Err(format!("expected `{ty}`, found {}", describe(value)))
203 }
204}
205
206fn validate_file(value: &Value) -> Result<(), String> {
207 let Some(object) = value.as_object() else {
208 return Err(format!(
209 "expected `file` (an object with a `{FILE_HANDLE_FIELD}`), found {}",
210 describe(value)
211 ));
212 };
213 match object.get(FILE_HANDLE_FIELD) {
214 Some(Value::String(_)) => Ok(()),
215 Some(other) => Err(format!(
216 "`file` has a `{FILE_HANDLE_FIELD}` that is {}, and it must be a string",
217 describe(other)
218 )),
219 None => Err(format!("`file` is missing field `{FILE_HANDLE_FIELD}`")),
220 }
221}
222
223fn describe(value: &Value) -> &'static str {
224 match value {
225 Value::Null => "null",
226 Value::Bool(_) => "a boolean",
227 Value::Number(_) => "a number",
228 Value::String(_) => "a string",
229 Value::Array(_) => "an array",
230 Value::Object(_) => "an object",
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use ingot_ir::FieldType;
238
239 fn record_types() -> BTreeMap<String, RecordType> {
240 [(
241 "search_result".to_string(),
242 RecordType {
243 fields: vec![
244 FieldType {
245 name: "title".into(),
246 ty: "string".into(),
247 },
248 FieldType {
249 name: "score".into(),
250 ty: "int".into(),
251 },
252 ],
253 },
254 )]
255 .into_iter()
256 .collect()
257 }
258
259 #[test]
260 fn prose_types_are_not_constrained() {
261 let types = BTreeMap::new();
262 assert_eq!(
263 response_shape("markdown", &types).unwrap(),
264 ResponseShape::Prose
265 );
266 assert_eq!(
267 response_shape("text", &types).unwrap(),
268 ResponseShape::Prose
269 );
270 }
271
272 #[test]
273 fn scalars_and_lists_are_wrapped() {
274 let types = BTreeMap::new();
275 let ResponseShape::Schema { schema, wrapped } = response_shape("string[]", &types).unwrap()
276 else {
277 panic!("expected a constrained shape");
278 };
279 assert!(wrapped);
280 assert_eq!(schema["properties"]["value"]["type"], "array");
281 assert_eq!(schema["properties"]["value"]["items"]["type"], "string");
282 }
283
284 #[test]
285 fn records_are_not_wrapped() {
286 let types = record_types();
287 let ResponseShape::Schema { schema, wrapped } =
288 response_shape("search_result", &types).unwrap()
289 else {
290 panic!("expected a constrained shape");
291 };
292 assert!(!wrapped, "an object schema is already valid at the root");
293 assert_eq!(schema["additionalProperties"], false);
294 assert_eq!(schema["required"], json!(["title", "score"]));
295 }
296
297 #[test]
298 fn binary_content_cannot_be_asked_for() {
299 let types = BTreeMap::new();
300 let error = response_shape("bytes", &types).unwrap_err();
301 assert_eq!(error.ty, "bytes");
302 }
303
304 #[test]
305 fn validation_accepts_matching_values() {
306 let types = record_types();
307 assert!(validate(&json!("hello"), "string", &types).is_ok());
308 assert!(validate(&json!([1, 2]), "int[]", &types).is_ok());
309 assert!(validate(&json!({"title": "t", "score": 3}), "search_result", &types).is_ok());
310 }
311
312 #[test]
313 fn validation_names_the_offending_field() {
314 let types = record_types();
315 let error = validate(
316 &json!({"title": "t", "score": "three"}),
317 "search_result",
318 &types,
319 )
320 .unwrap_err();
321 assert!(error.contains("score"), "{error}");
322 assert!(error.contains("expected `int`"), "{error}");
323 }
324
325 #[test]
326 fn validation_reports_the_offending_index() {
327 let types = BTreeMap::new();
328 let error = validate(&json!(["a", 2]), "string[]", &types).unwrap_err();
329 assert!(error.contains("at index 1"), "{error}");
330 }
331
332 #[test]
333 fn a_missing_field_is_reported_by_name() {
334 let types = record_types();
335 let error = validate(&json!({"title": "t"}), "search_result", &types).unwrap_err();
336 assert!(error.contains("missing field `score`"), "{error}");
337 }
338
339 #[test]
340 fn a_file_is_a_handle_with_a_path() {
341 let types = BTreeMap::new();
342 assert!(validate(&json!({"path": "out/report.md"}), "file", &types).is_ok());
343 assert!(validate(&json!({"path": "a", "bytes": 12}), "file", &types).is_ok());
345 }
346
347 #[test]
348 fn a_file_without_a_path_is_reported_as_such() {
349 let types = BTreeMap::new();
350 let error = validate(&json!({"bytes": 12}), "file", &types).unwrap_err();
351 assert!(error.contains("missing field `path`"), "{error}");
352
353 let error = validate(&json!("out/report.md"), "file", &types).unwrap_err();
356 assert!(error.contains("expected `file`"), "{error}");
357 assert!(!error.contains("unknown type"), "{error}");
358 }
359
360 #[test]
361 fn bytes_travel_as_a_base64_string() {
362 let types = BTreeMap::new();
363 assert!(validate(&json!("aGVsbG8="), "bytes", &types).is_ok());
364 assert!(validate(&json!([104, 105]), "bytes", &types).is_err());
365 }
366
367 #[test]
368 fn a_record_field_a_model_cannot_produce_says_why() {
369 let types: BTreeMap<String, RecordType> = [(
370 "attachment".to_string(),
371 RecordType {
372 fields: vec![FieldType {
373 name: "body".into(),
374 ty: "file".into(),
375 }],
376 },
377 )]
378 .into_iter()
379 .collect();
380
381 let error = response_shape("attachment", &types).unwrap_err();
382 assert_eq!(error.ty, "file");
383 assert!(error.reason.contains("tool"), "{}", error.reason);
384 }
385}