1//! The LLM that sits between a visitor and Jev: what it is told, the tools it 2//! is given, and what it wrote back. 3//! 4//! Jev cannot write text. It judges a question whose answer space somebody 5//! has already written down. The LLM's whole job is to write that question: 6//! one of three tool calls, one per Jev type. Nothing here does I/O; the 7//! Worker sends [`request`] through the Workers AI binding and the eval sends 8//! it over REST, and both hand the reply to [`parse`]. 9#![forbid(unsafe_code)] 10 11use rules::Want; 12use serde::{Deserialize, Serialize}; 13use serde_json::{Value, json}; 14 15mod models; 16pub use models::{CANDIDATES, FREE_NEURONS_PER_DAY, Model}; 17 18/// The most tool calls one input may become. More are dropped, not run. 19pub const MAX_CALLS: usize = 4; 20/// The most options a Choice may list: enough for a real field, few enough to 21/// read as bars on a phone. Jev itself takes up to 255. 22pub const MAX_OPTIONS: usize = 8; 23/// A Score's levels, as Jev takes them. 24pub const SCORE_LEVELS: std::ops::RangeInclusive<usize> = 2..=10; 25/// The reply's token ceiling, and so the worst case a call can cost. 26pub const MAX_TOKENS: u32 = 1200; 27 28const SYSTEM: &str = "\ 29You sit between a person and Jev. Jev is a model that cannot write text. It only judges, in three ways: 30- jev_noul: a yes-or-no question, answered with the probability of yes. 31- jev_choice: one of several options that you list, answered with a probability for each. 32- jev_score: a position on a scale whose levels you write, lowest first. 33 34Turn the person's input into the tool call that answers it. 35- Always call a tool. Never answer the question yourself, and write no other text. 36- A yes-or-no question becomes jev_noul. So does \"how likely is X\": ask whether X, and the probability is the answer. 37- \"Which\", \"who\", \"what is the best\" and other questions with a best answer become jev_choice. \ 38You choose 2 to 8 real, specific options and give each a one-line description. 39- \"How good\", \"how much\", \"how spicy\", \"rate this\" become jev_score with 3 to 7 levels, lowest first. 40- Use one call. Use more only when the input plainly asks several separate things, and never more than 4. 41- Write `instructions` as one clear question. Jev sees the person's input beside it, and nothing else."; 42 43/// The three tools, in the OpenAI function format Workers AI takes. 44fn tools() -> Value { 45 let text = |description: &str| json!({ "type": "string", "description": description }); 46 let tool = |name: &str, description: &str, properties: Value, required: &[&str]| { 47 json!({ 48 "type": "function", 49 "function": { 50 "name": name, 51 "description": description, 52 "parameters": { "type": "object", "properties": properties, "required": required }, 53 }, 54 }) 55 }; 56 json!([ 57 tool( 58 "jev_noul", 59 "Ask Jev a yes-or-no question. Jev answers with the probability that the answer is yes.", 60 json!({ 61 "instructions": text("The yes-or-no question."), 62 "yes_means": text("What a yes means, in one sentence."), 63 "no_means": text("What a no means, in one sentence."), 64 }), 65 &["instructions", "yes_means", "no_means"], 66 ), 67 tool( 68 "jev_choice", 69 "Ask Jev to pick one of several options. Jev answers with a probability for every option.", 70 json!({ 71 "instructions": text("The question the options answer."), 72 "options": { 73 "type": "array", 74 "description": "2 to 8 options. Labels are short and all different.", 75 "items": { 76 "type": "object", 77 "properties": { 78 "label": text("The option's short name."), 79 "description": text("One line on what this option is."), 80 }, 81 "required": ["label", "description"], 82 }, 83 }, 84 }), 85 &["instructions", "options"], 86 ), 87 tool( 88 "jev_score", 89 "Ask Jev to place the input on a scale. Jev answers with a score and a probability for every level.", 90 json!({ 91 "instructions": text("What is being scored."), 92 "levels": { 93 "type": "array", 94 "description": "2 to 10 levels, lowest first. Each is one line saying what that level means.", 95 "items": { "type": "string" }, 96 }, 97 }), 98 &["instructions", "levels"], 99 ), 100 ]) 101} 102 103/// The tools the LLM may call for `wants`: every one when the input is 104/// several questions (what each one is, is the LLM's to work out), and 105/// otherwise only the kinds the rules settled on. `None` is every tool. 106fn settled(wants: &[Want]) -> Option<Vec<&'static str>> { 107 if wants.is_empty() || wants.contains(&Want::Split) { 108 return None; 109 } 110 Some( 111 wants 112 .iter() 113 .map(|want| match want { 114 Want::Options => "jev_choice", 115 Want::Scale => "jev_score", 116 Want::Split => unreachable!("ruled out above"), 117 }) 118 .collect(), 119 ) 120} 121 122/// The LLM's instructions when the rules have settled what the input is. 123/// It is told about, and given, only the tools for that: Jev has already 124/// answered the other readings itself, and a second answer to one of them 125/// from here would only disagree with the first (2026-10-02: "what are the 126/// chances that…" got a yes-or-no from Jev and another, with a different 127/// probability, from a `jev_noul` the LLM wrote when a scale was wanted). 128fn settled_system(tools: &[&str]) -> String { 129 let has = |tool: &str| tools.contains(&tool); 130 let mut text = String::from( 131 "You sit between a person and Jev. Jev is a model that cannot write text. It only judges. \ 132 For this input, like this:\n", 133 ); 134 if has("jev_choice") { 135 text.push_str("- jev_choice: one of several options that you list, answered with a probability for each.\n"); 136 } 137 if has("jev_score") { 138 text.push_str("- jev_score: a position on a scale whose levels you write, lowest first.\n"); 139 } 140 text.push_str(match (has("jev_choice"), has("jev_score")) { 141 (true, true) => { 142 "\nThe person's input has already been read two ways: as a pick among possibilities, and as a \ 143 how-much question. Write exactly two tool calls, one jev_choice and one jev_score.\n" 144 } 145 (true, false) => { 146 "\nThe person's input has already been read as a pick among possibilities. Write exactly one \ 147 jev_choice call.\n" 148 } 149 _ => "\nThe person's input has already been read as a how-much question. Write exactly one jev_score call.\n", 150 }); 151 text.push_str("- Always call a tool. Never answer the question yourself, and write no other text.\n"); 152 if has("jev_choice") { 153 text.push_str("- For jev_choice, choose 2 to 8 real, specific options and give each a one-line description.\n"); 154 } 155 if has("jev_score") { 156 text.push_str( 157 "- For jev_score, write 3 to 7 levels, lowest first, that fit what is asked: its own units, ranges \ 158 or named grades where it has them, and levels of likelihood where it asks how likely.\n", 159 ); 160 } 161 text.push_str("- Write `instructions` as one clear question. Jev sees the person's input beside it, and nothing else."); 162 text 163} 164 165/// Whether a question the LLM wrote is of a kind it was asked for. One that 166/// is not, is not sent: Jev has answered that reading already, or the rules 167/// did not take it. 168pub fn takes(wants: &[Want], draft: &Draft) -> bool { 169 settled(wants).is_none_or(|tools| tools.contains(&draft.tool())) 170} 171 172/// The request body for `input`, as JSON text. The same bytes go to the 173/// binding and to the page's tool call panel. `wants` is what the rules say 174/// the LLM is to write ([`rules::Network::wants`]): sorted, no repeats. 175pub fn request(input: &str, wants: &[Want]) -> String { 176 let (system, tools) = match settled(wants) { 177 Some(names) => { 178 let given: Vec<Value> = tools() 179 .as_array() 180 .into_iter() 181 .flatten() 182 .filter(|tool| names.iter().any(|name| tool["function"]["name"] == *name)) 183 .cloned() 184 .collect(); 185 (settled_system(&names), Value::Array(given)) 186 } 187 None => (SYSTEM.to_owned(), tools()), 188 }; 189 json!({ 190 "messages": [ 191 { "role": "system", "content": system }, 192 { "role": "user", "content": input }, 193 ], 194 "tools": tools, 195 "max_tokens": MAX_TOKENS, 196 "temperature": 0, 197 }) 198 .to_string() 199} 200 201/// A question for Jev as the LLM wrote it, checked for shape. 202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 203pub enum Draft { 204 Noul { instructions: String, yes_means: String, no_means: String }, 205 Choice { instructions: String, options: Vec<Opt> }, 206 Score { instructions: String, levels: Vec<String> }, 207} 208 209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 210#[serde(deny_unknown_fields)] 211pub struct Opt { 212 pub label: String, 213 pub description: String, 214} 215 216impl Draft { 217 /// The tool that was called, which is also the Jev type. 218 pub fn tool(&self) -> &'static str { 219 match self { 220 Draft::Noul { .. } => "jev_noul", 221 Draft::Choice { .. } => "jev_choice", 222 Draft::Score { .. } => "jev_score", 223 } 224 } 225 226 pub fn instructions(&self) -> &str { 227 match self { 228 Draft::Noul { instructions, .. } 229 | Draft::Choice { instructions, .. } 230 | Draft::Score { instructions, .. } => instructions, 231 } 232 } 233} 234 235/// One tool call from the reply. 236#[derive(Clone, Debug, PartialEq)] 237pub struct ToolCall { 238 pub name: String, 239 /// The arguments exactly as the model wrote them. 240 pub arguments: String, 241 /// The question they describe, or why they do not describe one. 242 pub draft: Result<Draft, String>, 243} 244 245/// What a reply cost, as the API reported it. 246#[derive(Clone, Copy, Debug, Default, PartialEq)] 247pub struct Usage { 248 pub prompt_tokens: u64, 249 pub completion_tokens: u64, 250 /// Cloudflare's own count, when the reply carries one. 251 pub neurons: Option<f64>, 252} 253 254#[derive(Clone, Debug, PartialEq)] 255pub struct Reply { 256 /// At most [`MAX_CALLS`], in the order the model made them. 257 pub calls: Vec<ToolCall>, 258 /// How many calls the model made past the cap. They were dropped. 259 pub dropped: usize, 260 pub usage: Usage, 261} 262 263/// Reads a chat-completion reply: the binding's own, or REST's, which wraps 264/// it in `result`. An `Err` is a reply with no usable shape at all; a reply 265/// whose calls are malformed is `Ok`, with the reason on each call. 266pub fn parse(body: &str) -> Result<Reply, String> { 267 let root: Value = serde_json::from_str(body).map_err(|e| format!("the reply is not JSON: {e}"))?; 268 let root = root.get("result").unwrap_or(&root); 269 let message = root 270 .pointer("/choices/0/message") 271 .ok_or_else(|| "the reply has no choices[0].message".to_owned())?; 272 let made: &[Value] = message.get("tool_calls").and_then(Value::as_array).map_or(&[], Vec::as_slice); 273 let calls = made.iter().take(MAX_CALLS).map(tool_call).collect(); 274 let usage = root.get("usage"); 275 let count = |name: &str| usage.and_then(|u| u.get(name)).and_then(Value::as_u64).unwrap_or(0); 276 Ok(Reply { 277 calls, 278 dropped: made.len().saturating_sub(MAX_CALLS), 279 usage: Usage { 280 prompt_tokens: count("prompt_tokens"), 281 completion_tokens: count("completion_tokens"), 282 neurons: usage.and_then(|u| u.get("neurons")).and_then(Value::as_f64), 283 }, 284 }) 285} 286 287fn tool_call(call: &Value) -> ToolCall { 288 let name = call.pointer("/function/name").and_then(Value::as_str).unwrap_or_default().to_owned(); 289 let arguments = match call.pointer("/function/arguments") { 290 Some(Value::String(text)) => text.clone(), 291 Some(other) => other.to_string(), 292 None => String::new(), 293 }; 294 let draft = draft(&name, &arguments); 295 ToolCall { name, arguments, draft } 296} 297 298/// The arguments as an object. The format says `arguments` is JSON text of an 299/// object. One model (granite-4.0-h-micro, measured 2026-10-02) encodes that 300/// text a second time, so a string that decodes to a string is decoded once 301/// more. Nothing else is repaired. 302fn object(arguments: &str) -> Result<Value, String> { 303 let mut value: Value = 304 serde_json::from_str(arguments).map_err(|e| format!("the arguments are not JSON: {e}"))?; 305 if let Value::String(inner) = &value { 306 value = serde_json::from_str(inner).map_err(|e| format!("the arguments are not JSON: {e}"))?; 307 } 308 if value.is_object() { Ok(value) } else { Err("the arguments are not an object".to_owned()) } 309} 310 311fn draft(name: &str, arguments: &str) -> Result<Draft, String> { 312 #[derive(Deserialize)] 313 #[serde(deny_unknown_fields)] 314 struct NoulArgs { 315 instructions: String, 316 yes_means: String, 317 no_means: String, 318 } 319 #[derive(Deserialize)] 320 #[serde(deny_unknown_fields)] 321 struct ChoiceArgs { 322 instructions: String, 323 options: Vec<Opt>, 324 } 325 #[derive(Deserialize)] 326 #[serde(deny_unknown_fields)] 327 struct ScoreArgs { 328 instructions: String, 329 levels: Vec<String>, 330 } 331 fn read<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, String> { 332 serde_json::from_value(value).map_err(|e| e.to_string()) 333 } 334 let filled = |what: &str, text: &str| { 335 if text.trim().is_empty() { Err(format!("{what} is empty")) } else { Ok(()) } 336 }; 337 338 let value = object(arguments)?; 339 match name { 340 "jev_noul" => { 341 let NoulArgs { instructions, yes_means, no_means } = read(value)?; 342 filled("instructions", &instructions)?; 343 filled("yes_means", &yes_means)?; 344 filled("no_means", &no_means)?; 345 Ok(Draft::Noul { instructions, yes_means, no_means }) 346 } 347 "jev_choice" => { 348 let ChoiceArgs { instructions, options } = read(value)?; 349 filled("instructions", &instructions)?; 350 if !(2..=MAX_OPTIONS).contains(&options.len()) { 351 return Err(format!("a choice takes 2 to {MAX_OPTIONS} options, not {}", options.len())); 352 } 353 for (i, option) in options.iter().enumerate() { 354 filled("an option's label", &option.label)?; 355 if options[..i].iter().any(|earlier| earlier.label == option.label) { 356 return Err(format!("the option {:?} appears twice", option.label)); 357 } 358 } 359 Ok(Draft::Choice { instructions, options }) 360 } 361 "jev_score" => { 362 let ScoreArgs { instructions, levels } = read(value)?; 363 filled("instructions", &instructions)?; 364 if !SCORE_LEVELS.contains(&levels.len()) { 365 return Err(format!( 366 "a score takes {} to {} levels, not {}", 367 SCORE_LEVELS.start(), 368 SCORE_LEVELS.end(), 369 levels.len() 370 )); 371 } 372 for level in &levels { 373 filled("a level", level)?; 374 } 375 Ok(Draft::Score { instructions, levels }) 376 } 377 other => Err(format!("there is no tool called {other:?}")), 378 } 379} 380 381#[cfg(test)] 382mod tests;