1use crate::providers::Provider;
8use clap::Parser;
9
10#[derive(Parser)]
11#[command(name = "every-other-token")]
12#[command(version = "4.0.0")]
13#[command(about = "A real-time token stream mutator for LLM interpretability research")]
14pub struct Args {
15 #[arg(default_value = "")]
17 pub prompt: String,
18
19 #[arg(default_value = "reverse")]
21 pub transform: String,
22
23 #[arg(default_value = "gpt-3.5-turbo")]
25 pub model: String,
26
27 #[arg(long, value_enum, default_value = "openai")]
29 pub provider: Provider,
30
31 #[arg(long, short)]
33 pub visual: bool,
34
35 #[arg(long)]
37 pub heatmap: bool,
38
39 #[arg(long)]
41 pub orchestrator: bool,
42
43 #[arg(long)]
45 pub web: bool,
46
47 #[arg(long, default_value = "8888")]
49 pub port: u16,
50
51 #[arg(long)]
53 pub research: bool,
54
55 #[arg(long, default_value = "10")]
57 pub runs: u32,
58
59 #[arg(long, default_value = "research_output.json")]
61 pub output: String,
62
63 #[arg(long)]
65 pub system_a: Option<String>,
66
67 #[arg(long, default_value = "5")]
69 pub top_logprobs: u8,
70
71 #[arg(long)]
73 pub system_b: Option<String>,
74
75 #[arg(long)]
77 pub db: Option<String>,
78
79 #[arg(long)]
81 pub significance: bool,
82
83 #[arg(long)]
85 pub heatmap_export: Option<String>,
86
87 #[arg(long, default_value = "0.0")]
89 pub heatmap_min_confidence: f32,
90
91 #[arg(long, default_value = "position")]
93 pub heatmap_sort_by: String,
94
95 #[arg(long)]
97 pub record: Option<String>,
98
99 #[arg(long)]
101 pub replay: Option<String>,
102
103 #[arg(long)]
112 pub rate: Option<f64>,
113
114 #[arg(long)]
117 pub seed: Option<u64>,
118
119 #[arg(long)]
121 pub log_db: Option<String>,
122
123 #[arg(long)]
125 pub baseline: bool,
126
127 #[arg(long)]
129 pub prompt_file: Option<String>,
130
131 #[arg(long)]
133 pub diff_terminal: bool,
134
135 #[arg(long)]
137 pub json_stream: bool,
138
139 #[arg(long, value_name = "SHELL")]
141 pub completions: Option<clap_complete::Shell>,
142
143 #[cfg(feature = "helix-bridge")]
149 #[arg(long)]
150 pub helix_url: Option<String>,
151
152 #[arg(long)]
156 pub rate_range: Option<String>,
157
158 #[arg(long)]
161 pub dry_run: bool,
162
163 #[arg(long)]
166 pub template: Option<String>,
167
168 #[arg(long)]
173 pub min_confidence: Option<f64>,
174
175 #[arg(long, default_value = "json")]
177 pub format: String,
178
179 #[arg(long, default_value = "5")]
182 pub collapse_window: usize,
183
184 #[arg(long, default_value = "http://localhost:3000")]
186 pub orchestrator_url: String,
187
188 #[arg(long, default_value = "3")]
190 pub max_retries: u32,
191
192 #[arg(long, default_value = "4096")]
195 pub anthropic_max_tokens: u32,
196
197 #[arg(long)]
200 pub synonym_file: Option<String>,
201
202 #[arg(long)]
205 pub api_key: Option<String>,
206
207 #[arg(long, default_value = "1.0")]
209 pub replay_speed: f64,
210
211 #[arg(long, default_value = "120")]
214 pub timeout: u64,
215
216 #[arg(long)]
219 pub export_timeseries: Option<String>,
220
221 #[arg(long)]
223 pub json_schema: bool,
224
225 #[arg(long)]
227 pub list_models: Option<String>,
228
229 #[arg(long)]
231 pub validate_config: bool,
232}
233
234pub fn resolve_model(provider: &Provider, model: &str) -> String {
237 match provider {
238 Provider::Anthropic if model == "gpt-3.5-turbo" => "claude-sonnet-4-6".to_string(),
239 Provider::Mock => "mock-fixture-v1".to_string(),
240 _ => model.to_string(),
241 }
242}
243
244const KNOWN_OPENAI_MODELS: &[&str] = &[
249 "gpt-3.5-turbo",
250 "gpt-3.5-turbo-0125",
251 "gpt-4",
252 "gpt-4-turbo",
253 "gpt-4o",
254 "gpt-4o-mini",
255 "gpt-4.1",
256 "gpt-4.1-mini",
257 "o1",
258 "o1-mini",
259 "o3",
260 "o3-mini",
261];
262
263const KNOWN_ANTHROPIC_MODELS: &[&str] = &[
264 "claude-3-haiku-20240307",
265 "claude-3-sonnet-20240229",
266 "claude-3-opus-20240229",
267 "claude-3-5-sonnet-20241022",
268 "claude-3-5-haiku-20241022",
269 "claude-haiku-4-5-20251001",
270 "claude-sonnet-4-6",
271 "claude-opus-4-6",
272];
273
274pub fn validate_model(provider: &Provider, model: &str) {
277 let known: &[&str] = match provider {
278 Provider::Openai => KNOWN_OPENAI_MODELS,
279 Provider::Anthropic => KNOWN_ANTHROPIC_MODELS,
280 Provider::Mock => return,
281 };
282 if !known.contains(&model) {
283 eprintln!(
284 "[warn] '{}' is not in the known {} model list — verify the model name is correct",
285 model, provider
286 );
287 }
288}
289
290pub fn parse_rate_range(s: &str) -> Option<(f64, f64)> {
295 let sep = s.rfind('-')?;
296 let min = s[..sep].parse::<f64>().ok()?;
297 let max = s[sep + 1..].parse::<f64>().ok()?;
298 if min <= max && min >= 0.0 && max <= 1.0 {
299 Some((min, max))
300 } else {
301 None
302 }
303}
304
305pub fn apply_template(template: &str, prompt: &str) -> String {
311 template.split("{input}").collect::<Vec<_>>().join(prompt)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_resolve_model_anthropic_default_swap() {
322 assert_eq!(
323 resolve_model(&Provider::Anthropic, "gpt-3.5-turbo"),
324 "claude-sonnet-4-6"
325 );
326 }
327
328 #[test]
329 fn test_resolve_model_anthropic_explicit_model_kept() {
330 assert_eq!(
331 resolve_model(&Provider::Anthropic, "claude-haiku-4-5-20251001"),
332 "claude-haiku-4-5-20251001"
333 );
334 }
335
336 #[test]
337 fn test_resolve_model_openai_default_kept() {
338 assert_eq!(
339 resolve_model(&Provider::Openai, "gpt-3.5-turbo"),
340 "gpt-3.5-turbo"
341 );
342 }
343
344 #[test]
345 fn test_resolve_model_openai_explicit_model_kept() {
346 assert_eq!(resolve_model(&Provider::Openai, "gpt-4"), "gpt-4");
347 }
348
349 #[test]
350 fn test_args_parse_minimal() {
351 let args = Args::parse_from(["eot", "hello world"]);
352 assert_eq!(args.prompt, "hello world");
353 assert_eq!(args.transform, "reverse");
354 assert_eq!(args.model, "gpt-3.5-turbo");
355 assert_eq!(args.provider, Provider::Openai);
356 assert!(!args.visual);
357 assert!(!args.heatmap);
358 assert!(!args.orchestrator);
359 assert!(!args.web);
360 assert_eq!(args.port, 8888);
361 assert_eq!(args.top_logprobs, 5);
362 }
363
364 #[test]
365 fn test_args_parse_full() {
366 let args = Args::parse_from([
367 "eot",
368 "test prompt",
369 "uppercase",
370 "gpt-4",
371 "--provider",
372 "anthropic",
373 "--visual",
374 "--heatmap",
375 "--orchestrator",
376 "--web",
377 "--port",
378 "9000",
379 ]);
380 assert_eq!(args.prompt, "test prompt");
381 assert_eq!(args.transform, "uppercase");
382 assert_eq!(args.model, "gpt-4");
383 assert_eq!(args.provider, Provider::Anthropic);
384 assert!(args.visual);
385 assert!(args.heatmap);
386 assert!(args.orchestrator);
387 assert!(args.web);
388 assert_eq!(args.port, 9000);
389 }
390
391 #[test]
392 fn test_args_parse_provider_openai() {
393 let args = Args::parse_from(["eot", "prompt", "--provider", "openai"]);
394 assert_eq!(args.provider, Provider::Openai);
395 }
396
397 #[test]
398 fn test_args_parse_provider_anthropic() {
399 let args = Args::parse_from(["eot", "prompt", "--provider", "anthropic"]);
400 assert_eq!(args.provider, Provider::Anthropic);
401 }
402
403 #[test]
404 fn test_args_parse_short_visual() {
405 let args = Args::parse_from(["eot", "prompt", "-v"]);
406 assert!(args.visual);
407 }
408
409 #[test]
410 fn test_args_default_port() {
411 let args = Args::parse_from(["eot", "prompt"]);
412 assert_eq!(args.port, 8888);
413 }
414
415 #[test]
416 fn test_args_custom_port() {
417 let args = Args::parse_from(["eot", "prompt", "--port", "3000"]);
418 assert_eq!(args.port, 3000);
419 }
420
421 #[test]
422 fn test_args_research_flag_default_false() {
423 let args = Args::parse_from(["eot", "prompt"]);
424 assert!(!args.research);
425 }
426
427 #[test]
428 fn test_args_research_flag_set() {
429 let args = Args::parse_from(["eot", "prompt", "--research"]);
430 assert!(args.research);
431 }
432
433 #[test]
434 fn test_args_runs_default_one() {
435 let args = Args::parse_from(["eot", "prompt"]);
436 assert_eq!(args.runs, 10);
437 }
438
439 #[test]
440 fn test_args_runs_custom() {
441 let args = Args::parse_from(["eot", "prompt", "--runs", "50"]);
442 assert_eq!(args.runs, 50);
443 }
444
445 #[test]
446 fn test_args_output_default_none() {
447 let args = Args::parse_from(["eot", "prompt"]);
448 assert_eq!(args.output, "research_output.json");
449 }
450
451 #[test]
452 fn test_args_output_custom() {
453 let args = Args::parse_from(["eot", "prompt", "--output", "results.json"]);
454 assert_eq!(args.output, "results.json");
455 }
456
457 #[test]
458 fn test_args_system_prompt_default_none() {
459 let args = Args::parse_from(["eot", "prompt"]);
460 assert!(args.system_a.is_none());
461 }
462
463 #[test]
464 fn test_args_system_prompt_set() {
465 let args = Args::parse_from(["eot", "prompt", "--system-a", "Be concise."]);
466 assert_eq!(args.system_a.as_deref(), Some("Be concise."));
467 }
468
469 #[test]
470 fn test_args_research_with_runs_and_output() {
471 let args = Args::parse_from([
472 "eot",
473 "test prompt",
474 "--research",
475 "--runs",
476 "100",
477 "--output",
478 "out.json",
479 ]);
480 assert!(args.research);
481 assert_eq!(args.runs, 100);
482 assert_eq!(args.output, "out.json");
483 }
484
485 #[test]
486 fn test_args_research_does_not_require_web() {
487 let args = Args::parse_from(["eot", "prompt", "--research"]);
488 assert!(!args.web);
489 assert!(args.research);
490 }
491
492 #[test]
493 fn test_args_parse_research_flag() {
494 let args = Args::parse_from(["eot", "prompt", "--research"]);
495 assert!(args.research);
496 assert_eq!(args.runs, 10);
497 assert_eq!(args.output, "research_output.json");
498 }
499
500 #[test]
501 fn test_args_parse_research_custom_runs() {
502 let args = Args::parse_from(["eot", "prompt", "--research", "--runs", "50"]);
503 assert_eq!(args.runs, 50);
504 }
505
506 #[test]
507 fn test_args_parse_research_custom_output() {
508 let args = Args::parse_from(["eot", "prompt", "--research", "--output", "out.json"]);
509 assert_eq!(args.output, "out.json");
510 }
511
512 #[test]
513 fn test_args_parse_system_a() {
514 let args = Args::parse_from(["eot", "prompt", "--system-a", "Be concise."]);
515 assert_eq!(args.system_a, Some("Be concise.".to_string()));
516 }
517
518 #[test]
519 fn test_args_parse_system_b() {
520 let args = Args::parse_from(["eot", "prompt", "--system-b", "Be verbose."]);
521 assert_eq!(args.system_b, Some("Be verbose.".to_string()));
522 }
523
524 #[cfg(feature = "helix-bridge")]
525 #[test]
526 fn test_args_helix_url_default_none() {
527 let args = Args::parse_from(["eot", "prompt"]);
528 assert!(args.helix_url.is_none());
529 }
530
531 #[cfg(feature = "helix-bridge")]
532 #[test]
533 fn test_args_helix_url_set() {
534 let args = Args::parse_from(["eot", "prompt", "--helix-url", "http://127.0.0.1:8080"]);
535 assert_eq!(args.helix_url.as_deref(), Some("http://127.0.0.1:8080"));
536 }
537
538 #[test]
539 fn test_parse_rate_range_valid() {
540 assert_eq!(parse_rate_range("0.3-0.7"), Some((0.3, 0.7)));
541 }
542
543 #[test]
544 fn test_parse_rate_range_equal() {
545 assert_eq!(parse_rate_range("0.5-0.5"), Some((0.5, 0.5)));
546 }
547
548 #[test]
549 fn test_parse_rate_range_invalid() {
550 assert_eq!(parse_rate_range("invalid"), None);
551 }
552
553 #[test]
554 fn test_parse_rate_range_min_greater_than_max() {
555 assert_eq!(parse_rate_range("0.8-0.2"), None);
556 }
557
558 #[test]
559 fn test_parse_rate_range_scientific_notation() {
560 let result = parse_rate_range("1e-3-0.5");
562 assert!(result.is_some());
563 let (min, max) = result.unwrap();
564 assert!((min - 0.001).abs() < 1e-9);
565 assert!((max - 0.5).abs() < 1e-9);
566 }
567
568 #[test]
569 fn test_parse_rate_range_no_separator_returns_none() {
570 assert_eq!(parse_rate_range("0.5"), None);
571 }
572
573 #[test]
574 fn test_apply_template_with_placeholder() {
575 assert_eq!(apply_template("Answer: {input}", "hello"), "Answer: hello");
576 }
577
578 #[test]
579 fn test_apply_template_no_placeholder() {
580 assert_eq!(apply_template("No placeholder", "hello"), "No placeholder");
581 }
582
583 #[test]
584 fn test_args_dry_run_flag() {
585 let args = Args::parse_from(["eot", "prompt", "--dry-run"]);
586 assert!(args.dry_run);
587 }
588
589 #[test]
590 fn test_args_min_confidence() {
591 let args = Args::parse_from(["eot", "prompt", "--min-confidence", "0.8"]);
592 assert_eq!(args.min_confidence, Some(0.8));
593 }
594
595 #[test]
596 fn test_args_collapse_window() {
597 let args = Args::parse_from(["eot", "prompt", "--collapse-window", "10"]);
598 assert_eq!(args.collapse_window, 10);
599 }
600
601 #[test]
602 fn test_args_format_jsonl() {
603 let args = Args::parse_from(["eot", "prompt", "--format", "jsonl"]);
604 assert_eq!(args.format, "jsonl");
605 }
606
607 #[test]
610 fn test_validate_model_known_openai_no_warn() {
611 validate_model(&Provider::Openai, "gpt-4");
613 validate_model(&Provider::Openai, "gpt-3.5-turbo");
614 validate_model(&Provider::Openai, "gpt-4o");
615 }
616
617 #[test]
618 fn test_validate_model_known_anthropic_no_warn() {
619 validate_model(&Provider::Anthropic, "claude-sonnet-4-6");
620 validate_model(&Provider::Anthropic, "claude-opus-4-6");
621 validate_model(&Provider::Anthropic, "claude-haiku-4-5-20251001");
622 }
623
624 #[test]
625 fn test_validate_model_unknown_does_not_panic() {
626 validate_model(&Provider::Openai, "gpt-9-turbo-ultra");
628 validate_model(&Provider::Anthropic, "claude-99");
629 }
630
631 #[test]
632 fn test_validate_model_mock_always_silent() {
633 validate_model(&Provider::Mock, "any-model-string");
635 }
636
637 #[test]
638 fn test_known_openai_models_nonempty() {
639 assert!(!KNOWN_OPENAI_MODELS.is_empty());
640 }
641
642 #[test]
643 fn test_known_anthropic_models_nonempty() {
644 assert!(!KNOWN_ANTHROPIC_MODELS.is_empty());
645 }
646
647 #[test]
648 fn test_known_openai_models_contain_gpt4() {
649 assert!(KNOWN_OPENAI_MODELS.contains(&"gpt-4"));
650 }
651
652 #[test]
653 fn test_known_anthropic_models_contain_sonnet() {
654 assert!(KNOWN_ANTHROPIC_MODELS.contains(&"claude-sonnet-4-6"));
655 }
656
657 #[test]
660 fn test_apply_template_prompt_with_placeholder_not_reexpanded() {
661 let result = apply_template("Q: {input}", "what is {input}?");
663 assert_eq!(result, "Q: what is {input}?");
664 }
665
666 #[test]
667 fn test_apply_template_multiple_placeholders() {
668 let result = apply_template("{input} and {input}", "hello");
669 assert_eq!(result, "hello and hello");
670 }
671
672 #[test]
675 fn test_args_orchestrator_url_default() {
676 let args = Args::parse_from(["eot", "prompt"]);
677 assert_eq!(args.orchestrator_url, "http://localhost:3000");
678 }
679
680 #[test]
681 fn test_args_orchestrator_url_custom() {
682 let args = Args::parse_from([
683 "eot",
684 "prompt",
685 "--orchestrator-url",
686 "http://10.0.0.1:9000",
687 ]);
688 assert_eq!(args.orchestrator_url, "http://10.0.0.1:9000");
689 }
690
691 #[test]
692 fn test_args_max_retries_default() {
693 let args = Args::parse_from(["eot", "prompt"]);
694 assert_eq!(args.max_retries, 3);
695 }
696
697 #[test]
698 fn test_args_max_retries_custom() {
699 let args = Args::parse_from(["eot", "prompt", "--max-retries", "5"]);
700 assert_eq!(args.max_retries, 5);
701 }
702
703 #[test]
704 fn test_args_max_retries_zero() {
705 let args = Args::parse_from(["eot", "prompt", "--max-retries", "0"]);
706 assert_eq!(args.max_retries, 0);
707 }
708
709 #[test]
710 fn test_args_timeout_default() {
711 let args = Args::parse_from(["eot", "prompt"]);
712 assert_eq!(args.timeout, 120);
713 }
714
715 #[test]
716 fn test_args_timeout_custom() {
717 let args = Args::parse_from(["eot", "prompt", "--timeout", "60"]);
718 assert_eq!(args.timeout, 60);
719 }
720
721 #[test]
722 fn test_args_timeout_zero_disables() {
723 let args = Args::parse_from(["eot", "prompt", "--timeout", "0"]);
724 assert_eq!(args.timeout, 0);
725 }
726
727 #[test]
729 fn test_validate_config_flag_exists() {
730 let args = Args::parse_from(["eot", "prompt"]);
731 assert!(!args.validate_config, "validate_config should default to false");
732 let args2 = Args::parse_from(["eot", "prompt", "--validate-config"]);
733 assert!(args2.validate_config);
734 }
735
736 #[test]
738 fn test_list_models_openai_includes_gpt4() {
739 let openai_models = ["gpt-3.5-turbo", "gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo"];
740 assert!(openai_models.contains(&"gpt-4"), "openai list should include gpt-4");
741 }
742
743 #[test]
744 fn test_list_models_flag_accepts_openai() {
745 let args = Args::parse_from(["eot", "prompt", "--list-models", "openai"]);
746 assert_eq!(args.list_models.as_deref(), Some("openai"));
747 }
748
749 #[test]
750 fn test_list_models_flag_accepts_all() {
751 let args = Args::parse_from(["eot", "prompt", "--list-models", "all"]);
752 assert_eq!(args.list_models.as_deref(), Some("all"));
753 }
754
755 #[test]
757 fn test_json_schema_flag_outputs_valid_json() {
758 const RESEARCH_SCHEMA: &str = include_str!("../docs/research-schema.json");
759 let result = serde_json::from_str::<serde_json::Value>(RESEARCH_SCHEMA);
760 assert!(result.is_ok(), "research-schema.json must be valid JSON");
761 }
762
763 #[test]
765 fn test_record_path_unwritable_detected() {
766 let bad_path = "/nonexistent_dir_eot_test/output.json";
768 let result = std::fs::OpenOptions::new()
769 .create(true)
770 .write(true)
771 .open(bad_path);
772 assert!(result.is_err(), "opening a path in a nonexistent dir should fail");
773 }
774
775 #[test]
777 fn test_export_timeseries_flag_default_none() {
778 let args = Args::parse_from(["eot", "prompt"]);
779 assert!(args.export_timeseries.is_none());
780 }
781
782 #[test]
783 fn test_export_timeseries_flag_set() {
784 let args = Args::parse_from(["eot", "prompt", "--export-timeseries", "out.csv"]);
785 assert_eq!(args.export_timeseries.as_deref(), Some("out.csv"));
786 }
787}