Skip to main content

jev_bridge/
strategy.rs

1//! Runtime probing of a generic OpenAI-compatible inference service.
2//!
3//! The bridge never guesses: every candidate transport is exercised with a
4//! real probe request before it is adopted, and the chosen probe is reported
5//! by `GET /health` so an operator can see exactly which contract is in use.
6//!
7//! Probe results on llama.cpp (b11096) shaped this module:
8//!   * `/v1/completions` answers with chat-shaped `logprobs.content[0].top_logprobs`
9//!     entries that carry an explicit `id`, and ignores `allowed_token_ids`.
10//!   * `/completion` exposes `completion_probabilities[0].top_logprobs` and
11//!     leaves `top_probs` null.
12//!   * An unrestricted top-k needs k >= 50 before all sixteen answer letters
13//!     appear, so unrestricted probes request a generous k.
14
15use anyhow::{anyhow, bail, Context, Result};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// How the target service exposes option-token log probabilities.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Probe {
22    /// vLLM: restrict the distribution to the option tokens and ask for their
23    /// exact token ids. This is the same contract fastjev's own vLLM backend
24    /// uses through `SamplingParams`.
25    LogprobTokenIds,
26    /// vLLM without `logprob_token_ids`: restrict the distribution and read
27    /// the top-k, which must then contain every option token.
28    AllowedTokenIdsTopK,
29    /// OpenAI-compatible completions: read the top-k of the unrestricted
30    /// next-token distribution.
31    CompletionsTopK,
32    /// llama.cpp native `/completion` with `n_probs`.
33    LlamaCppNProbs,
34}
35
36/// The transports to try, in the order they are probed.
37pub const CANDIDATES: [Probe; 4] = [
38    Probe::LogprobTokenIds,
39    Probe::AllowedTokenIdsTopK,
40    Probe::CompletionsTopK,
41    Probe::LlamaCppNProbs,
42];
43
44impl Probe {
45    /// Stable identifier reported by `GET /health`.
46    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    /// One line describing what this transport relies on.
56    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    /// Whether the request is expected to restrict the distribution to the
74    /// answer tokens. A probe that claims restriction but returns foreign
75    /// candidates is rejected rather than silently trusted.
76    pub fn restricts_distribution(self) -> bool {
77        matches!(self, Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK)
78    }
79
80    /// Whether this transport can read several prompts in one request.
81    ///
82    /// The OpenAI-shaped transports accept an array `prompt`, which a
83    /// prefix-caching server prefills together. llama.cpp's native
84    /// `/completion` answers an array with a bare JSON array of response
85    /// objects instead of one object, so it stays one prompt per request.
86    pub fn supports_batch(self) -> bool {
87        !matches!(self, Probe::LlamaCppNProbs)
88    }
89
90    /// The path this transport posts to.
91    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
106/// Candidate count requested from a top-k endpoint.
107///
108/// A restricted distribution only ever contains the answer tokens, so k equal
109/// to the option count suffices. An unrestricted distribution also contains
110/// ordinary vocabulary, and measurement on a 2B llama.cpp model showed four of
111/// sixteen answer letters falling outside k=20, so unrestricted probes ask for
112/// a wide margin instead.
113fn 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/// The `prompt` field of an OpenAI-compatible completions request.
122///
123/// One string is the shape every runtime accepts; an array asks for several
124/// prompts in one request, which lets a prefix-caching server prefill the
125/// shared part once. Both llama.cpp and vLLM accept the array form.
126#[derive(Debug, Serialize)]
127#[serde(untagged)]
128pub enum PromptField {
129    /// One prompt.
130    Single(String),
131    /// Several prompts, read in one request.
132    Batch(Vec<String>),
133}
134
135/// The request body for an OpenAI-compatible `/v1/completions` call.
136///
137/// Optional fields are omitted rather than sent as `null`: a runtime that does
138/// not know `allowed_token_ids` is likelier to answer a request without it, and
139/// the probe already checks whether the field had any effect.
140#[derive(Debug, Serialize)]
141pub struct OpenAiCompletionRequest {
142    model: String,
143    prompt: PromptField,
144    /// One token is enough: only the first position's distribution is read.
145    max_tokens: u32,
146    /// Left at 1.0 so the returned log probabilities are the model's own.
147    temperature: f32,
148    /// How many candidates to return from the head of the distribution.
149    logprobs: usize,
150    /// Ask vLLM to label candidates by token id instead of token text, which
151    /// removes any ambiguity about leading spaces.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    return_tokens_as_token_ids: Option<bool>,
154    /// vLLM extension: restrict sampling to these token ids, which turns the
155    /// returned log probabilities into the conditional option distribution.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    allowed_token_ids: Option<Vec<u32>>,
158    /// vLLM extension: report these exact token ids even when they fall
159    /// outside the top-k.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    logprob_token_ids: Option<Vec<u32>>,
162}
163
164/// The request body for llama.cpp's native `/completion`.
165///
166/// Every sampler knob is set explicitly and neutrally. llama.cpp reports the
167/// distribution *after* the sampler chain, so inheriting a server preset such
168/// as `repeat-penalty = 1.05` or `top-p = 0.85` would return a distorted
169/// distribution instead of the model's own.
170#[derive(Debug, Serialize)]
171pub struct LlamaCppCompletionRequest {
172    model: String,
173    prompt: String,
174    n_predict: u32,
175    /// How many candidates to return, matching `logprobs` above.
176    n_probs: usize,
177    cache_prompt: bool,
178    temperature: f32,
179    /// 0 disables top-k truncation.
180    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/// A scoring request in whichever dialect the chosen probe speaks.
190#[derive(Debug, Serialize)]
191#[serde(untagged)]
192pub enum RequestBody {
193    /// `/v1/completions` in the OpenAI dialect.
194    OpenAi(OpenAiCompletionRequest),
195    /// llama.cpp's native `/completion`.
196    LlamaCpp(LlamaCppCompletionRequest),
197}
198
199/// Build the scoring request for one probe.
200///
201/// `prompts` holds one prompt for a single readout, or several for a batched
202/// one. Only [`Probe::supports_batch`] transports accept more than one; the
203/// native transport takes the first prompt and the caller falls back to
204/// sequential calls.
205pub 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
260/// The full URL a probe posts to.
261pub fn request_url(probe: Probe, openai_base: &str, native_base: &str) -> String {
262    probe.url(openai_base, native_base)
263}
264
265/// One candidate token in a returned distribution.
266#[derive(Debug, Clone, PartialEq)]
267pub struct Candidate {
268    /// Token id, when the runtime reported one.
269    pub id: Option<u32>,
270    /// Token text, or `token_id:<n>` in the OpenAI map shape.
271    pub token: String,
272    /// Natural-log probability.
273    pub logprob: f64,
274}
275
276/// One candidate as a runtime serialised it.
277///
278/// llama.cpp sends an array of these on both endpoints, with an explicit `id`
279/// and a `logprob`; older builds send `prob` instead. Everything is optional
280/// because the bridge must report a *missing* candidate rather than default one.
281#[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/// A position's candidates, in whichever shape the runtime uses.
294///
295/// OpenAI and vLLM return a map from token text — or `token_id:<n>` when
296/// `return_tokens_as_token_ids` is honoured — to a log probability. llama.cpp
297/// returns an array of entries. Both are accepted, and the map's values stay
298/// dynamic because they may be a bare number or an object with a `logprob`.
299#[derive(Debug, Deserialize)]
300#[serde(untagged)]
301enum CandidateList {
302    Entries(Vec<CandidateEntry>),
303    Map(serde_json::Map<String, Value>),
304}
305
306/// One generated position on llama.cpp's native `/completion`.
307#[derive(Debug, Deserialize)]
308struct NativePosition {
309    #[serde(default)]
310    top_logprobs: Option<CandidateList>,
311    /// Only consulted when `top_logprobs` is absent: a probability carries less
312    /// precision than a log probability.
313    #[serde(default)]
314    top_probs: Option<CandidateList>,
315}
316
317/// One generated position in an OpenAI-shaped choice.
318#[derive(Debug, Deserialize)]
319struct ChoiceLogprobs {
320    /// OpenAI completions shape: one entry per generated position.
321    #[serde(default)]
322    top_logprobs: Vec<CandidateList>,
323    /// llama.cpp answers the completions endpoint with chat-shaped content.
324    #[serde(default)]
325    content: Vec<NativePosition>,
326}
327
328#[derive(Debug, Deserialize)]
329struct Choice {
330    /// Which prompt of a batched request this choice answers; absent on some
331    /// servers, in which case the array position is used.
332    #[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/// Every field the bridge reads from a scoring response.
347///
348/// One struct covers both endpoints: a runtime fills the fields its dialect
349/// uses and leaves the rest absent, and the probe that was chosen already
350/// decided which fields to read.
351#[derive(Debug, Deserialize)]
352pub struct CompletionResponse {
353    #[serde(default)]
354    choices: Vec<Choice>,
355    #[serde(default)]
356    usage: Option<Usage>,
357    /// llama.cpp native `/completion`.
358    #[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
383/// Normalize the several shapes servers use for one position's candidates.
384fn 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
413/// Locate the candidate list for the first generated position of one choice.
414///
415/// The order within each probe is deliberate: the shape that probe is expected
416/// to receive is tried first, and the alternative is only a fallback for
417/// runtimes that answer in the other dialect.
418fn 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        // llama.cpp answers `/v1/completions` with chat-shaped logprobs.
426        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
460/// Decode a scoring response into the struct the bridge reads.
461pub 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
466/// Extract one log probability per requested slot, in slot order.
467///
468/// A missing slot is an error rather than a zero: silently dropping an option
469/// would change the meaning of the returned distribution.
470pub 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
480/// Extract every prompt's candidate list from one batched readout, in request
481/// order.
482///
483/// All-or-nothing by design: a response with a different number of choices
484/// than prompts, choices whose `index` fields are not a permutation of
485/// `0..expected`, or any choice without a usable distribution fails the whole
486/// call. A partial success would silently mis-align prompts and probabilities.
487///
488/// The candidate lists stay per choice because each row declares its own
489/// option count; extracting a row's slots is
490/// [`slot_logprobs_from_candidates`]'s job.
491pub 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    // `index` is the server's own statement of which prompt a choice belongs
505    // to; the wire order is not guaranteed to match the request order. A choice
506    // without `index` is taken at its array position (some servers omit it for
507    // single-element batches).
508    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
545/// One option distribution's log probabilities for the requested slots, in
546/// slot order.
547///
548/// A missing slot is an error rather than a zero: silently dropping an option
549/// would change the meaning of the returned distribution.
550pub 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
574/// Whether every returned candidate belongs to the declared answer tokens.
575///
576/// A probe that asks for a restricted distribution but receives foreign
577/// candidates has been silently ignored by the server, which would make the
578/// reported transport a lie.
579pub 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
588/// Decode the returned candidates without requiring any slot to be present.
589///
590/// A `finish_reason` of `error` is rejected here: such a response's
591/// distribution is not one the caller asked for.
592pub 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
602/// The prompt length the runtime reported, or zero when it reported none.
603pub 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
614/// Whether the runtime silently shortened the prompt.
615///
616/// A truncated prompt would score a decision the caller never asked for, so the
617/// bridge turns this into an error instead of a slightly wrong answer.
618pub fn truncated(probe: Probe, response: &CompletionResponse) -> bool {
619    match probe {
620        Probe::LlamaCppNProbs => response.truncated,
621        _ => false,
622    }
623}
624
625/// Validate that a probe response is usable before adopting its transport.
626pub 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        // Shape observed on llama.cpp b11096: chat-style logprobs on the
711        // completions endpoint, with an explicit token id.
712        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}