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 {
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
129const FULL_VOCABULARY: usize = 200_000;
134
135pub 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#[derive(Debug, Serialize)]
168#[serde(untagged)]
169pub enum PromptField {
170 Single(String),
172 Batch(Vec<String>),
174}
175
176#[derive(Debug, Serialize)]
182pub struct OpenAiCompletionRequest {
183 model: String,
184 prompt: PromptField,
185 max_tokens: u32,
187 temperature: f32,
189 logprobs: usize,
191 #[serde(skip_serializing_if = "Option::is_none")]
194 return_tokens_as_token_ids: Option<bool>,
195 #[serde(skip_serializing_if = "Option::is_none")]
198 allowed_token_ids: Option<Vec<u32>>,
199 #[serde(skip_serializing_if = "Option::is_none")]
202 logprob_token_ids: Option<Vec<u32>>,
203}
204
205#[derive(Debug, Serialize)]
212pub struct LlamaCppCompletionRequest {
213 model: String,
214 prompt: String,
215 n_predict: u32,
216 n_probs: usize,
218 cache_prompt: bool,
219 temperature: f32,
220 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#[derive(Debug, Serialize)]
232#[serde(untagged)]
233pub enum RequestBody {
234 OpenAi(OpenAiCompletionRequest),
236 LlamaCpp(LlamaCppCompletionRequest),
238}
239
240pub 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
250pub 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
314pub fn request_url(probe: Probe, openai_base: &str, native_base: &str) -> String {
316 probe.url(openai_base, native_base)
317}
318
319#[derive(Debug, Clone, PartialEq)]
321pub struct Candidate {
322 pub id: Option<u32>,
324 pub token: String,
326 pub logprob: f64,
328}
329
330#[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#[derive(Debug, Deserialize)]
354#[serde(untagged)]
355enum CandidateList {
356 Entries(Vec<CandidateEntry>),
357 Map(serde_json::Map<String, Value>),
358}
359
360#[derive(Debug, Deserialize)]
362struct NativePosition {
363 #[serde(default)]
364 top_logprobs: Option<CandidateList>,
365 #[serde(default)]
368 top_probs: Option<CandidateList>,
369}
370
371#[derive(Debug, Deserialize)]
373struct ChoiceLogprobs {
374 #[serde(default)]
376 top_logprobs: Vec<CandidateList>,
377 #[serde(default)]
379 content: Vec<NativePosition>,
380}
381
382#[derive(Debug, Deserialize)]
383struct Choice {
384 #[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#[derive(Debug, Deserialize)]
406pub struct CompletionResponse {
407 #[serde(default)]
408 choices: Vec<Choice>,
409 #[serde(default)]
410 usage: Option<Usage>,
411 #[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
437fn 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
467fn 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 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
514pub 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
520pub 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
534pub 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 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
599pub 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
628pub 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
642pub 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
656pub 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
668pub fn truncated(probe: Probe, response: &CompletionResponse) -> bool {
673 match probe {
674 Probe::LlamaCppNProbs => response.truncated,
675 _ => false,
676 }
677}
678
679pub 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 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}