1use anyhow::{anyhow, bail, Context, Result};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Probe {
22 LogprobTokenIds,
26 AllowedTokenIdsTopK,
29 CompletionsTopK,
32 LlamaCppNProbs,
34}
35
36pub const CANDIDATES: [Probe; 4] = [
38 Probe::LogprobTokenIds,
39 Probe::AllowedTokenIdsTopK,
40 Probe::CompletionsTopK,
41 Probe::LlamaCppNProbs,
42];
43
44impl Probe {
45 pub fn name(self) -> &'static str {
47 match self {
48 Probe::LogprobTokenIds => "vllm-logprob-token-ids",
49 Probe::AllowedTokenIdsTopK => "vllm-allowed-token-ids-top-k",
50 Probe::CompletionsTopK => "openai-completions-top-k",
51 Probe::LlamaCppNProbs => "llamacpp-native-n-probs",
52 }
53 }
54
55 pub fn note(self) -> &'static str {
57 match self {
58 Probe::LogprobTokenIds => {
59 "option distribution is restricted to the declared answer tokens; exact token ids requested"
60 }
61 Probe::AllowedTokenIdsTopK => {
62 "option distribution is restricted to the declared answer tokens; top-k must cover them"
63 }
64 Probe::CompletionsTopK => {
65 "unrestricted next-token distribution; every answer token must fall inside top-k"
66 }
67 Probe::LlamaCppNProbs => {
68 "llama.cpp native completion probabilities; every answer token must fall inside n_probs"
69 }
70 }
71 }
72
73 pub fn restricts_distribution(self) -> bool {
77 matches!(self, Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK)
78 }
79
80 pub fn supports_batch(self) -> bool {
87 !matches!(self, Probe::LlamaCppNProbs)
88 }
89
90 pub fn endpoint(self) -> &'static str {
92 match self {
93 Probe::LlamaCppNProbs => "/completion",
94 _ => "/completions",
95 }
96 }
97
98 fn url(self, openai_base: &str, native_base: &str) -> String {
99 match self {
100 Probe::LlamaCppNProbs => format!("{}/completion", native_base.trim_end_matches('/')),
101 _ => format!("{}/completions", openai_base.trim_end_matches('/')),
102 }
103 }
104}
105
106fn top_k(probe: Probe, slot_count: usize) -> usize {
114 match probe {
115 Probe::LogprobTokenIds => 1,
116 Probe::AllowedTokenIdsTopK => slot_count.max(4),
117 Probe::CompletionsTopK | Probe::LlamaCppNProbs => (slot_count * 4).clamp(32, 128),
118 }
119}
120
121#[derive(Debug, Serialize)]
127#[serde(untagged)]
128pub enum PromptField {
129 Single(String),
131 Batch(Vec<String>),
133}
134
135#[derive(Debug, Serialize)]
141pub struct OpenAiCompletionRequest {
142 model: String,
143 prompt: PromptField,
144 max_tokens: u32,
146 temperature: f32,
148 logprobs: usize,
150 #[serde(skip_serializing_if = "Option::is_none")]
153 return_tokens_as_token_ids: Option<bool>,
154 #[serde(skip_serializing_if = "Option::is_none")]
157 allowed_token_ids: Option<Vec<u32>>,
158 #[serde(skip_serializing_if = "Option::is_none")]
161 logprob_token_ids: Option<Vec<u32>>,
162}
163
164#[derive(Debug, Serialize)]
171pub struct LlamaCppCompletionRequest {
172 model: String,
173 prompt: String,
174 n_predict: u32,
175 n_probs: usize,
177 cache_prompt: bool,
178 temperature: f32,
179 top_k: i32,
181 top_p: f32,
182 min_p: f32,
183 repeat_penalty: f32,
184 presence_penalty: f32,
185 frequency_penalty: f32,
186 typical_p: f32,
187}
188
189#[derive(Debug, Serialize)]
191#[serde(untagged)]
192pub enum RequestBody {
193 OpenAi(OpenAiCompletionRequest),
195 LlamaCpp(LlamaCppCompletionRequest),
197}
198
199pub fn build_body(probe: Probe, model: &str, prompts: &[String], slots: &[u32]) -> RequestBody {
206 let candidates = top_k(probe, slots.len());
207 let prompt = match prompts {
208 [one] => PromptField::Single(one.clone()),
209 many => PromptField::Batch(many.to_vec()),
210 };
211 match probe {
212 Probe::LogprobTokenIds => RequestBody::OpenAi(OpenAiCompletionRequest {
213 model: model.to_string(),
214 prompt,
215 max_tokens: 1,
216 temperature: 1.0,
217 logprobs: candidates,
218 return_tokens_as_token_ids: Some(true),
219 allowed_token_ids: Some(slots.to_vec()),
220 logprob_token_ids: Some(slots.to_vec()),
221 }),
222 Probe::AllowedTokenIdsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
223 model: model.to_string(),
224 prompt,
225 max_tokens: 1,
226 temperature: 1.0,
227 logprobs: candidates,
228 return_tokens_as_token_ids: Some(true),
229 allowed_token_ids: Some(slots.to_vec()),
230 logprob_token_ids: None,
231 }),
232 Probe::CompletionsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
233 model: model.to_string(),
234 prompt,
235 max_tokens: 1,
236 temperature: 1.0,
237 logprobs: candidates,
238 return_tokens_as_token_ids: Some(true),
239 allowed_token_ids: None,
240 logprob_token_ids: None,
241 }),
242 Probe::LlamaCppNProbs => RequestBody::LlamaCpp(LlamaCppCompletionRequest {
243 model: model.to_string(),
244 prompt: prompts.first().cloned().unwrap_or_default(),
245 n_predict: 1,
246 n_probs: candidates,
247 cache_prompt: true,
248 temperature: 1.0,
249 top_k: 0,
250 top_p: 1.0,
251 min_p: 0.0,
252 repeat_penalty: 1.0,
253 presence_penalty: 0.0,
254 frequency_penalty: 0.0,
255 typical_p: 1.0,
256 }),
257 }
258}
259
260pub fn request_url(probe: Probe, openai_base: &str, native_base: &str) -> String {
262 probe.url(openai_base, native_base)
263}
264
265#[derive(Debug, Clone, PartialEq)]
267pub struct Candidate {
268 pub id: Option<u32>,
270 pub token: String,
272 pub logprob: f64,
274}
275
276#[derive(Debug, Clone, Deserialize)]
282struct CandidateEntry {
283 #[serde(default)]
284 id: Option<u32>,
285 #[serde(default)]
286 token: String,
287 #[serde(default)]
288 logprob: Option<f64>,
289 #[serde(default)]
290 prob: Option<f64>,
291}
292
293#[derive(Debug, Deserialize)]
300#[serde(untagged)]
301enum CandidateList {
302 Entries(Vec<CandidateEntry>),
303 Map(serde_json::Map<String, Value>),
304}
305
306#[derive(Debug, Deserialize)]
308struct NativePosition {
309 #[serde(default)]
310 top_logprobs: Option<CandidateList>,
311 #[serde(default)]
314 top_probs: Option<CandidateList>,
315}
316
317#[derive(Debug, Deserialize)]
319struct ChoiceLogprobs {
320 #[serde(default)]
322 top_logprobs: Vec<CandidateList>,
323 #[serde(default)]
325 content: Vec<NativePosition>,
326}
327
328#[derive(Debug, Deserialize)]
329struct Choice {
330 #[serde(default)]
333 index: Option<u64>,
334 #[serde(default)]
335 finish_reason: Option<String>,
336 #[serde(default)]
337 logprobs: Option<ChoiceLogprobs>,
338}
339
340#[derive(Debug, Deserialize)]
341struct Usage {
342 #[serde(default)]
343 prompt_tokens: Option<u64>,
344}
345
346#[derive(Debug, Deserialize)]
352pub struct CompletionResponse {
353 #[serde(default)]
354 choices: Vec<Choice>,
355 #[serde(default)]
356 usage: Option<Usage>,
357 #[serde(default)]
359 completion_probabilities: Vec<NativePosition>,
360 #[serde(default)]
361 tokens_evaluated: Option<u64>,
362 #[serde(default)]
363 truncated: bool,
364}
365
366fn candidate_logprob(entry: &CandidateEntry) -> Option<f64> {
367 entry
368 .logprob
369 .or_else(|| entry.prob.map(f64::ln))
370 .filter(|value| value.is_finite())
371}
372
373fn map_logprob(value: &Value) -> Option<f64> {
374 if let Some(number) = value.as_f64() {
375 return Some(number);
376 }
377 value
378 .get("logprob")
379 .and_then(Value::as_f64)
380 .or_else(|| value.get("prob").and_then(Value::as_f64).map(f64::ln))
381}
382
383fn collect_candidates(list: &CandidateList) -> Vec<Candidate> {
385 match list {
386 CandidateList::Entries(entries) => entries
387 .iter()
388 .filter_map(|entry| {
389 Some(Candidate {
390 id: entry.id,
391 token: entry.token.clone(),
392 logprob: candidate_logprob(entry)?,
393 })
394 })
395 .collect(),
396 CandidateList::Map(map) => map
397 .iter()
398 .filter_map(|(key, value)| {
399 let logprob = map_logprob(value)?;
400 let id = key
401 .strip_prefix("token_id:")
402 .and_then(|text| text.parse::<u32>().ok());
403 Some(Candidate {
404 id,
405 token: key.clone(),
406 logprob,
407 })
408 })
409 .collect(),
410 }
411}
412
413fn candidate_position_in<'a>(
419 probe: Probe,
420 choice: Option<&'a Choice>,
421 response: &'a CompletionResponse,
422) -> Result<&'a CandidateList> {
423 let logprobs = choice.and_then(|choice| choice.logprobs.as_ref());
424 match probe {
425 Probe::CompletionsTopK => logprobs
427 .and_then(|logprobs| logprobs.content.first())
428 .and_then(|position| position.top_logprobs.as_ref())
429 .or_else(|| logprobs.and_then(|logprobs| logprobs.top_logprobs.first()))
430 .ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
431 Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK => logprobs
432 .and_then(|logprobs| logprobs.top_logprobs.first())
433 .or_else(|| {
434 logprobs
435 .and_then(|logprobs| logprobs.content.first())
436 .and_then(|position| position.top_logprobs.as_ref())
437 })
438 .ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
439 Probe::LlamaCppNProbs => response
440 .completion_probabilities
441 .first()
442 .and_then(|position| {
443 position
444 .top_logprobs
445 .as_ref()
446 .or(position.top_probs.as_ref())
447 })
448 .ok_or_else(|| anyhow!("response has no candidates under completion_probabilities[0]")),
449 }
450}
451
452fn find_candidate(candidates: &[Candidate], slot: u32, letter: char) -> Option<f64> {
453 let by_id = candidates.iter().find(|candidate| candidate.id == Some(slot));
454 let text = letter.to_string();
455 by_id
456 .or_else(|| candidates.iter().find(|candidate| candidate.token == text))
457 .map(|candidate| candidate.logprob)
458}
459
460pub fn parse_response(body: &Value) -> Result<CompletionResponse> {
462 serde_json::from_value(body.clone())
463 .context("the scoring response did not match any known shape")
464}
465
466pub fn parse_logprobs(
471 probe: Probe,
472 response: &CompletionResponse,
473 slots: &[u32],
474 letters: &[char],
475) -> Result<Vec<f64>> {
476 let candidates = parse_candidates(probe, response)?;
477 slot_logprobs_from_candidates(&candidates, slots, letters)
478}
479
480pub fn parse_batch_candidates(
492 probe: Probe,
493 response: &CompletionResponse,
494 expected: usize,
495) -> Result<Vec<Vec<Candidate>>> {
496 if response.choices.len() != expected {
497 bail!(
498 "the endpoint returned {} choices for {expected} prompts; a batched readout is \
499 all-or-nothing — refusing to guess which answer belongs to which prompt",
500 response.choices.len()
501 );
502 }
503
504 let mut order: Vec<usize> = Vec::with_capacity(response.choices.len());
509 for (position, choice) in response.choices.iter().enumerate() {
510 let index = match choice.index {
511 Some(index) => usize::try_from(index).with_context(|| {
512 format!("batched choice {position} has an `index` that does not fit this platform")
513 })?,
514 None => position,
515 };
516 order.push(index);
517 }
518 let mut sorted = order.clone();
519 sorted.sort_unstable();
520 if sorted != (0..expected).collect::<Vec<usize>>() {
521 bail!(
522 "batched choices' `index` fields are not a permutation of 0..{expected}: {order:?}"
523 );
524 }
525
526 let mut readouts: Vec<Option<Vec<Candidate>>> = vec![None; expected];
527 for (choice, index) in response.choices.iter().zip(order) {
528 if let Some(finish) = choice.finish_reason.as_deref()
529 && finish == "error"
530 {
531 bail!("service reported finish_reason=error for prompt {index}");
532 }
533 let candidates = collect_candidates(candidate_position_in(probe, Some(choice), response)?);
534 if candidates.is_empty() {
535 bail!("prompt {index}: the returned distribution contains no usable candidates");
536 }
537 readouts[index] = Some(candidates);
538 }
539 Ok(readouts
540 .into_iter()
541 .map(|readout| readout.expect("every index in 0..expected was assigned"))
542 .collect())
543}
544
545pub fn slot_logprobs_from_candidates(
551 candidates: &[Candidate],
552 slots: &[u32],
553 letters: &[char],
554) -> Result<Vec<f64>> {
555 if candidates.is_empty() {
556 bail!("returned distribution contains no usable candidates");
557 }
558 let mut result = Vec::with_capacity(slots.len());
559 for (slot, letter) in slots.iter().zip(letters) {
560 let logprob = find_candidate(candidates, *slot, *letter).ok_or_else(|| {
561 anyhow!(
562 "option token {letter:?} (id {slot}) is absent from the returned distribution; \
563 raise the requested top-k or restrict the distribution to the answer tokens"
564 )
565 })?;
566 if !logprob.is_finite() {
567 bail!("logprob for option token {letter:?} is not finite");
568 }
569 result.push(logprob);
570 }
571 Ok(result)
572}
573
574pub fn candidates_restricted_to(candidates: &[Candidate], slots: &[u32], letters: &[char]) -> bool {
580 candidates.iter().all(|candidate| {
581 candidate
582 .id
583 .map(|id| slots.contains(&id))
584 .unwrap_or_else(|| candidate.token.chars().count() == 1 && letters.contains(&candidate.token.chars().next().unwrap()))
585 })
586}
587
588pub fn parse_candidates(probe: Probe, response: &CompletionResponse) -> Result<Vec<Candidate>> {
593 let choice = response.choices.first();
594 if let Some(finish) = choice.and_then(|choice| choice.finish_reason.as_deref())
595 && finish == "error"
596 {
597 bail!("service reported finish_reason=error");
598 }
599 Ok(collect_candidates(candidate_position_in(probe, choice, response)?))
600}
601
602pub fn input_tokens(probe: Probe, response: &CompletionResponse) -> u64 {
604 match probe {
605 Probe::LlamaCppNProbs => response.tokens_evaluated.unwrap_or(0),
606 _ => response
607 .usage
608 .as_ref()
609 .and_then(|usage| usage.prompt_tokens)
610 .unwrap_or(0),
611 }
612}
613
614pub fn truncated(probe: Probe, response: &CompletionResponse) -> bool {
619 match probe {
620 Probe::LlamaCppNProbs => response.truncated,
621 _ => false,
622 }
623}
624
625pub fn validate_probe(logprobs: &[f64]) -> Result<()> {
627 if logprobs.len() < 2 {
628 bail!("probe returned fewer than two option scores");
629 }
630 if logprobs.iter().any(|value| !value.is_finite()) {
631 bail!("probe returned a non-finite option score");
632 }
633 if logprobs.iter().any(|value| *value > 1e-6) {
634 bail!("probe returned a positive log probability, which cannot be a log softmax value");
635 }
636 Ok(())
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use serde_json::json;
643
644 #[test]
645 fn logprob_token_ids_body_restricts_and_selects() {
646 let RequestBody::OpenAi(body) =
647 build_body(Probe::LogprobTokenIds, "m", &["prompt".to_string()], &[32, 33])
648 else {
649 panic!("this probe speaks the OpenAI dialect");
650 };
651 assert_eq!(body.allowed_token_ids.as_deref(), Some(&[32u32, 33][..]));
652 assert_eq!(body.logprob_token_ids.as_deref(), Some(&[32u32, 33][..]));
653 assert_eq!(body.max_tokens, 1);
654 assert_eq!(body.logprobs, 1, "only the requested ids need reporting");
655 }
656
657 #[test]
658 fn unrestricted_probes_request_a_wide_top_k() {
659 assert_eq!(top_k(Probe::CompletionsTopK, 16), 64);
660 assert_eq!(top_k(Probe::LlamaCppNProbs, 16), 64);
661 assert_eq!(top_k(Probe::CompletionsTopK, 2), 32);
662 assert_eq!(top_k(Probe::AllowedTokenIdsTopK, 16), 16);
663 assert_eq!(top_k(Probe::LogprobTokenIds, 16), 1);
664 }
665
666 #[test]
667 fn llamacpp_body_neutralizes_the_sampler() {
668 let RequestBody::LlamaCpp(body) =
669 build_body(Probe::LlamaCppNProbs, "m", &["prompt".to_string()], &[54, 55])
670 else {
671 panic!("this probe speaks the llama.cpp dialect");
672 };
673 assert_eq!(body.n_predict, 1);
674 assert_eq!(body.n_probs, 32);
675 assert_eq!(body.temperature, 1.0);
676 assert_eq!(body.top_k, 0, "top-k truncation would hide low-ranked options");
677 assert_eq!(body.repeat_penalty, 1.0, "a penalty would distort the distribution");
678 }
679
680 #[test]
681 fn an_unrestricted_probe_omits_the_vllm_only_fields() {
682 let RequestBody::OpenAi(body) =
683 build_body(Probe::CompletionsTopK, "m", &["prompt".to_string()], &[54, 55])
684 else {
685 panic!("this probe speaks the OpenAI dialect");
686 };
687 assert!(body.allowed_token_ids.is_none());
688 assert!(body.logprob_token_ids.is_none());
689 assert_eq!(body.logprobs, 32);
690 }
691
692 #[test]
693 fn vllm_map_response_is_parsed_by_token_id() {
694 let body = json!({"choices": [{"logprobs": {"top_logprobs": [{
695 "token_id:32": -0.51, "token_id:33": -1.2, "token_id:99": -4.0
696 }]}}]});
697 let values = parse_logprobs(Probe::LogprobTokenIds, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
698 assert_eq!(values, vec![-0.51, -1.2]);
699 }
700
701 #[test]
702 fn vllm_map_response_falls_back_to_token_text() {
703 let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"A": -0.1, "B": -2.0}]}}]});
704 let values = parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
705 assert_eq!(values, vec![-0.1, -2.0]);
706 }
707
708 #[test]
709 fn llamacpp_completions_response_is_parsed_by_id() {
710 let body = json!({"choices": [{"finish_reason": "length", "logprobs": {"content": [{
713 "id": 55, "token": "B", "logprob": -2.1e-05,
714 "top_logprobs": [
715 {"id": 55, "token": "B", "logprob": -2.1e-05},
716 {"id": 54, "token": "A", "logprob": -12.32},
717 {"id": 56, "token": "C", "logprob": -15.61}
718 ]}]}}]});
719 let values =
720 parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[54, 55, 56], &['A', 'B', 'C']).unwrap();
721 assert_eq!(values, vec![-12.32, -2.1e-05, -15.61]);
722 }
723
724 #[test]
725 fn llamacpp_native_response_prefers_top_logprobs() {
726 let body = json!({"completion_probabilities": [{"id": 32, "token": "A", "prob": 0.6,
727 "top_probs": [{"id": 32, "token": "A", "prob": 0.6}],
728 "top_logprobs": [{"id": 32, "token": "A", "logprob": -0.51},
729 {"id": 33, "token": "B", "logprob": -0.92}]}]});
730 let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
731 assert_eq!(values, vec![-0.51, -0.92]);
732 }
733
734 #[test]
735 fn llamacpp_native_response_falls_back_to_probabilities() {
736 let body = json!({"completion_probabilities": [{"id": 32, "token": "A",
737 "top_probs": [{"id": 32, "token": "A", "prob": 0.5}, {"id": 33, "token": "B", "prob": 0.5}]}]});
738 let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
739 assert!((values[0] - 0.5f64.ln()).abs() < 1e-12);
740 }
741
742 #[test]
743 fn missing_option_is_an_error_not_a_zero() {
744 let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"token_id:32": -0.1}]}}]});
745 let error =
746 parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap_err();
747 assert!(error.to_string().contains("absent from the returned distribution"));
748 }
749
750 #[test]
751 fn foreign_candidates_reveal_an_unrestricted_distribution() {
752 let restricted = vec![Candidate { id: Some(54), token: "A".into(), logprob: -0.1 }];
753 let unrestricted = vec![
754 Candidate { id: Some(54), token: "A".into(), logprob: -0.1 },
755 Candidate { id: Some(608), token: "The".into(), logprob: -9.0 },
756 ];
757 assert!(candidates_restricted_to(&restricted, &[54, 55], &['A', 'B']));
758 assert!(!candidates_restricted_to(&unrestricted, &[54, 55], &['A', 'B']));
759 }
760
761 #[test]
762 fn probe_validation_rejects_positive_logprobs() {
763 assert!(validate_probe(&[-0.5, -1.5]).is_ok());
764 assert!(validate_probe(&[0.5, -1.5]).is_err());
765 }
766
767 #[test]
768 fn urls_split_between_openai_and_native_bases() {
769 assert_eq!(
770 request_url(Probe::CompletionsTopK, "http://host:8000/v1/", "http://host:8000"),
771 "http://host:8000/v1/completions"
772 );
773 assert_eq!(
774 request_url(Probe::LlamaCppNProbs, "http://host:8080/v1", "http://host:8080/"),
775 "http://host:8080/completion"
776 );
777 }
778}