1use anyhow::{bail, Context, Result};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use sha2::{Digest, Sha256};
18
19use crate::render::LocalRenderer;
20use crate::wire::{to_python_json, RowOption};
21#[cfg(feature = "local-tokenizer")]
22use crate::tokenizer::LocalTokenizer;
23
24pub const LETTERS: &str = "ABCDEFGHIJKLMNOP";
26pub const DIRECT_SYSTEM: &str = "Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. Respond with only its uppercase letter, with no explanation or reasoning.";
28pub const PROMPT_VERSION: &str = "direct-options-v1";
30
31#[derive(Debug, Clone, Serialize)]
33pub struct ChatMessage {
34 pub role: &'static str,
36 pub content: String,
38}
39
40pub fn direct_messages(state: &Value, question: &str, options: &[RowOption]) -> Vec<ChatMessage> {
45 let entries: Vec<Value> = options
46 .iter()
47 .enumerate()
48 .map(|(index, option)| {
49 json!({
50 "letter": &LETTERS[index..index + 1],
51 "description": option.description,
52 })
53 })
54 .collect();
55 let payload = json!({
56 "evidence": state,
57 "criterion": question,
58 "options": entries,
59 });
60 vec![
61 ChatMessage {
62 role: "system",
63 content: DIRECT_SYSTEM.to_string(),
64 },
65 ChatMessage {
66 role: "user",
67 content: to_python_json(&payload),
68 },
69 ]
70}
71
72pub fn prompt_sha256(prompt: &str) -> String {
77 let mut hasher = Sha256::new();
78 hasher.update(prompt.as_bytes());
79 format!("{:x}", hasher.finalize())
80}
81
82pub fn softmax(values: &[f64]) -> Vec<f64> {
84 let maximum = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
85 let weights: Vec<f64> = values.iter().map(|value| (value - maximum).exp()).collect();
86 let total: f64 = weights.iter().sum();
87 weights.iter().map(|weight| weight / total).collect()
88}
89
90#[derive(Debug, Serialize)]
92struct ApplyTemplateRequest<'a> {
93 model: &'a str,
94 messages: &'a [ChatMessage],
95 chat_template_kwargs: &'a Value,
97}
98
99#[derive(Debug, Deserialize)]
100struct ApplyTemplateResponse {
101 prompt: String,
102}
103
104#[derive(Debug, Serialize)]
106struct TokenizeRequest<'a> {
107 model: &'a str,
108 content: &'a str,
109 add_special: bool,
111}
112
113#[derive(Debug, Deserialize)]
114struct TokenizeResponse {
115 tokens: Vec<u32>,
116}
117
118#[derive(Debug, Serialize)]
119struct DetokenizeRequest<'a> {
120 model: &'a str,
121 tokens: &'a [u32],
122}
123
124#[derive(Debug, Deserialize)]
125struct DetokenizeResponse {
126 content: String,
127}
128
129pub struct RuntimeClient {
135 client: reqwest::Client,
136 base: String,
137 model: String,
138 api_key: Option<String>,
139}
140
141impl RuntimeClient {
142 pub fn new(
144 client: reqwest::Client,
145 base: impl Into<String>,
146 model: impl Into<String>,
147 api_key: Option<String>,
148 ) -> Self {
149 Self {
150 client,
151 base: base.into().trim_end_matches('/').to_string(),
152 model: model.into(),
153 api_key,
154 }
155 }
156
157 async fn post<T: serde::de::DeserializeOwned>(
158 &self,
159 path: &str,
160 body: &(impl serde::Serialize + ?Sized),
161 ) -> Result<T> {
162 let url = format!("{}{path}", self.base);
163 let mut request = self.client.post(&url).json(body);
164 if let Some(key) = &self.api_key {
165 request = request.bearer_auth(key);
166 }
167 let response = request
168 .send()
169 .await
170 .with_context(|| format!("POST {url} failed"))?;
171 let status = response.status();
172 let text = response
173 .text()
174 .await
175 .with_context(|| format!("reading {url} response failed"))?;
176 if !status.is_success() {
177 bail!("POST {url} returned HTTP {status}: {}", truncate(&text));
178 }
179 serde_json::from_str(&text)
180 .with_context(|| format!("POST {url} returned an unexpected body: {}", truncate(&text)))
181 }
182
183 pub async fn apply_template(
185 &self,
186 messages: &[ChatMessage],
187 chat_template_kwargs: &Value,
188 ) -> Result<String> {
189 let body = ApplyTemplateRequest {
190 model: &self.model,
191 messages,
192 chat_template_kwargs,
193 };
194 let response: ApplyTemplateResponse = self
195 .post("/apply-template", &body)
196 .await
197 .context("rendering the chat template on the serving runtime failed")?;
198 Ok(response.prompt)
199 }
200
201 pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
203 let body = TokenizeRequest {
204 model: &self.model,
205 content,
206 add_special: false,
207 };
208 let response: TokenizeResponse = self.post("/tokenize", &body).await?;
209 Ok(response.tokens)
210 }
211
212 pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
214 let body = DetokenizeRequest {
215 model: &self.model,
216 tokens,
217 };
218 let response: DetokenizeResponse = self.post("/detokenize", &body).await?;
219 Ok(response.content)
220 }
221}
222
223#[derive(Default)]
229pub struct LocalComponents {
230 pub renderer: Option<LocalRenderer>,
232 #[cfg(feature = "local-tokenizer")]
233 pub tokenizer: Option<LocalTokenizer>,
235}
236
237pub struct ChatTemplate {
239 runtime: RuntimeClient,
240 local: LocalComponents,
241 chat_template_kwargs: Value,
242}
243
244impl ChatTemplate {
245 pub fn new(runtime: RuntimeClient, local: LocalComponents, chat_template_kwargs: Value) -> Self {
247 Self {
248 runtime,
249 local,
250 chat_template_kwargs,
251 }
252 }
253
254 pub fn renders_locally(&self) -> bool {
256 self.local.renderer.is_some()
257 }
258
259 pub fn tokenizes_locally(&self) -> bool {
261 #[cfg(feature = "local-tokenizer")]
262 {
263 self.local.tokenizer.is_some()
264 }
265 #[cfg(not(feature = "local-tokenizer"))]
266 {
267 false
268 }
269 }
270
271 pub async fn render(&self, messages: &[ChatMessage]) -> Result<String> {
273 match &self.local.renderer {
274 Some(local) => local.render(messages, &self.chat_template_kwargs),
275 None => {
276 self.runtime
277 .apply_template(messages, &self.chat_template_kwargs)
278 .await
279 }
280 }
281 }
282
283 pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
285 #[cfg(feature = "local-tokenizer")]
286 if let Some(local) = &self.local.tokenizer {
287 return local.tokenize(content);
288 }
289 self.runtime.tokenize(content).await
290 }
291
292 pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
294 #[cfg(feature = "local-tokenizer")]
295 if let Some(local) = &self.local.tokenizer {
296 return local.detokenize(tokens);
297 }
298 self.runtime.detokenize(tokens).await
299 }
300
301 pub async fn resolve_slots(&self) -> Result<Vec<u32>> {
306 let mut slots = Vec::with_capacity(LETTERS.len());
307 for letter in LETTERS.chars() {
308 let ids = self.tokenize(&letter.to_string()).await?;
309 if ids.len() != 1 {
310 bail!(
311 "answer slot {letter:?} is not a single token ({} tokens); this model cannot \
312 serve the direct option-logit contract",
313 ids.len()
314 );
315 }
316 slots.push(ids[0]);
317 }
318 let mut unique = slots.clone();
319 unique.sort_unstable();
320 unique.dedup();
321 if unique.len() != slots.len() {
322 bail!("answer-slot tokens collide");
323 }
324 let decoded = self
325 .detokenize(&slots)
326 .await
327 .context("verifying answer-slot round trip failed")?;
328 if decoded != LETTERS {
329 bail!("answer slots decode to {decoded:?} instead of {LETTERS:?}");
330 }
331 Ok(slots)
332 }
333
334 pub async fn verify_boundary(&self, prompt: &str, slots: &[u32]) -> Result<()> {
339 let base = self.tokenize(prompt).await?;
340 if base.is_empty() {
341 bail!("rendered prompt is empty");
342 }
343 for (index, letter) in LETTERS.chars().enumerate() {
344 let combined = self.tokenize(&format!("{prompt}{letter}")).await?;
345 let mut expected = base.clone();
346 expected.push(slots[index]);
347 if combined != expected {
348 bail!(
349 "answer boundary changes tokenization for slot {letter:?}: the prompt does \
350 not end in a position where {letter:?} stays one token"
351 );
352 }
353 }
354 Ok(())
355 }
356
357 pub fn chat_template_kwargs(&self) -> Value {
359 self.chat_template_kwargs.clone()
360 }
361}
362
363fn truncate(text: &str) -> String {
364 let mut owned = text.chars().take(400).collect::<String>();
365 if text.chars().count() > 400 {
366 owned.push('…');
367 }
368 owned
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use serde_json::Map;
375
376 fn options() -> Vec<RowOption> {
377 vec![
378 RowOption {
379 id: "billing".into(),
380 description: "Billing and refunds.".into(),
381 },
382 RowOption {
383 id: "sales".into(),
384 description: "Pricing and contracts.".into(),
385 },
386 ]
387 }
388
389 #[test]
390 fn payload_matches_python_json_layout() {
391 let messages = direct_messages(&json!("charged twice"), "Which queue?", &options());
392 let user = messages[1].content.as_str();
393 assert_eq!(
394 user,
395 r#"{"evidence": "charged twice", "criterion": "Which queue?", "options": [{"letter": "A", "description": "Billing and refunds."}, {"letter": "B", "description": "Pricing and contracts."}]}"#
396 );
397 assert_eq!(messages[0].content, DIRECT_SYSTEM);
398 }
399
400 #[test]
401 fn structured_state_is_embedded_as_json() {
402 let messages = direct_messages(&json!({"message": "hi", "count": 2}), "q", &options());
403 let user = messages[1].content.as_str();
404 assert!(
405 user.contains(r#""evidence": {"message": "hi", "count": 2}"#),
406 "{user}"
407 );
408 }
409
410 #[test]
411 fn softmax_is_stable_for_large_magnitudes() {
412 let probabilities = softmax(&[-1000.0, -1001.0]);
413 assert!((probabilities[0] - 0.7310585786300049).abs() < 1e-12);
414 assert!((probabilities.iter().sum::<f64>() - 1.0).abs() < 1e-12);
415 }
416
417 #[test]
418 fn prompt_hash_is_stable_and_distinguishing() {
419 assert_eq!(prompt_sha256("abc").len(), 64);
420 assert_ne!(prompt_sha256("abc"), prompt_sha256("abd"));
421 }
422
423 #[test]
424 fn softmax_matches_the_measured_llamacpp_distribution() {
425 let probabilities = softmax(&[-12.320333, -0.0000213, -15.617624]);
428 assert!(probabilities[1] > 0.9999, "{probabilities:?}");
429 assert!(probabilities[0] < 1e-5);
430 }
431
432 #[tokio::test]
433 async fn a_local_renderer_takes_precedence_over_the_runtime() {
434 let runtime = RuntimeClient::new(
435 reqwest::Client::new(),
436 "http://127.0.0.1:1".to_string(),
437 "unreachable".to_string(),
438 None,
439 );
440 let local = LocalRenderer::new(
441 "local:{{ messages[0].content }}:{{ enable_thinking }}".to_string(),
442 Map::new(),
443 )
444 .unwrap();
445 let template = ChatTemplate::new(
446 runtime,
447 LocalComponents {
448 renderer: Some(local),
449 ..Default::default()
450 },
451 json!({"enable_thinking": false}),
452 );
453
454 assert!(template.renders_locally());
455 let messages = direct_messages(&json!("state"), "question", &options());
456 let rendered = template.render(&messages).await.unwrap();
458 assert!(rendered.starts_with("local:"), "{rendered}");
459 assert!(rendered.ends_with(":False"), "{rendered}");
460 }
461}