use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Probe {
LogprobTokenIds,
AllowedTokenIdsTopK,
CompletionsTopK,
LlamaCppNProbs,
}
pub const CANDIDATES: [Probe; 4] = [
Probe::LogprobTokenIds,
Probe::AllowedTokenIdsTopK,
Probe::CompletionsTopK,
Probe::LlamaCppNProbs,
];
impl Probe {
pub fn name(self) -> &'static str {
match self {
Probe::LogprobTokenIds => "vllm-logprob-token-ids",
Probe::AllowedTokenIdsTopK => "vllm-allowed-token-ids-top-k",
Probe::CompletionsTopK => "openai-completions-top-k",
Probe::LlamaCppNProbs => "llamacpp-native-n-probs",
}
}
pub fn note(self) -> &'static str {
match self {
Probe::LogprobTokenIds => {
"option distribution is restricted to the declared answer tokens; exact token ids requested"
}
Probe::AllowedTokenIdsTopK => {
"option distribution is restricted to the declared answer tokens; top-k must cover them"
}
Probe::CompletionsTopK => {
"unrestricted next-token distribution; every answer token must fall inside top-k"
}
Probe::LlamaCppNProbs => {
"llama.cpp native completion probabilities; every answer token must fall inside n_probs"
}
}
}
pub fn restricts_distribution(self) -> bool {
matches!(self, Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK)
}
pub fn supports_batch(self) -> bool {
!matches!(self, Probe::LlamaCppNProbs)
}
pub fn endpoint(self) -> &'static str {
match self {
Probe::LlamaCppNProbs => "/completion",
_ => "/completions",
}
}
fn url(self, openai_base: &str, native_base: &str) -> String {
match self {
Probe::LlamaCppNProbs => format!("{}/completion", native_base.trim_end_matches('/')),
_ => format!("{}/completions", openai_base.trim_end_matches('/')),
}
}
}
fn top_k(probe: Probe, slot_count: usize) -> usize {
match probe {
Probe::LogprobTokenIds => 1,
Probe::AllowedTokenIdsTopK => slot_count.max(4),
Probe::CompletionsTopK | Probe::LlamaCppNProbs => (slot_count * 4).clamp(32, 128),
}
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum PromptField {
Single(String),
Batch(Vec<String>),
}
#[derive(Debug, Serialize)]
pub struct OpenAiCompletionRequest {
model: String,
prompt: PromptField,
max_tokens: u32,
temperature: f32,
logprobs: usize,
#[serde(skip_serializing_if = "Option::is_none")]
return_tokens_as_token_ids: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_token_ids: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
logprob_token_ids: Option<Vec<u32>>,
}
#[derive(Debug, Serialize)]
pub struct LlamaCppCompletionRequest {
model: String,
prompt: String,
n_predict: u32,
n_probs: usize,
cache_prompt: bool,
temperature: f32,
top_k: i32,
top_p: f32,
min_p: f32,
repeat_penalty: f32,
presence_penalty: f32,
frequency_penalty: f32,
typical_p: f32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum RequestBody {
OpenAi(OpenAiCompletionRequest),
LlamaCpp(LlamaCppCompletionRequest),
}
pub fn build_body(probe: Probe, model: &str, prompts: &[String], slots: &[u32]) -> RequestBody {
let candidates = top_k(probe, slots.len());
let prompt = match prompts {
[one] => PromptField::Single(one.clone()),
many => PromptField::Batch(many.to_vec()),
};
match probe {
Probe::LogprobTokenIds => RequestBody::OpenAi(OpenAiCompletionRequest {
model: model.to_string(),
prompt,
max_tokens: 1,
temperature: 1.0,
logprobs: candidates,
return_tokens_as_token_ids: Some(true),
allowed_token_ids: Some(slots.to_vec()),
logprob_token_ids: Some(slots.to_vec()),
}),
Probe::AllowedTokenIdsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
model: model.to_string(),
prompt,
max_tokens: 1,
temperature: 1.0,
logprobs: candidates,
return_tokens_as_token_ids: Some(true),
allowed_token_ids: Some(slots.to_vec()),
logprob_token_ids: None,
}),
Probe::CompletionsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
model: model.to_string(),
prompt,
max_tokens: 1,
temperature: 1.0,
logprobs: candidates,
return_tokens_as_token_ids: Some(true),
allowed_token_ids: None,
logprob_token_ids: None,
}),
Probe::LlamaCppNProbs => RequestBody::LlamaCpp(LlamaCppCompletionRequest {
model: model.to_string(),
prompt: prompts.first().cloned().unwrap_or_default(),
n_predict: 1,
n_probs: candidates,
cache_prompt: true,
temperature: 1.0,
top_k: 0,
top_p: 1.0,
min_p: 0.0,
repeat_penalty: 1.0,
presence_penalty: 0.0,
frequency_penalty: 0.0,
typical_p: 1.0,
}),
}
}
pub fn request_url(probe: Probe, openai_base: &str, native_base: &str) -> String {
probe.url(openai_base, native_base)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
pub id: Option<u32>,
pub token: String,
pub logprob: f64,
}
#[derive(Debug, Clone, Deserialize)]
struct CandidateEntry {
#[serde(default)]
id: Option<u32>,
#[serde(default)]
token: String,
#[serde(default)]
logprob: Option<f64>,
#[serde(default)]
prob: Option<f64>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CandidateList {
Entries(Vec<CandidateEntry>),
Map(serde_json::Map<String, Value>),
}
#[derive(Debug, Deserialize)]
struct NativePosition {
#[serde(default)]
top_logprobs: Option<CandidateList>,
#[serde(default)]
top_probs: Option<CandidateList>,
}
#[derive(Debug, Deserialize)]
struct ChoiceLogprobs {
#[serde(default)]
top_logprobs: Vec<CandidateList>,
#[serde(default)]
content: Vec<NativePosition>,
}
#[derive(Debug, Deserialize)]
struct Choice {
#[serde(default)]
index: Option<u64>,
#[serde(default)]
finish_reason: Option<String>,
#[serde(default)]
logprobs: Option<ChoiceLogprobs>,
}
#[derive(Debug, Deserialize)]
struct Usage {
#[serde(default)]
prompt_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct CompletionResponse {
#[serde(default)]
choices: Vec<Choice>,
#[serde(default)]
usage: Option<Usage>,
#[serde(default)]
completion_probabilities: Vec<NativePosition>,
#[serde(default)]
tokens_evaluated: Option<u64>,
#[serde(default)]
truncated: bool,
}
fn candidate_logprob(entry: &CandidateEntry) -> Option<f64> {
entry
.logprob
.or_else(|| entry.prob.map(f64::ln))
.filter(|value| value.is_finite())
}
fn map_logprob(value: &Value) -> Option<f64> {
if let Some(number) = value.as_f64() {
return Some(number);
}
value
.get("logprob")
.and_then(Value::as_f64)
.or_else(|| value.get("prob").and_then(Value::as_f64).map(f64::ln))
}
fn collect_candidates(list: &CandidateList) -> Vec<Candidate> {
match list {
CandidateList::Entries(entries) => entries
.iter()
.filter_map(|entry| {
Some(Candidate {
id: entry.id,
token: entry.token.clone(),
logprob: candidate_logprob(entry)?,
})
})
.collect(),
CandidateList::Map(map) => map
.iter()
.filter_map(|(key, value)| {
let logprob = map_logprob(value)?;
let id = key
.strip_prefix("token_id:")
.and_then(|text| text.parse::<u32>().ok());
Some(Candidate {
id,
token: key.clone(),
logprob,
})
})
.collect(),
}
}
fn candidate_position_in<'a>(
probe: Probe,
choice: Option<&'a Choice>,
response: &'a CompletionResponse,
) -> Result<&'a CandidateList> {
let logprobs = choice.and_then(|choice| choice.logprobs.as_ref());
match probe {
Probe::CompletionsTopK => logprobs
.and_then(|logprobs| logprobs.content.first())
.and_then(|position| position.top_logprobs.as_ref())
.or_else(|| logprobs.and_then(|logprobs| logprobs.top_logprobs.first()))
.ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK => logprobs
.and_then(|logprobs| logprobs.top_logprobs.first())
.or_else(|| {
logprobs
.and_then(|logprobs| logprobs.content.first())
.and_then(|position| position.top_logprobs.as_ref())
})
.ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
Probe::LlamaCppNProbs => response
.completion_probabilities
.first()
.and_then(|position| {
position
.top_logprobs
.as_ref()
.or(position.top_probs.as_ref())
})
.ok_or_else(|| anyhow!("response has no candidates under completion_probabilities[0]")),
}
}
fn find_candidate(candidates: &[Candidate], slot: u32, letter: char) -> Option<f64> {
let by_id = candidates.iter().find(|candidate| candidate.id == Some(slot));
let text = letter.to_string();
by_id
.or_else(|| candidates.iter().find(|candidate| candidate.token == text))
.map(|candidate| candidate.logprob)
}
pub fn parse_response(body: &Value) -> Result<CompletionResponse> {
serde_json::from_value(body.clone())
.context("the scoring response did not match any known shape")
}
pub fn parse_logprobs(
probe: Probe,
response: &CompletionResponse,
slots: &[u32],
letters: &[char],
) -> Result<Vec<f64>> {
let candidates = parse_candidates(probe, response)?;
slot_logprobs_from_candidates(&candidates, slots, letters)
}
pub fn parse_batch_candidates(
probe: Probe,
response: &CompletionResponse,
expected: usize,
) -> Result<Vec<Vec<Candidate>>> {
if response.choices.len() != expected {
bail!(
"the endpoint returned {} choices for {expected} prompts; a batched readout is \
all-or-nothing — refusing to guess which answer belongs to which prompt",
response.choices.len()
);
}
let mut order: Vec<usize> = Vec::with_capacity(response.choices.len());
for (position, choice) in response.choices.iter().enumerate() {
let index = match choice.index {
Some(index) => usize::try_from(index).with_context(|| {
format!("batched choice {position} has an `index` that does not fit this platform")
})?,
None => position,
};
order.push(index);
}
let mut sorted = order.clone();
sorted.sort_unstable();
if sorted != (0..expected).collect::<Vec<usize>>() {
bail!(
"batched choices' `index` fields are not a permutation of 0..{expected}: {order:?}"
);
}
let mut readouts: Vec<Option<Vec<Candidate>>> = vec![None; expected];
for (choice, index) in response.choices.iter().zip(order) {
if let Some(finish) = choice.finish_reason.as_deref()
&& finish == "error"
{
bail!("service reported finish_reason=error for prompt {index}");
}
let candidates = collect_candidates(candidate_position_in(probe, Some(choice), response)?);
if candidates.is_empty() {
bail!("prompt {index}: the returned distribution contains no usable candidates");
}
readouts[index] = Some(candidates);
}
Ok(readouts
.into_iter()
.map(|readout| readout.expect("every index in 0..expected was assigned"))
.collect())
}
pub fn slot_logprobs_from_candidates(
candidates: &[Candidate],
slots: &[u32],
letters: &[char],
) -> Result<Vec<f64>> {
if candidates.is_empty() {
bail!("returned distribution contains no usable candidates");
}
let mut result = Vec::with_capacity(slots.len());
for (slot, letter) in slots.iter().zip(letters) {
let logprob = find_candidate(candidates, *slot, *letter).ok_or_else(|| {
anyhow!(
"option token {letter:?} (id {slot}) is absent from the returned distribution; \
raise the requested top-k or restrict the distribution to the answer tokens"
)
})?;
if !logprob.is_finite() {
bail!("logprob for option token {letter:?} is not finite");
}
result.push(logprob);
}
Ok(result)
}
pub fn candidates_restricted_to(candidates: &[Candidate], slots: &[u32], letters: &[char]) -> bool {
candidates.iter().all(|candidate| {
candidate
.id
.map(|id| slots.contains(&id))
.unwrap_or_else(|| candidate.token.chars().count() == 1 && letters.contains(&candidate.token.chars().next().unwrap()))
})
}
pub fn parse_candidates(probe: Probe, response: &CompletionResponse) -> Result<Vec<Candidate>> {
let choice = response.choices.first();
if let Some(finish) = choice.and_then(|choice| choice.finish_reason.as_deref())
&& finish == "error"
{
bail!("service reported finish_reason=error");
}
Ok(collect_candidates(candidate_position_in(probe, choice, response)?))
}
pub fn input_tokens(probe: Probe, response: &CompletionResponse) -> u64 {
match probe {
Probe::LlamaCppNProbs => response.tokens_evaluated.unwrap_or(0),
_ => response
.usage
.as_ref()
.and_then(|usage| usage.prompt_tokens)
.unwrap_or(0),
}
}
pub fn truncated(probe: Probe, response: &CompletionResponse) -> bool {
match probe {
Probe::LlamaCppNProbs => response.truncated,
_ => false,
}
}
pub fn validate_probe(logprobs: &[f64]) -> Result<()> {
if logprobs.len() < 2 {
bail!("probe returned fewer than two option scores");
}
if logprobs.iter().any(|value| !value.is_finite()) {
bail!("probe returned a non-finite option score");
}
if logprobs.iter().any(|value| *value > 1e-6) {
bail!("probe returned a positive log probability, which cannot be a log softmax value");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn logprob_token_ids_body_restricts_and_selects() {
let RequestBody::OpenAi(body) =
build_body(Probe::LogprobTokenIds, "m", &["prompt".to_string()], &[32, 33])
else {
panic!("this probe speaks the OpenAI dialect");
};
assert_eq!(body.allowed_token_ids.as_deref(), Some(&[32u32, 33][..]));
assert_eq!(body.logprob_token_ids.as_deref(), Some(&[32u32, 33][..]));
assert_eq!(body.max_tokens, 1);
assert_eq!(body.logprobs, 1, "only the requested ids need reporting");
}
#[test]
fn unrestricted_probes_request_a_wide_top_k() {
assert_eq!(top_k(Probe::CompletionsTopK, 16), 64);
assert_eq!(top_k(Probe::LlamaCppNProbs, 16), 64);
assert_eq!(top_k(Probe::CompletionsTopK, 2), 32);
assert_eq!(top_k(Probe::AllowedTokenIdsTopK, 16), 16);
assert_eq!(top_k(Probe::LogprobTokenIds, 16), 1);
}
#[test]
fn llamacpp_body_neutralizes_the_sampler() {
let RequestBody::LlamaCpp(body) =
build_body(Probe::LlamaCppNProbs, "m", &["prompt".to_string()], &[54, 55])
else {
panic!("this probe speaks the llama.cpp dialect");
};
assert_eq!(body.n_predict, 1);
assert_eq!(body.n_probs, 32);
assert_eq!(body.temperature, 1.0);
assert_eq!(body.top_k, 0, "top-k truncation would hide low-ranked options");
assert_eq!(body.repeat_penalty, 1.0, "a penalty would distort the distribution");
}
#[test]
fn an_unrestricted_probe_omits_the_vllm_only_fields() {
let RequestBody::OpenAi(body) =
build_body(Probe::CompletionsTopK, "m", &["prompt".to_string()], &[54, 55])
else {
panic!("this probe speaks the OpenAI dialect");
};
assert!(body.allowed_token_ids.is_none());
assert!(body.logprob_token_ids.is_none());
assert_eq!(body.logprobs, 32);
}
#[test]
fn vllm_map_response_is_parsed_by_token_id() {
let body = json!({"choices": [{"logprobs": {"top_logprobs": [{
"token_id:32": -0.51, "token_id:33": -1.2, "token_id:99": -4.0
}]}}]});
let values = parse_logprobs(Probe::LogprobTokenIds, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
assert_eq!(values, vec![-0.51, -1.2]);
}
#[test]
fn vllm_map_response_falls_back_to_token_text() {
let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"A": -0.1, "B": -2.0}]}}]});
let values = parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
assert_eq!(values, vec![-0.1, -2.0]);
}
#[test]
fn llamacpp_completions_response_is_parsed_by_id() {
let body = json!({"choices": [{"finish_reason": "length", "logprobs": {"content": [{
"id": 55, "token": "B", "logprob": -2.1e-05,
"top_logprobs": [
{"id": 55, "token": "B", "logprob": -2.1e-05},
{"id": 54, "token": "A", "logprob": -12.32},
{"id": 56, "token": "C", "logprob": -15.61}
]}]}}]});
let values =
parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[54, 55, 56], &['A', 'B', 'C']).unwrap();
assert_eq!(values, vec![-12.32, -2.1e-05, -15.61]);
}
#[test]
fn llamacpp_native_response_prefers_top_logprobs() {
let body = json!({"completion_probabilities": [{"id": 32, "token": "A", "prob": 0.6,
"top_probs": [{"id": 32, "token": "A", "prob": 0.6}],
"top_logprobs": [{"id": 32, "token": "A", "logprob": -0.51},
{"id": 33, "token": "B", "logprob": -0.92}]}]});
let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
assert_eq!(values, vec![-0.51, -0.92]);
}
#[test]
fn llamacpp_native_response_falls_back_to_probabilities() {
let body = json!({"completion_probabilities": [{"id": 32, "token": "A",
"top_probs": [{"id": 32, "token": "A", "prob": 0.5}, {"id": 33, "token": "B", "prob": 0.5}]}]});
let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
assert!((values[0] - 0.5f64.ln()).abs() < 1e-12);
}
#[test]
fn missing_option_is_an_error_not_a_zero() {
let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"token_id:32": -0.1}]}}]});
let error =
parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap_err();
assert!(error.to_string().contains("absent from the returned distribution"));
}
#[test]
fn foreign_candidates_reveal_an_unrestricted_distribution() {
let restricted = vec![Candidate { id: Some(54), token: "A".into(), logprob: -0.1 }];
let unrestricted = vec![
Candidate { id: Some(54), token: "A".into(), logprob: -0.1 },
Candidate { id: Some(608), token: "The".into(), logprob: -9.0 },
];
assert!(candidates_restricted_to(&restricted, &[54, 55], &['A', 'B']));
assert!(!candidates_restricted_to(&unrestricted, &[54, 55], &['A', 'B']));
}
#[test]
fn probe_validation_rejects_positive_logprobs() {
assert!(validate_probe(&[-0.5, -1.5]).is_ok());
assert!(validate_probe(&[0.5, -1.5]).is_err());
}
#[test]
fn urls_split_between_openai_and_native_bases() {
assert_eq!(
request_url(Probe::CompletionsTopK, "http://host:8000/v1/", "http://host:8000"),
"http://host:8000/v1/completions"
);
assert_eq!(
request_url(Probe::LlamaCppNProbs, "http://host:8080/v1", "http://host:8080/"),
"http://host:8080/completion"
);
}
}