use anyhow::{Context, Result};
use clap::Parser;
use std::io::Read;
use std::path::PathBuf;
use llm_token_visualizer::detect;
use llm_token_visualizer::report;
use llm_token_visualizer::{
HtmlRenderer, MarkdownRenderer, Renderer, TerminalRenderer, TokenAnalysis, VisualizationConfig,
};
#[derive(Parser)]
#[command(name = "llm-token-visualizer", version)]
#[command(after_help = "Examples:
llm-token-visualizer --logprobs-file answer.json
llm-token-visualizer --logprobs-file answer.json --format html -o report.html
llm-token-visualizer --logprobs-file answer.json --format markdown >> $GITHUB_STEP_SUMMARY
llm-token-visualizer --live \"Who painted The Night Watch?\" --save answer.json
llm-token-visualizer --logprobs-file answer.json --fail-on-flag --format json
Sample responses with logprobs are in examples/logprobs/ (samples/ in release archives).")]
#[command(
about = "Flag low-confidence spans in LLM output from token logprobs, and visualize per-token confidence"
)]
struct Args {
#[arg(long, value_name = "PATH")]
logprobs_file: Option<PathBuf>,
#[arg(long, value_name = "PROMPT")]
live: Option<String>,
#[arg(long, default_value = "gpt-4o-mini")]
model: String,
#[arg(long, default_value_t = 200)]
max_tokens: u32,
#[arg(long, value_name = "PATH")]
save: Option<PathBuf>,
#[arg(long, default_value_t = detect::DEFAULT_THRESHOLD)]
threshold: f64,
#[arg(long)]
fail_on_flag: bool,
#[arg(short, long)]
text: Option<String>,
#[arg(long)]
text_file: Option<PathBuf>,
#[arg(short, long)]
confidence: Option<String>,
#[arg(long)]
confidence_file: Option<PathBuf>,
#[arg(short, long, default_value = "terminal")]
format: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
verbose: bool,
#[arg(long)]
demo: bool,
}
fn main() {
match run() {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("error: {:#}", e);
std::process::exit(1);
}
}
}
fn run() -> Result<i32> {
let args = Args::parse();
if !(0.0..=1.0).contains(&args.threshold) {
anyhow::bail!("--threshold must be between 0 and 1");
}
if args.logprobs_file.is_some() || args.live.is_some() {
return run_detect(&args);
}
let (text, token_analysis) = if args.demo {
load_demo_data()?
} else {
let text = load_text(&args)?;
let token_analysis = load_token_analysis(&args)?;
(text, token_analysis)
};
render(&args, &text, &token_analysis)?;
Ok(0)
}
fn render(args: &Args, text: &str, analysis: &TokenAnalysis) -> Result<()> {
let config = VisualizationConfig {
verbose: args.verbose,
show_confidence_scores: true,
show_flags: true,
};
let output = match args.format.as_str() {
"terminal" => TerminalRenderer::new().render(text, analysis, &config)?,
"html" => HtmlRenderer::new().render(text, analysis, &config)?,
"markdown" => MarkdownRenderer::new().render(text, analysis, &config)?,
"json" => anyhow::bail!("--format json needs --logprobs-file or --live"),
_ => anyhow::bail!("Unsupported format: {}", args.format),
};
write_output(args, &output)
}
fn write_output(args: &Args, output: &str) -> Result<()> {
match (&args.output, args.format.as_str()) {
(Some(path), f) if f != "terminal" => std::fs::write(path, output)
.with_context(|| format!("could not write {}", path.display())),
_ => {
println!("{}", output);
Ok(())
}
}
}
fn run_detect(args: &Args) -> Result<i32> {
let json = if let Some(prompt) = &args.live {
let raw = fetch_live(prompt, args)?;
if let Some(path) = &args.save {
std::fs::write(path, &raw)
.with_context(|| format!("could not write {}", path.display()))?;
}
raw
} else {
let path = args.logprobs_file.as_ref().expect("checked by caller");
if path.as_os_str() == "-" {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
} else {
std::fs::read_to_string(path)
.with_context(|| format!("could not read {}", path.display()))?
}
};
let tokens = detect::parse_logprobs(&json)?;
let report = detect::detect(&tokens, args.threshold);
let source = match (&args.live, &args.logprobs_file) {
(Some(_), _) => "live".to_string(),
(None, Some(p)) if p.as_os_str() == "-" => "stdin".to_string(),
(None, Some(p)) => p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string()),
(None, None) => unreachable!("checked by caller"),
};
let meta = report::Meta::from_response(source, &json);
let output = match args.format.as_str() {
"json" => serde_json::to_string_pretty(&report)?,
"html" => report::html(&tokens, &report, &meta),
"markdown" => report::markdown(&tokens, &report, &meta),
"terminal" => {
let mut out = report::terminal(&tokens, &report, &meta);
if args.verbose {
out.push_str(
"
token p alternatives
",
);
for (i, t) in tokens.iter().enumerate() {
let alts: Vec<String> = t
.top_logprobs
.iter()
.filter(|a| a.token != t.token)
.map(|a| format!("{:?} {:.2}", a.token, a.logprob.exp()))
.collect();
out.push_str(&format!(
" {:>3} {:<18} {:.3} {}
",
i,
format!("{:?}", t.token),
t.prob(),
alts.join(", ")
));
}
}
out
}
f => anyhow::bail!("unsupported format {f:?}; use terminal, html, markdown or json"),
};
write_output(args, &output)?;
Ok(if args.fail_on_flag && report.flagged() {
2
} else {
0
})
}
#[cfg(feature = "live")]
fn fetch_live(prompt: &str, args: &Args) -> Result<String> {
llm_token_visualizer::live::fetch(prompt, &args.model, args.max_tokens)
}
#[cfg(not(feature = "live"))]
fn fetch_live(_prompt: &str, _args: &Args) -> Result<String> {
anyhow::bail!("this build has no live mode; rebuild with the default \"live\" feature")
}
fn load_text(args: &Args) -> Result<String> {
if let Some(text) = &args.text {
Ok(text.clone())
} else if let Some(path) = &args.text_file {
Ok(std::fs::read_to_string(path)?)
} else {
anyhow::bail!("Either --text or --text-file must be provided")
}
}
fn load_token_analysis(args: &Args) -> Result<TokenAnalysis> {
let json_str = if let Some(confidence) = &args.confidence {
confidence.clone()
} else if let Some(path) = &args.confidence_file {
std::fs::read_to_string(path)?
} else {
anyhow::bail!("Either --confidence or --confidence-file must be provided")
};
Ok(serde_json::from_str(&json_str)?)
}
fn load_demo_data() -> Result<(String, TokenAnalysis)> {
let text = "The Eiffel Tower was built in 1889 and stands 324 meters tall. It's located in Paris, France, and was designed by Gustave Eiffel. The tower has three levels and receives millions of visitors each year.".to_string();
let demo_json = r#"{
"tokens": [
{"text": "The", "confidence": 0.95},
{"text": " Eiffel", "confidence": 0.92},
{"text": " Tower", "confidence": 0.94},
{"text": " was", "confidence": 0.88},
{"text": " built", "confidence": 0.85},
{"text": " in", "confidence": 0.91},
{"text": " 1889", "confidence": 0.97},
{"text": " and", "confidence": 0.89},
{"text": " stands", "confidence": 0.86},
{"text": " 324", "confidence": 0.98},
{"text": " meters", "confidence": 0.96},
{"text": " tall", "confidence": 0.87},
{"text": ".", "confidence": 0.99},
{"text": " It", "confidence": 0.93},
{"text": "'s", "confidence": 0.91},
{"text": " located", "confidence": 0.88},
{"text": " in", "confidence": 0.94},
{"text": " Paris", "confidence": 0.96},
{"text": ",", "confidence": 0.99},
{"text": " France", "confidence": 0.95},
{"text": ",", "confidence": 0.99},
{"text": " and", "confidence": 0.90},
{"text": " was", "confidence": 0.87},
{"text": " designed", "confidence": 0.89},
{"text": " by", "confidence": 0.92},
{"text": " Gustave", "confidence": 0.94},
{"text": " Eiffel", "confidence": 0.96},
{"text": ".", "confidence": 0.99},
{"text": " The", "confidence": 0.91},
{"text": " tower", "confidence": 0.88},
{"text": " has", "confidence": 0.85},
{"text": " three", "confidence": 0.93},
{"text": " levels", "confidence": 0.89},
{"text": " and", "confidence": 0.87},
{"text": " receives", "confidence": 0.84},
{"text": " millions", "confidence": 0.82},
{"text": " of", "confidence": 0.90},
{"text": " visitors", "confidence": 0.86},
{"text": " each", "confidence": 0.88},
{"text": " year", "confidence": 0.85},
{"text": ".", "confidence": 0.99}
],
"flags": [
{"start": 6, "end": 7, "flag": "fact", "description": "Historical date - high confidence"},
{"start": 9, "end": 11, "flag": "fact", "description": "Physical measurement - verifiable"},
{"start": 35, "end": 36, "flag": "uncertain", "description": "Estimate without specific data"},
{"start": 25, "end": 27, "flag": "fact", "description": "Historical attribution"}
]
}"#;
let token_analysis: TokenAnalysis = serde_json::from_str(demo_json)?;
Ok((text, token_analysis))
}