1use anyhow::{bail, Context, Result};
13use serde_json::{json, Value};
14
15pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
16pub const DEFAULT_MODEL: &str = "gpt-4o-mini";
17
18pub const TOP_LOGPROBS: u32 = 3;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum LogprobsParam {
24 BoolWithTop,
26 Integer,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Provider {
33 OpenAi,
34 OpenRouter,
35 Together,
36 Vllm,
37 Ollama,
38}
39
40impl Provider {
41 pub const ALL: [Provider; 5] = [
42 Provider::OpenAi,
43 Provider::OpenRouter,
44 Provider::Together,
45 Provider::Vllm,
46 Provider::Ollama,
47 ];
48
49 pub fn name(self) -> &'static str {
50 match self {
51 Provider::OpenAi => "openai",
52 Provider::OpenRouter => "openrouter",
53 Provider::Together => "together",
54 Provider::Vllm => "vllm",
55 Provider::Ollama => "ollama",
56 }
57 }
58
59 pub fn from_name(name: &str) -> Option<Provider> {
60 let n = name.trim().to_ascii_lowercase();
61 Provider::ALL.into_iter().find(|p| p.name() == n)
62 }
63
64 pub fn default_base_url(self) -> &'static str {
66 match self {
67 Provider::OpenAi => DEFAULT_BASE_URL,
68 Provider::OpenRouter => "https://openrouter.ai/api/v1",
69 Provider::Together => "https://api.together.ai/v1",
70 Provider::Vllm => "http://localhost:8000/v1",
71 Provider::Ollama => "http://localhost:11434/v1",
72 }
73 }
74
75 pub fn key_env(self) -> Option<(&'static str, bool)> {
78 match self {
79 Provider::OpenAi => Some(("OPENAI_API_KEY", true)),
80 Provider::OpenRouter => Some(("OPENROUTER_API_KEY", true)),
81 Provider::Together => Some(("TOGETHER_API_KEY", true)),
82 Provider::Vllm => Some(("VLLM_API_KEY", false)),
83 Provider::Ollama => None,
84 }
85 }
86
87 pub fn default_model(self) -> Option<&'static str> {
90 match self {
91 Provider::OpenAi => Some(DEFAULT_MODEL),
92 Provider::OpenRouter => Some("openai/gpt-4o-mini"),
93 Provider::Together | Provider::Vllm | Provider::Ollama => None,
94 }
95 }
96
97 pub fn model_hint(self) -> &'static str {
99 match self {
100 Provider::Together => {
101 "a Together model id (GET https://api.together.ai/v1/models lists them)"
102 }
103 Provider::Vllm => "the model the server was started with (see GET /v1/models)",
104 Provider::Ollama => {
105 "a model you have pulled, as listed by `ollama list`, e.g. qwen2.5-coder:14b"
106 }
107 _ => "a model id",
108 }
109 }
110
111 pub fn logprobs_param(self) -> LogprobsParam {
112 match self {
113 Provider::Together => LogprobsParam::Integer,
114 _ => LogprobsParam::BoolWithTop,
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct Target {
122 pub provider: Provider,
123 pub base_url: String,
124 pub model: String,
125 pub api_key: Option<String>,
126}
127
128impl Target {
129 pub fn resolve(
136 provider: Provider,
137 base_url: Option<&str>,
138 model: Option<&str>,
139 env: impl Fn(&str) -> Option<String>,
140 ) -> Result<Target> {
141 let base_url = match base_url {
142 Some(u) => u.to_string(),
143 None if provider == Provider::OpenAi => {
144 env("OPENAI_BASE_URL").unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
145 }
146 None => provider.default_base_url().to_string(),
147 };
148 let base_url = base_url.trim().trim_end_matches('/').to_string();
149 if base_url.is_empty() {
150 bail!("--base-url is empty");
151 }
152
153 let model = match model.or(provider.default_model()) {
154 Some(m) => m.to_string(),
155 None => bail!(
156 "--provider {} needs --model: {}",
157 provider.name(),
158 provider.model_hint()
159 ),
160 };
161
162 let api_key = match provider.key_env() {
163 Some((var, required)) => match env(var).filter(|k| !k.trim().is_empty()) {
164 Some(k) => Some(k),
165 None if required => bail!(
166 "--provider {} needs {} in the environment",
167 provider.name(),
168 var
169 ),
170 None => None,
171 },
172 None => None,
173 };
174
175 Ok(Target {
176 provider,
177 base_url,
178 model,
179 api_key,
180 })
181 }
182
183 pub fn url(&self) -> String {
184 format!("{}/chat/completions", self.base_url)
185 }
186
187 pub fn body(&self, prompt: &str, max_tokens: u32) -> Value {
189 let mut b = json!({
190 "model": self.model,
191 "messages": [{"role": "user", "content": prompt}],
192 "max_tokens": max_tokens,
193 "temperature": 0,
194 });
195 match self.provider.logprobs_param() {
196 LogprobsParam::BoolWithTop => {
197 b["logprobs"] = json!(true);
198 b["top_logprobs"] = json!(TOP_LOGPROBS);
199 }
200 LogprobsParam::Integer => {
201 b["logprobs"] = json!(TOP_LOGPROBS);
202 }
203 }
204 if self.provider == Provider::OpenRouter {
205 b["provider"] = json!({"require_parameters": true});
208 }
209 b
210 }
211}
212
213pub fn request_body(prompt: &str, model: &str, max_tokens: u32) -> Value {
215 Target {
216 provider: Provider::OpenAi,
217 base_url: DEFAULT_BASE_URL.to_string(),
218 model: model.to_string(),
219 api_key: None,
220 }
221 .body(prompt, max_tokens)
222}
223
224pub fn check_has_logprobs(raw: &str, target: &Target) -> Result<()> {
227 let v: Value = serde_json::from_str(raw).context("the API response is not JSON")?;
228 if let Some(err) = v.get("error") {
229 bail!("{} returned an error: {}", target.url(), err);
230 }
231 let choice = v.pointer("/choices/0");
232 let lp = choice.and_then(|c| c.get("logprobs"));
233 let has = match lp {
234 Some(Value::Object(o)) => {
235 o.get("content")
236 .and_then(Value::as_array)
237 .is_some_and(|a| !a.is_empty())
238 || o.get("tokens")
239 .and_then(Value::as_array)
240 .is_some_and(|a| !a.is_empty())
241 }
242 Some(Value::Array(a)) => !a.is_empty(),
243 _ => false,
244 };
245 if has {
246 return Ok(());
247 }
248 let answer = choice
249 .and_then(|c| c.pointer("/message/content"))
250 .and_then(Value::as_str)
251 .unwrap_or("");
252 let mut msg = format!(
253 "{} (model {}) answered, but returned no token logprobs, so there is nothing to analyze.",
254 target.provider.name(),
255 target.model
256 );
257 msg.push_str(match target.provider {
258 Provider::Ollama => {
259 " Ollama returns logprobs on its OpenAI-compatible endpoint in recent versions; update Ollama and try again."
260 }
261 Provider::OpenRouter => {
262 " Pick a model whose upstream provider supports logprobs (the model's page on openrouter.ai lists supported parameters)."
263 }
264 Provider::OpenAi => " Not every model returns logprobs; OpenAI's logprobs examples use gpt-4o-mini.",
265 _ => " This model or server may not support logprobs.",
266 });
267 if !answer.is_empty() {
268 let short: String = answer.chars().take(200).collect();
269 msg.push_str(&format!("\nThe answer was: {short}"));
270 }
271 bail!(msg)
272}
273
274pub fn fetch_target(target: &Target, prompt: &str, max_tokens: u32) -> Result<String> {
276 let url = target.url();
277 let mut req = ureq::post(&url).set("Content-Type", "application/json");
278 if let Some(key) = &target.api_key {
279 req = req.set("Authorization", &format!("Bearer {}", key));
280 }
281 if target.provider == Provider::OpenRouter {
282 req = req
283 .set(
284 "HTTP-Referer",
285 "https://gitlab.com/mattbusel/LLM-Hallucination-Detection-Script",
286 )
287 .set("X-Title", "llm-token-visualizer");
288 }
289 let resp = req.send_string(&target.body(prompt, max_tokens).to_string());
290
291 match resp {
292 Ok(r) => r.into_string().context("could not read the API response"),
293 Err(ureq::Error::Status(code, r)) => {
294 let body = r.into_string().unwrap_or_default();
295 bail!("{} returned HTTP {}: {}", url, code, body.trim())
296 }
297 Err(e) => {
298 let local = matches!(target.provider, Provider::Ollama | Provider::Vllm);
299 if local {
300 bail!(
301 "request to {} failed: {} (is the {} server running?)",
302 url,
303 e,
304 target.provider.name()
305 )
306 }
307 Err(anyhow::anyhow!("request to {} failed: {}", url, e))
308 }
309 }
310}
311
312pub fn fetch(prompt: &str, model: &str, max_tokens: u32) -> Result<String> {
314 let t = Target::resolve(Provider::OpenAi, None, Some(model), |k| {
315 std::env::var(k).ok()
316 })?;
317 fetch_target(&t, prompt, max_tokens)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::collections::HashMap;
324
325 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
326 let m: HashMap<String, String> = pairs
327 .iter()
328 .map(|(k, v)| (k.to_string(), v.to_string()))
329 .collect();
330 move |k| m.get(k).cloned()
331 }
332
333 #[test]
334 fn request_asks_for_logprobs() {
335 let b = request_body("hi", "m", 50);
336 assert_eq!(b["logprobs"], true);
337 assert_eq!(b["top_logprobs"], 3);
338 assert_eq!(b["messages"][0]["content"], "hi");
339 assert_eq!(b["model"], "m");
340 assert_eq!(b["temperature"], 0);
341 assert!(b.get("provider").is_none());
342 }
343
344 #[test]
345 fn provider_names_round_trip() {
346 for p in Provider::ALL {
347 assert_eq!(Provider::from_name(p.name()), Some(p));
348 }
349 assert_eq!(
350 Provider::from_name("OpenRouter"),
351 Some(Provider::OpenRouter)
352 );
353 assert_eq!(Provider::from_name("anthropic"), None);
354 }
355
356 #[test]
357 fn openai_default_is_backwards_compatible() {
358 let t = Target::resolve(
359 Provider::OpenAi,
360 None,
361 None,
362 env(&[("OPENAI_API_KEY", "sk")]),
363 )
364 .unwrap();
365 assert_eq!(t.url(), "https://api.openai.com/v1/chat/completions");
366 assert_eq!(t.model, "gpt-4o-mini");
367 assert_eq!(t.api_key.as_deref(), Some("sk"));
368
369 let t = Target::resolve(
370 Provider::OpenAi,
371 None,
372 None,
373 env(&[
374 ("OPENAI_API_KEY", "sk"),
375 ("OPENAI_BASE_URL", "https://x.test/v1/"),
376 ]),
377 )
378 .unwrap();
379 assert_eq!(t.url(), "https://x.test/v1/chat/completions");
380
381 let e = Target::resolve(Provider::OpenAi, None, None, env(&[])).unwrap_err();
382 assert!(e.to_string().contains("OPENAI_API_KEY"), "{e}");
383 }
384
385 #[test]
386 fn base_url_flag_beats_env_and_preset() {
387 let t = Target::resolve(
388 Provider::OpenAi,
389 Some("http://h:1/v1/"),
390 None,
391 env(&[
392 ("OPENAI_API_KEY", "sk"),
393 ("OPENAI_BASE_URL", "https://x.test/v1"),
394 ]),
395 )
396 .unwrap();
397 assert_eq!(t.url(), "http://h:1/v1/chat/completions");
398 let t = Target::resolve(
399 Provider::Ollama,
400 Some("http://box:11434/v1"),
401 Some("m"),
402 env(&[]),
403 )
404 .unwrap();
405 assert_eq!(t.url(), "http://box:11434/v1/chat/completions");
406 }
407
408 #[test]
409 fn openrouter_request() {
410 let t = Target::resolve(
411 Provider::OpenRouter,
412 None,
413 None,
414 env(&[
415 ("OPENROUTER_API_KEY", "or"),
416 ("OPENAI_BASE_URL", "https://ignored"),
417 ]),
418 )
419 .unwrap();
420 assert_eq!(t.url(), "https://openrouter.ai/api/v1/chat/completions");
421 assert_eq!(t.model, "openai/gpt-4o-mini");
422 assert_eq!(t.api_key.as_deref(), Some("or"));
423 let b = t.body("q", 10);
424 assert_eq!(b["logprobs"], true);
425 assert_eq!(b["top_logprobs"], 3);
426 assert_eq!(b["provider"]["require_parameters"], true);
427 let e = Target::resolve(Provider::OpenRouter, None, None, env(&[])).unwrap_err();
428 assert!(e.to_string().contains("OPENROUTER_API_KEY"), "{e}");
429 }
430
431 #[test]
432 fn together_request_uses_integer_logprobs() {
433 let t = Target::resolve(
434 Provider::Together,
435 None,
436 Some("some/model"),
437 env(&[("TOGETHER_API_KEY", "tg")]),
438 )
439 .unwrap();
440 assert_eq!(t.url(), "https://api.together.ai/v1/chat/completions");
441 let b = t.body("q", 10);
442 assert_eq!(b["logprobs"], 3);
443 assert!(b.get("top_logprobs").is_none());
444 let e = Target::resolve(
445 Provider::Together,
446 None,
447 None,
448 env(&[("TOGETHER_API_KEY", "tg")]),
449 )
450 .unwrap_err();
451 assert!(e.to_string().contains("--model"), "{e}");
452 }
453
454 #[test]
455 fn local_providers_need_no_key() {
456 let t =
457 Target::resolve(Provider::Ollama, None, Some("qwen2.5-coder:14b"), env(&[])).unwrap();
458 assert_eq!(t.url(), "http://localhost:11434/v1/chat/completions");
459 assert_eq!(t.api_key, None);
460 assert_eq!(t.body("q", 5)["logprobs"], true);
461
462 let t = Target::resolve(Provider::Vllm, None, Some("m"), env(&[])).unwrap();
463 assert_eq!(t.url(), "http://localhost:8000/v1/chat/completions");
464 assert_eq!(t.api_key, None);
465 let t = Target::resolve(
466 Provider::Vllm,
467 None,
468 Some("m"),
469 env(&[("VLLM_API_KEY", "v")]),
470 )
471 .unwrap();
472 assert_eq!(t.api_key.as_deref(), Some("v"));
473
474 let e = Target::resolve(Provider::Ollama, None, None, env(&[])).unwrap_err();
475 assert!(e.to_string().contains("ollama list"), "{e}");
476 }
477
478 fn target(p: Provider) -> Target {
479 Target {
480 provider: p,
481 base_url: p.default_base_url().into(),
482 model: "m".into(),
483 api_key: None,
484 }
485 }
486
487 #[test]
488 fn missing_logprobs_is_explained() {
489 let raw = r#"{"choices":[{"message":{"content":"Rembrandt."},"logprobs":null}]}"#;
490 let e = check_has_logprobs(raw, &target(Provider::Ollama))
491 .unwrap_err()
492 .to_string();
493 assert!(e.contains("no token logprobs"), "{e}");
494 assert!(e.contains("Rembrandt."), "{e}");
495 let raw = r#"{"choices":[{"message":{"content":"x"}}]}"#;
496 assert!(check_has_logprobs(raw, &target(Provider::OpenRouter)).is_err());
497 let raw = r#"{"choices":[{"logprobs":{"content":[]}}]}"#;
498 assert!(check_has_logprobs(raw, &target(Provider::OpenAi)).is_err());
499 }
500
501 #[test]
502 fn present_logprobs_pass_the_check() {
503 let raw = r#"{"choices":[{"logprobs":{"content":[{"token":"a","logprob":-0.1}]}}]}"#;
504 assert!(check_has_logprobs(raw, &target(Provider::OpenAi)).is_ok());
505 let raw = r#"{"choices":[{"logprobs":{"tokens":["a"],"token_logprobs":[-0.1]}}]}"#;
506 assert!(check_has_logprobs(raw, &target(Provider::Together)).is_ok());
507 }
508}