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 the letters compete with it:
111///
112/// * measured on a 2B llama.cpp model, four of sixteen answer letters fell
113///   outside k=20;
114/// * measured on a 9B model with a ~1000-token prompt and sixteen options (the
115///   djev-run tetris demo), `F` fell outside k=64.
116///
117/// So unrestricted transports ask for a wide margin — deeper is nearly free
118/// (the per-decision cost moved ~1% from k=100 to k=256 on the reference
119/// endpoint) while a missing slot is an error that [`candidate_ladder`] can
120/// retry away.
121fn top_k(probe: Probe, slot_count: usize) -> usize {
122    match probe {
123        Probe::LogprobTokenIds => 1,
124        Probe::AllowedTokenIdsTopK => slot_count.max(4),
125        Probe::CompletionsTopK | Probe::LlamaCppNProbs => (slot_count * 16).clamp(32, 256),
126    }
127}
128
129/// The whole vocabulary, as an upper bound for a deepened request.
130///
131/// Any real tokenizer fits: the largest shipped vocabularies are around 260k
132/// entries, and llama.cpp caps `n_probs` at the vocabulary size.
133const FULL_VOCABULARY: usize = 200_000;
134
135/// The candidate counts to try for `slot_count` options, shallowest first.
136///
137/// The first request is sized by [`top_k`]; when it misses an answer token the
138/// caller retries with the next entry instead of failing the decision, because
139/// a large prompt can push a letter past a top-k that looked generous for the
140/// option count. Retries reuse the server's prefix cache, so only the readout
141/// is recomputed — measured on the reference endpoint, a deepened retry costs
142/// about one sampled token plus the larger candidate list.
143///
144/// Restricted transports never need more than one step (the server is told
145/// exactly which tokens to return); their ladder still deepens so the caller
146/// can treat every probe the same way.
147pub fn candidate_ladder(probe: Probe, slot_count: usize) -> Vec<usize> {
148    let initial = top_k(probe, slot_count);
149    let mut ladder = vec![initial];
150    for multiplier in [4, 16] {
151        let next = initial.saturating_mul(multiplier).min(FULL_VOCABULARY);
152        if next > *ladder.last().expect("the ladder starts nonempty") {
153            ladder.push(next);
154        }
155    }
156    if *ladder.last().expect("the ladder starts nonempty") < FULL_VOCABULARY {
157        ladder.push(FULL_VOCABULARY);
158    }
159    ladder
160}
161
162/// The `prompt` field of an OpenAI-compatible completions request.
163///
164/// One string is the shape every runtime accepts; an array asks for several
165/// prompts in one request, which lets a prefix-caching server prefill the
166/// shared part once. Both llama.cpp and vLLM accept the array form.
167#[derive(Debug, Serialize)]
168#[serde(untagged)]
169pub enum PromptField {
170    /// One prompt.
171    Single(String),
172    /// Several prompts, read in one request.
173    Batch(Vec<String>),
174}
175
176/// The request body for an OpenAI-compatible `/v1/completions` call.
177///
178/// Optional fields are omitted rather than sent as `null`: a runtime that does
179/// not know `allowed_token_ids` is likelier to answer a request without it, and
180/// the probe already checks whether the field had any effect.
181#[derive(Debug, Serialize)]
182pub struct OpenAiCompletionRequest {
183    model: String,
184    prompt: PromptField,
185    /// One token is enough: only the first position's distribution is read.
186    max_tokens: u32,
187    /// Left at 1.0 so the returned log probabilities are the model's own.
188    temperature: f32,
189    /// How many candidates to return from the head of the distribution.
190    logprobs: usize,
191    /// Ask vLLM to label candidates by token id instead of token text, which
192    /// removes any ambiguity about leading spaces.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    return_tokens_as_token_ids: Option<bool>,
195    /// vLLM extension: restrict sampling to these token ids, which turns the
196    /// returned log probabilities into the conditional option distribution.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    allowed_token_ids: Option<Vec<u32>>,
199    /// vLLM extension: report these exact token ids even when they fall
200    /// outside the top-k.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    logprob_token_ids: Option<Vec<u32>>,
203}
204
205/// The request body for llama.cpp's native `/completion`.
206///
207/// Every sampler knob is set explicitly and neutrally. llama.cpp reports the
208/// distribution *after* the sampler chain, so inheriting a server preset such
209/// as `repeat-penalty = 1.05` or `top-p = 0.85` would return a distorted
210/// distribution instead of the model's own.
211#[derive(Debug, Serialize)]
212pub struct LlamaCppCompletionRequest {
213    model: String,
214    prompt: String,
215    n_predict: u32,
216    /// How many candidates to return, matching `logprobs` above.
217    n_probs: usize,
218    cache_prompt: bool,
219    temperature: f32,
220    /// 0 disables top-k truncation.
221    top_k: i32,
222    top_p: f32,
223    min_p: f32,
224    repeat_penalty: f32,
225    presence_penalty: f32,
226    frequency_penalty: f32,
227    typical_p: f32,
228}
229
230/// A scoring request in whichever dialect the chosen probe speaks.
231#[derive(Debug, Serialize)]
232#[serde(untagged)]
233pub enum RequestBody {
234    /// `/v1/completions` in the OpenAI dialect.
235    OpenAi(OpenAiCompletionRequest),
236    /// llama.cpp's native `/completion`.
237    LlamaCpp(LlamaCppCompletionRequest),
238}
239
240/// Build the scoring request for one probe.
241///
242/// `prompts` holds one prompt for a single readout, or several for a batched
243/// one. Only [`Probe::supports_batch`] transports accept more than one; the
244/// native transport takes the first prompt and the caller falls back to
245/// sequential calls.
246pub fn build_body(probe: Probe, model: &str, prompts: &[String], slots: &[u32]) -> RequestBody {
247    build_body_with_candidates(probe, model, prompts, slots, top_k(probe, slots.len()))
248}
249
250/// Build the scoring request for one probe with an explicit candidate count.
251///
252/// A caller that retries after a missing option token passes a deeper count
253/// than [`top_k`] picks; see [`candidate_ladder`].
254pub fn build_body_with_candidates(
255    probe: Probe,
256    model: &str,
257    prompts: &[String],
258    slots: &[u32],
259    candidates: usize,
260) -> RequestBody {
261    let prompt = match prompts {
262        [one] => PromptField::Single(one.clone()),
263        many => PromptField::Batch(many.to_vec()),
264    };
265    match probe {
266        Probe::LogprobTokenIds => RequestBody::OpenAi(OpenAiCompletionRequest {
267            model: model.to_string(),
268            prompt,
269            max_tokens: 1,
270            temperature: 1.0,
271            logprobs: candidates,
272            return_tokens_as_token_ids: Some(true),
273            allowed_token_ids: Some(slots.to_vec()),
274            logprob_token_ids: Some(slots.to_vec()),
275        }),
276        Probe::AllowedTokenIdsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
277            model: model.to_string(),
278            prompt,
279            max_tokens: 1,
280            temperature: 1.0,
281            logprobs: candidates,
282            return_tokens_as_token_ids: Some(true),
283            allowed_token_ids: Some(slots.to_vec()),
284            logprob_token_ids: None,
285        }),
286        Probe::CompletionsTopK => RequestBody::OpenAi(OpenAiCompletionRequest {
287            model: model.to_string(),
288            prompt,
289            max_tokens: 1,
290            temperature: 1.0,
291            logprobs: candidates,
292            return_tokens_as_token_ids: Some(true),
293            allowed_token_ids: None,
294            logprob_token_ids: None,
295        }),
296        Probe::LlamaCppNProbs => RequestBody::LlamaCpp(LlamaCppCompletionRequest {
297            model: model.to_string(),
298            prompt: prompts.first().cloned().unwrap_or_default(),
299            n_predict: 1,
300            n_probs: candidates,
301            cache_prompt: true,
302            temperature: 1.0,
303            top_k: 0,
304            top_p: 1.0,
305            min_p: 0.0,
306            repeat_penalty: 1.0,
307            presence_penalty: 0.0,
308            frequency_penalty: 0.0,
309            typical_p: 1.0,
310        }),
311    }
312}
313
314/// The full URL a probe posts to.
315pub fn request_url(probe: Probe, openai_base: &str, native_base: &str) -> String {
316    probe.url(openai_base, native_base)
317}
318
319/// One candidate token in a returned distribution.
320#[derive(Debug, Clone, PartialEq)]
321pub struct Candidate {
322    /// Token id, when the runtime reported one.
323    pub id: Option<u32>,
324    /// Token text, or `token_id:<n>` in the OpenAI map shape.
325    pub token: String,
326    /// Natural-log probability.
327    pub logprob: f64,
328}
329
330/// One candidate as a runtime serialised it.
331///
332/// llama.cpp sends an array of these on both endpoints, with an explicit `id`
333/// and a `logprob`; older builds send `prob` instead. Everything is optional
334/// because the bridge must report a *missing* candidate rather than default one.
335#[derive(Debug, Clone, Deserialize)]
336struct CandidateEntry {
337    #[serde(default)]
338    id: Option<u32>,
339    #[serde(default)]
340    token: String,
341    #[serde(default)]
342    logprob: Option<f64>,
343    #[serde(default)]
344    prob: Option<f64>,
345}
346
347/// A position's candidates, in whichever shape the runtime uses.
348///
349/// OpenAI and vLLM return a map from token text — or `token_id:<n>` when
350/// `return_tokens_as_token_ids` is honoured — to a log probability. llama.cpp
351/// returns an array of entries. Both are accepted, and the map's values stay
352/// dynamic because they may be a bare number or an object with a `logprob`.
353#[derive(Debug, Deserialize)]
354#[serde(untagged)]
355enum CandidateList {
356    Entries(Vec<CandidateEntry>),
357    Map(serde_json::Map<String, Value>),
358}
359
360/// One generated position on llama.cpp's native `/completion`.
361#[derive(Debug, Deserialize)]
362struct NativePosition {
363    #[serde(default)]
364    top_logprobs: Option<CandidateList>,
365    /// Only consulted when `top_logprobs` is absent: a probability carries less
366    /// precision than a log probability.
367    #[serde(default)]
368    top_probs: Option<CandidateList>,
369}
370
371/// One generated position in an OpenAI-shaped choice.
372#[derive(Debug, Deserialize)]
373struct ChoiceLogprobs {
374    /// OpenAI completions shape: one entry per generated position.
375    #[serde(default)]
376    top_logprobs: Vec<CandidateList>,
377    /// llama.cpp answers the completions endpoint with chat-shaped content.
378    #[serde(default)]
379    content: Vec<NativePosition>,
380}
381
382#[derive(Debug, Deserialize)]
383struct Choice {
384    /// Which prompt of a batched request this choice answers; absent on some
385    /// servers, in which case the array position is used.
386    #[serde(default)]
387    index: Option<u64>,
388    #[serde(default)]
389    finish_reason: Option<String>,
390    #[serde(default)]
391    logprobs: Option<ChoiceLogprobs>,
392}
393
394#[derive(Debug, Deserialize)]
395struct Usage {
396    #[serde(default)]
397    prompt_tokens: Option<u64>,
398}
399
400/// Every field the bridge reads from a scoring response.
401///
402/// One struct covers both endpoints: a runtime fills the fields its dialect
403/// uses and leaves the rest absent, and the probe that was chosen already
404/// decided which fields to read.
405#[derive(Debug, Deserialize)]
406pub struct CompletionResponse {
407    #[serde(default)]
408    choices: Vec<Choice>,
409    #[serde(default)]
410    usage: Option<Usage>,
411    /// llama.cpp native `/completion`.
412    #[serde(default)]
413    completion_probabilities: Vec<NativePosition>,
414    #[serde(default)]
415    tokens_evaluated: Option<u64>,
416    #[serde(default)]
417    truncated: bool,
418}
419
420fn candidate_logprob(entry: &CandidateEntry) -> Option<f64> {
421    entry
422        .logprob
423        .or_else(|| entry.prob.map(f64::ln))
424        .filter(|value| value.is_finite())
425}
426
427fn map_logprob(value: &Value) -> Option<f64> {
428    if let Some(number) = value.as_f64() {
429        return Some(number);
430    }
431    value
432        .get("logprob")
433        .and_then(Value::as_f64)
434        .or_else(|| value.get("prob").and_then(Value::as_f64).map(f64::ln))
435}
436
437/// Normalize the several shapes servers use for one position's candidates.
438fn collect_candidates(list: &CandidateList) -> Vec<Candidate> {
439    match list {
440        CandidateList::Entries(entries) => entries
441            .iter()
442            .filter_map(|entry| {
443                Some(Candidate {
444                    id: entry.id,
445                    token: entry.token.clone(),
446                    logprob: candidate_logprob(entry)?,
447                })
448            })
449            .collect(),
450        CandidateList::Map(map) => map
451            .iter()
452            .filter_map(|(key, value)| {
453                let logprob = map_logprob(value)?;
454                let id = key
455                    .strip_prefix("token_id:")
456                    .and_then(|text| text.parse::<u32>().ok());
457                Some(Candidate {
458                    id,
459                    token: key.clone(),
460                    logprob,
461                })
462            })
463            .collect(),
464    }
465}
466
467/// Locate the candidate list for the first generated position of one choice.
468///
469/// The order within each probe is deliberate: the shape that probe is expected
470/// to receive is tried first, and the alternative is only a fallback for
471/// runtimes that answer in the other dialect.
472fn candidate_position_in<'a>(
473    probe: Probe,
474    choice: Option<&'a Choice>,
475    response: &'a CompletionResponse,
476) -> Result<&'a CandidateList> {
477    let logprobs = choice.and_then(|choice| choice.logprobs.as_ref());
478    match probe {
479        // llama.cpp answers `/v1/completions` with chat-shaped logprobs.
480        Probe::CompletionsTopK => logprobs
481            .and_then(|logprobs| logprobs.content.first())
482            .and_then(|position| position.top_logprobs.as_ref())
483            .or_else(|| logprobs.and_then(|logprobs| logprobs.top_logprobs.first()))
484            .ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
485        Probe::LogprobTokenIds | Probe::AllowedTokenIdsTopK => logprobs
486            .and_then(|logprobs| logprobs.top_logprobs.first())
487            .or_else(|| {
488                logprobs
489                    .and_then(|logprobs| logprobs.content.first())
490                    .and_then(|position| position.top_logprobs.as_ref())
491            })
492            .ok_or_else(|| anyhow!("response has no candidates under choices[].logprobs")),
493        Probe::LlamaCppNProbs => response
494            .completion_probabilities
495            .first()
496            .and_then(|position| {
497                position
498                    .top_logprobs
499                    .as_ref()
500                    .or(position.top_probs.as_ref())
501            })
502            .ok_or_else(|| anyhow!("response has no candidates under completion_probabilities[0]")),
503    }
504}
505
506fn find_candidate(candidates: &[Candidate], slot: u32, letter: char) -> Option<f64> {
507    let by_id = candidates.iter().find(|candidate| candidate.id == Some(slot));
508    let text = letter.to_string();
509    by_id
510        .or_else(|| candidates.iter().find(|candidate| candidate.token == text))
511        .map(|candidate| candidate.logprob)
512}
513
514/// Decode a scoring response into the struct the bridge reads.
515pub fn parse_response(body: &Value) -> Result<CompletionResponse> {
516    serde_json::from_value(body.clone())
517        .context("the scoring response did not match any known shape")
518}
519
520/// Extract one log probability per requested slot, in slot order.
521///
522/// A missing slot is an error rather than a zero: silently dropping an option
523/// would change the meaning of the returned distribution.
524pub fn parse_logprobs(
525    probe: Probe,
526    response: &CompletionResponse,
527    slots: &[u32],
528    letters: &[char],
529) -> Result<Vec<f64>> {
530    let candidates = parse_candidates(probe, response)?;
531    slot_logprobs_from_candidates(&candidates, slots, letters)
532}
533
534/// Extract every prompt's candidate list from one batched readout, in request
535/// order.
536///
537/// All-or-nothing by design: a response with a different number of choices
538/// than prompts, choices whose `index` fields are not a permutation of
539/// `0..expected`, or any choice without a usable distribution fails the whole
540/// call. A partial success would silently mis-align prompts and probabilities.
541///
542/// The candidate lists stay per choice because each row declares its own
543/// option count; extracting a row's slots is
544/// [`slot_logprobs_from_candidates`]'s job.
545pub fn parse_batch_candidates(
546    probe: Probe,
547    response: &CompletionResponse,
548    expected: usize,
549) -> Result<Vec<Vec<Candidate>>> {
550    if response.choices.len() != expected {
551        bail!(
552            "the endpoint returned {} choices for {expected} prompts; a batched readout is \
553             all-or-nothing — refusing to guess which answer belongs to which prompt",
554            response.choices.len()
555        );
556    }
557
558    // `index` is the server's own statement of which prompt a choice belongs
559    // to; the wire order is not guaranteed to match the request order. A choice
560    // without `index` is taken at its array position (some servers omit it for
561    // single-element batches).
562    let mut order: Vec<usize> = Vec::with_capacity(response.choices.len());
563    for (position, choice) in response.choices.iter().enumerate() {
564        let index = match choice.index {
565            Some(index) => usize::try_from(index).with_context(|| {
566                format!("batched choice {position} has an `index` that does not fit this platform")
567            })?,
568            None => position,
569        };
570        order.push(index);
571    }
572    let mut sorted = order.clone();
573    sorted.sort_unstable();
574    if sorted != (0..expected).collect::<Vec<usize>>() {
575        bail!(
576            "batched choices' `index` fields are not a permutation of 0..{expected}: {order:?}"
577        );
578    }
579
580    let mut readouts: Vec<Option<Vec<Candidate>>> = vec![None; expected];
581    for (choice, index) in response.choices.iter().zip(order) {
582        if let Some(finish) = choice.finish_reason.as_deref()
583            && finish == "error"
584        {
585            bail!("service reported finish_reason=error for prompt {index}");
586        }
587        let candidates = collect_candidates(candidate_position_in(probe, Some(choice), response)?);
588        if candidates.is_empty() {
589            bail!("prompt {index}: the returned distribution contains no usable candidates");
590        }
591        readouts[index] = Some(candidates);
592    }
593    Ok(readouts
594        .into_iter()
595        .map(|readout| readout.expect("every index in 0..expected was assigned"))
596        .collect())
597}
598
599/// One option distribution's log probabilities for the requested slots, in
600/// slot order.
601///
602/// A missing slot is an error rather than a zero: silently dropping an option
603/// would change the meaning of the returned distribution.
604pub fn slot_logprobs_from_candidates(
605    candidates: &[Candidate],
606    slots: &[u32],
607    letters: &[char],
608) -> Result<Vec<f64>> {
609    if candidates.is_empty() {
610        bail!("returned distribution contains no usable candidates");
611    }
612    let mut result = Vec::with_capacity(slots.len());
613    for (slot, letter) in slots.iter().zip(letters) {
614        let logprob = find_candidate(candidates, *slot, *letter).ok_or_else(|| {
615            anyhow!(
616                "option token {letter:?} (id {slot}) is absent from the returned distribution; \
617                 raise the requested top-k or restrict the distribution to the answer tokens"
618            )
619        })?;
620        if !logprob.is_finite() {
621            bail!("logprob for option token {letter:?} is not finite");
622        }
623        result.push(logprob);
624    }
625    Ok(result)
626}
627
628/// Whether every returned candidate belongs to the declared answer tokens.
629///
630/// A probe that asks for a restricted distribution but receives foreign
631/// candidates has been silently ignored by the server, which would make the
632/// reported transport a lie.
633pub fn candidates_restricted_to(candidates: &[Candidate], slots: &[u32], letters: &[char]) -> bool {
634    candidates.iter().all(|candidate| {
635        candidate
636            .id
637            .map(|id| slots.contains(&id))
638            .unwrap_or_else(|| candidate.token.chars().count() == 1 && letters.contains(&candidate.token.chars().next().unwrap()))
639    })
640}
641
642/// Decode the returned candidates without requiring any slot to be present.
643///
644/// A `finish_reason` of `error` is rejected here: such a response's
645/// distribution is not one the caller asked for.
646pub fn parse_candidates(probe: Probe, response: &CompletionResponse) -> Result<Vec<Candidate>> {
647    let choice = response.choices.first();
648    if let Some(finish) = choice.and_then(|choice| choice.finish_reason.as_deref())
649        && finish == "error"
650    {
651        bail!("service reported finish_reason=error");
652    }
653    Ok(collect_candidates(candidate_position_in(probe, choice, response)?))
654}
655
656/// The prompt length the runtime reported, or zero when it reported none.
657pub fn input_tokens(probe: Probe, response: &CompletionResponse) -> u64 {
658    match probe {
659        Probe::LlamaCppNProbs => response.tokens_evaluated.unwrap_or(0),
660        _ => response
661            .usage
662            .as_ref()
663            .and_then(|usage| usage.prompt_tokens)
664            .unwrap_or(0),
665    }
666}
667
668/// Whether the runtime silently shortened the prompt.
669///
670/// A truncated prompt would score a decision the caller never asked for, so the
671/// bridge turns this into an error instead of a slightly wrong answer.
672pub fn truncated(probe: Probe, response: &CompletionResponse) -> bool {
673    match probe {
674        Probe::LlamaCppNProbs => response.truncated,
675        _ => false,
676    }
677}
678
679/// Validate that a probe response is usable before adopting its transport.
680pub fn validate_probe(logprobs: &[f64]) -> Result<()> {
681    if logprobs.len() < 2 {
682        bail!("probe returned fewer than two option scores");
683    }
684    if logprobs.iter().any(|value| !value.is_finite()) {
685        bail!("probe returned a non-finite option score");
686    }
687    if logprobs.iter().any(|value| *value > 1e-6) {
688        bail!("probe returned a positive log probability, which cannot be a log softmax value");
689    }
690    Ok(())
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use serde_json::json;
697
698    #[test]
699    fn logprob_token_ids_body_restricts_and_selects() {
700        let RequestBody::OpenAi(body) =
701            build_body(Probe::LogprobTokenIds, "m", &["prompt".to_string()], &[32, 33])
702        else {
703            panic!("this probe speaks the OpenAI dialect");
704        };
705        assert_eq!(body.allowed_token_ids.as_deref(), Some(&[32u32, 33][..]));
706        assert_eq!(body.logprob_token_ids.as_deref(), Some(&[32u32, 33][..]));
707        assert_eq!(body.max_tokens, 1);
708        assert_eq!(body.logprobs, 1, "only the requested ids need reporting");
709    }
710
711    #[test]
712    fn unrestricted_probes_request_a_wide_top_k() {
713        assert_eq!(top_k(Probe::CompletionsTopK, 16), 256);
714        assert_eq!(top_k(Probe::LlamaCppNProbs, 16), 256);
715        assert_eq!(top_k(Probe::CompletionsTopK, 2), 32);
716        assert_eq!(top_k(Probe::AllowedTokenIdsTopK, 16), 16);
717        assert_eq!(top_k(Probe::LogprobTokenIds, 16), 1);
718    }
719
720    #[test]
721    fn the_candidate_ladder_deepens_until_the_whole_vocabulary() {
722        assert_eq!(
723            candidate_ladder(Probe::CompletionsTopK, 16),
724            vec![256, 1024, 4096, FULL_VOCABULARY],
725            "sixteen options start at the top of the measured band and deepen from there"
726        );
727        assert_eq!(
728            candidate_ladder(Probe::LlamaCppNProbs, 2),
729            vec![32, 128, 512, FULL_VOCABULARY]
730        );
731        assert_eq!(
732            candidate_ladder(Probe::AllowedTokenIdsTopK, 16),
733            vec![16, 64, 256, FULL_VOCABULARY],
734            "restricted probes never retry, but their ladder is shaped the same way"
735        );
736    }
737
738    #[test]
739    fn llamacpp_body_neutralizes_the_sampler() {
740        let RequestBody::LlamaCpp(body) =
741            build_body(Probe::LlamaCppNProbs, "m", &["prompt".to_string()], &[54, 55])
742        else {
743            panic!("this probe speaks the llama.cpp dialect");
744        };
745        assert_eq!(body.n_predict, 1);
746        assert_eq!(body.n_probs, 32);
747        assert_eq!(body.temperature, 1.0);
748        assert_eq!(body.top_k, 0, "top-k truncation would hide low-ranked options");
749        assert_eq!(body.repeat_penalty, 1.0, "a penalty would distort the distribution");
750    }
751
752    #[test]
753    fn an_unrestricted_probe_omits_the_vllm_only_fields() {
754        let RequestBody::OpenAi(body) =
755            build_body(Probe::CompletionsTopK, "m", &["prompt".to_string()], &[54, 55])
756        else {
757            panic!("this probe speaks the OpenAI dialect");
758        };
759        assert!(body.allowed_token_ids.is_none());
760        assert!(body.logprob_token_ids.is_none());
761        assert_eq!(body.logprobs, 32);
762    }
763
764    #[test]
765    fn vllm_map_response_is_parsed_by_token_id() {
766        let body = json!({"choices": [{"logprobs": {"top_logprobs": [{
767            "token_id:32": -0.51, "token_id:33": -1.2, "token_id:99": -4.0
768        }]}}]});
769        let values = parse_logprobs(Probe::LogprobTokenIds, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
770        assert_eq!(values, vec![-0.51, -1.2]);
771    }
772
773    #[test]
774    fn vllm_map_response_falls_back_to_token_text() {
775        let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"A": -0.1, "B": -2.0}]}}]});
776        let values = parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
777        assert_eq!(values, vec![-0.1, -2.0]);
778    }
779
780    #[test]
781    fn llamacpp_completions_response_is_parsed_by_id() {
782        // Shape observed on llama.cpp b11096: chat-style logprobs on the
783        // completions endpoint, with an explicit token id.
784        let body = json!({"choices": [{"finish_reason": "length", "logprobs": {"content": [{
785            "id": 55, "token": "B", "logprob": -2.1e-05,
786            "top_logprobs": [
787                {"id": 55, "token": "B", "logprob": -2.1e-05},
788                {"id": 54, "token": "A", "logprob": -12.32},
789                {"id": 56, "token": "C", "logprob": -15.61}
790            ]}]}}]});
791        let values =
792            parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[54, 55, 56], &['A', 'B', 'C']).unwrap();
793        assert_eq!(values, vec![-12.32, -2.1e-05, -15.61]);
794    }
795
796    #[test]
797    fn llamacpp_native_response_prefers_top_logprobs() {
798        let body = json!({"completion_probabilities": [{"id": 32, "token": "A", "prob": 0.6,
799            "top_probs": [{"id": 32, "token": "A", "prob": 0.6}],
800            "top_logprobs": [{"id": 32, "token": "A", "logprob": -0.51},
801                             {"id": 33, "token": "B", "logprob": -0.92}]}]});
802        let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
803        assert_eq!(values, vec![-0.51, -0.92]);
804    }
805
806    #[test]
807    fn llamacpp_native_response_falls_back_to_probabilities() {
808        let body = json!({"completion_probabilities": [{"id": 32, "token": "A",
809            "top_probs": [{"id": 32, "token": "A", "prob": 0.5}, {"id": 33, "token": "B", "prob": 0.5}]}]});
810        let values = parse_logprobs(Probe::LlamaCppNProbs, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap();
811        assert!((values[0] - 0.5f64.ln()).abs() < 1e-12);
812    }
813
814    #[test]
815    fn missing_option_is_an_error_not_a_zero() {
816        let body = json!({"choices": [{"logprobs": {"top_logprobs": [{"token_id:32": -0.1}]}}]});
817        let error =
818            parse_logprobs(Probe::CompletionsTopK, &parse_response(&body).unwrap(), &[32, 33], &['A', 'B']).unwrap_err();
819        assert!(error.to_string().contains("absent from the returned distribution"));
820    }
821
822    #[test]
823    fn foreign_candidates_reveal_an_unrestricted_distribution() {
824        let restricted = vec![Candidate { id: Some(54), token: "A".into(), logprob: -0.1 }];
825        let unrestricted = vec![
826            Candidate { id: Some(54), token: "A".into(), logprob: -0.1 },
827            Candidate { id: Some(608), token: "The".into(), logprob: -9.0 },
828        ];
829        assert!(candidates_restricted_to(&restricted, &[54, 55], &['A', 'B']));
830        assert!(!candidates_restricted_to(&unrestricted, &[54, 55], &['A', 'B']));
831    }
832
833    #[test]
834    fn probe_validation_rejects_positive_logprobs() {
835        assert!(validate_probe(&[-0.5, -1.5]).is_ok());
836        assert!(validate_probe(&[0.5, -1.5]).is_err());
837    }
838
839    #[test]
840    fn urls_split_between_openai_and_native_bases() {
841        assert_eq!(
842            request_url(Probe::CompletionsTopK, "http://host:8000/v1/", "http://host:8000"),
843            "http://host:8000/v1/completions"
844        );
845        assert_eq!(
846            request_url(Probe::LlamaCppNProbs, "http://host:8080/v1", "http://host:8080/"),
847            "http://host:8080/completion"
848        );
849    }
850}