1use crate::config::AiConfig;
33use parking_lot::Mutex;
34use std::io::Write;
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::OnceLock;
37use std::time::Duration;
38
39#[derive(Debug)]
42pub enum AiError {
43 Usage(String),
46 Failed(String),
49}
50
51impl AiError {
52 pub fn status(&self) -> i32 {
54 match self {
55 AiError::Usage(_) => 2,
56 AiError::Failed(_) => 1,
57 }
58 }
59
60 pub fn reason(&self) -> &str {
62 match self {
63 AiError::Usage(m) | AiError::Failed(m) => m,
64 }
65 }
66}
67
68type Result<T> = std::result::Result<T, AiError>;
69
70fn usage<T>(msg: impl Into<String>) -> Result<T> {
71 Err(AiError::Usage(msg.into()))
72}
73
74fn failed<T>(msg: impl Into<String>) -> Result<T> {
75 Err(AiError::Failed(msg.into()))
76}
77
78#[derive(Debug)]
85pub enum Output {
86 Printed,
88 Scalar(String, String),
90 Array(String, Vec<String>),
92}
93
94static RUNTIME: OnceLock<Mutex<AiConfig>> = OnceLock::new();
102
103fn runtime() -> &'static Mutex<AiConfig> {
104 RUNTIME.get_or_init(|| Mutex::new(crate::config::current().ai.clone()))
105}
106
107fn config_set(assignment: &str) -> Result<()> {
111 let Some((key, value)) = assignment.split_once('=') else {
112 return usage(format!("-S wants key=value, got `{}`", assignment));
113 };
114 let mut cfg = runtime().lock();
115 match key {
116 "provider" => cfg.provider = value.to_string(),
117 "model" => cfg.model = value.to_string(),
118 "api_key_env" => cfg.api_key_env = value.to_string(),
119 "base_url" => cfg.base_url = value.to_string(),
120 "cache" => cfg.cache = matches!(value, "1" | "true" | "yes" | "on"),
121 "max_cost_run" => match value.parse() {
122 Ok(v) => cfg.max_cost_run = v,
123 Err(_) => return usage(format!("max_cost_run wants a number, got `{}`", value)),
124 },
125 "max_tokens" => match value.parse() {
126 Ok(v) => cfg.max_tokens = v,
127 Err(_) => return usage(format!("max_tokens wants an integer, got `{}`", value)),
128 },
129 "timeout" => match value.parse() {
130 Ok(v) => cfg.timeout = v,
131 Err(_) => return usage(format!("timeout wants an integer, got `{}`", value)),
132 },
133 other => return usage(format!("unknown config key `{}`", other)),
134 }
135 Ok(())
136}
137
138fn config_dump() -> String {
140 let cfg = runtime().lock();
141 let mut out = String::new();
142 out.push_str(&format!("provider={}\n", cfg.provider));
143 out.push_str(&format!("model={}\n", cfg.model));
144 out.push_str(&format!("api_key_env={}\n", cfg.api_key_env));
145 out.push_str(&format!("base_url={}\n", cfg.base_url));
146 out.push_str(&format!("cache={}\n", cfg.cache));
147 out.push_str(&format!("max_cost_run={}\n", cfg.max_cost_run));
148 out.push_str(&format!("max_tokens={}\n", cfg.max_tokens));
149 out.push_str(&format!("timeout={}\n", cfg.timeout));
150 out
151}
152
153static COST_USD_MICROS: AtomicU64 = AtomicU64::new(0);
156static INPUT_TOKENS: AtomicU64 = AtomicU64::new(0);
157static OUTPUT_TOKENS: AtomicU64 = AtomicU64::new(0);
158static CACHE_CREATION_TOKENS: AtomicU64 = AtomicU64::new(0);
159static CACHE_READ_TOKENS: AtomicU64 = AtomicU64::new(0);
160
161fn add_cost(usd: f64) {
162 COST_USD_MICROS.fetch_add((usd * 1_000_000.0) as u64, Ordering::Relaxed);
163}
164
165fn current_cost_usd() -> f64 {
166 COST_USD_MICROS.load(Ordering::Relaxed) as f64 / 1_000_000.0
167}
168
169fn price_per_mtok(model: &str) -> (f64, f64) {
183 match model {
184 m if m.starts_with("claude-fable") || m.starts_with("claude-mythos") => (10.00, 50.00),
185 m if m.starts_with("claude-opus-4-5") => (5.00, 25.00),
186 m if m.starts_with("claude-opus") => (5.00, 25.00),
187 m if m.starts_with("claude-sonnet-4-6") => (3.00, 15.00),
188 m if m.starts_with("claude-sonnet") => (2.00, 10.00),
189 m if m.starts_with("claude-haiku") => (1.00, 5.00),
190 m if m.starts_with("gpt-4o-mini") => (0.15, 0.60),
191 m if m.starts_with("gpt-4o") => (2.50, 10.00),
192 m if m.starts_with("gpt-5") => (5.00, 20.00),
193 m if m.starts_with("o1") => (15.00, 60.00),
194 m if m.starts_with("gemini-2.5-pro") => (1.25, 10.00),
195 m if m.starts_with("gemini") => (0.30, 2.50),
196 _ => (3.00, 15.00),
197 }
198}
199
200fn bill(model: &str, input: u64, output: u64, cache_creation: u64, cache_read: u64) {
207 INPUT_TOKENS.fetch_add(input, Ordering::Relaxed);
208 OUTPUT_TOKENS.fetch_add(output, Ordering::Relaxed);
209 CACHE_CREATION_TOKENS.fetch_add(cache_creation, Ordering::Relaxed);
210 CACHE_READ_TOKENS.fetch_add(cache_read, Ordering::Relaxed);
211 let (in_rate, out_rate) = price_per_mtok(model);
212 let per_token_in = in_rate / 1_000_000.0;
213 add_cost(
214 input as f64 * per_token_in
215 + output as f64 * (out_rate / 1_000_000.0)
216 + cache_creation as f64 * per_token_in * 1.25
217 + cache_read as f64 * per_token_in * 0.10,
218 );
219}
220
221static CACHE: OnceLock<Mutex<indexmap::IndexMap<String, String>>> = OnceLock::new();
224static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
225static CACHE_MISSES: AtomicU64 = AtomicU64::new(0);
226
227fn cache() -> &'static Mutex<indexmap::IndexMap<String, String>> {
228 CACHE.get_or_init(|| Mutex::new(indexmap::IndexMap::new()))
229}
230
231fn cache_key(provider: &str, model: &str, system: &str, prompt: &str) -> String {
234 use sha2::{Digest, Sha256};
235 let mut h = Sha256::new();
236 for field in [provider, model, system, prompt] {
237 h.update(field.as_bytes());
238 h.update(b"\x00");
239 }
240 h.finalize().iter().map(|b| format!("{:02x}", b)).collect()
241}
242
243static MOCKS: OnceLock<Mutex<Vec<(regex::Regex, String)>>> = OnceLock::new();
246
247fn mocks() -> &'static Mutex<Vec<(regex::Regex, String)>> {
248 MOCKS.get_or_init(|| Mutex::new(Vec::new()))
249}
250
251fn match_mock(prompt: &str) -> Option<String> {
252 mocks()
253 .lock()
254 .iter()
255 .find(|(re, _)| re.is_match(prompt))
256 .map(|(_, response)| response.clone())
257}
258
259fn mock_only_mode() -> bool {
263 matches!(
264 std::env::var("ZSHRS_AI_MODE").as_deref(),
265 Ok("mock-only") | Ok("mock_only")
266 )
267}
268
269#[derive(Clone, Debug)]
272struct HistoryEntry {
273 provider: String,
274 model: String,
275 prompt: String,
276 response_chars: usize,
277 usd: f64,
278 cache_hit: bool,
279}
280
281const HISTORY_CAP: usize = 100;
282static HISTORY: OnceLock<Mutex<std::collections::VecDeque<HistoryEntry>>> = OnceLock::new();
283
284fn history() -> &'static Mutex<std::collections::VecDeque<HistoryEntry>> {
285 HISTORY.get_or_init(|| Mutex::new(std::collections::VecDeque::with_capacity(HISTORY_CAP)))
286}
287
288fn record_history(entry: HistoryEntry) {
289 let mut g = history().lock();
290 if g.len() >= HISTORY_CAP {
291 g.pop_front();
292 }
293 g.push_back(entry);
294}
295
296fn truncate(s: &str, n: usize) -> String {
297 if s.chars().count() <= n {
298 return s.to_string();
299 }
300 s.chars().take(n).collect::<String>() + "…"
301}
302
303struct Call {
309 provider: String,
310 model: String,
311 system: String,
312 prompt: String,
313 max_tokens: i64,
314 temperature: f64,
317 timeout: i64,
318 cache: bool,
319 cache_control: bool,
320 stream: bool,
321}
322
323enum Sink<'a> {
325 Stdout(std::io::StdoutLock<'a>),
328 Buffer(String),
330}
331
332impl Sink<'_> {
333 fn push(&mut self, chunk: &str) {
334 match self {
335 Sink::Stdout(out) => {
336 let _ = out.write_all(chunk.as_bytes());
337 let _ = out.flush();
338 }
339 Sink::Buffer(buf) => buf.push_str(chunk),
340 }
341 }
342}
343
344#[derive(Default, Debug)]
348struct Flags {
349 model: Option<String>,
350 system: Option<String>,
351 provider: Option<String>,
352 max_tokens: Option<i64>,
353 temperature: Option<f64>,
354 timeout: Option<i64>,
355 no_cache: bool,
356 cache_control: bool,
357 buffered: bool,
358 var: Option<String>,
359 array: Option<String>,
360 words: Vec<String>,
361 report: Option<Report>,
363 set: Vec<String>,
364}
365
366#[derive(Debug)]
369enum Report {
370 Cost,
371 ClearCache,
372 History,
373 Config,
374 Mock(String),
375 Help,
376}
377
378fn parse_i64(flag: &str, raw: Option<&String>) -> Result<i64> {
379 match raw {
380 Some(v) => v
381 .parse()
382 .map_err(|_| AiError::Usage(format!("{} wants an integer, got `{}`", flag, v))),
383 None => usage(format!("{} requires an argument", flag)),
384 }
385}
386
387fn take<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a String> {
388 *i += 1;
389 args.get(*i)
390 .ok_or_else(|| AiError::Usage(format!("{} requires an argument", flag)))
391}
392
393fn parse_flags(args: &[String]) -> Result<Flags> {
394 let mut f = Flags::default();
395 let mut i = 0;
396 while i < args.len() {
397 let arg = &args[i];
398 match arg.as_str() {
399 "--" => {
400 f.words.extend_from_slice(&args[i + 1..]);
401 break;
402 }
403 "-m" => f.model = Some(take(args, &mut i, "-m")?.clone()),
404 "-s" => f.system = Some(take(args, &mut i, "-s")?.clone()),
405 "-P" => f.provider = Some(take(args, &mut i, "-P")?.clone()),
406 "-v" => f.var = Some(take(args, &mut i, "-v")?.clone()),
407 "-a" => f.array = Some(take(args, &mut i, "-a")?.clone()),
408 "-M" => f.report = Some(Report::Mock(take(args, &mut i, "-M")?.clone())),
409 "-S" => f.set.push(take(args, &mut i, "-S")?.clone()),
410 "-t" => {
411 let v = take(args, &mut i, "-t")?.clone();
412 f.max_tokens = Some(parse_i64("-t", Some(&v))?);
413 }
414 "-o" => {
415 let v = take(args, &mut i, "-o")?.clone();
416 f.timeout = Some(parse_i64("-o", Some(&v))?);
417 }
418 "-T" => {
419 let v = take(args, &mut i, "-T")?.clone();
420 match v.parse::<f64>() {
421 Ok(t) => f.temperature = Some(t),
422 Err(_) => return usage(format!("-T wants a number, got `{}`", v)),
423 }
424 }
425 "-n" => f.no_cache = true,
426 "-k" => f.cache_control = true,
427 "-b" => f.buffered = true,
428 "-c" => f.report = Some(Report::Cost),
429 "-K" => f.report = Some(Report::ClearCache),
430 "-H" => f.report = Some(Report::History),
431 "-G" => f.report = Some(Report::Config),
432 "-h" | "--help" => f.report = Some(Report::Help),
433 "-" => f.words.push(arg.clone()),
437 other if other.starts_with('-') && other.len() > 1 => {
438 return usage(format!("bad option: {}", other));
439 }
440 _ => f.words.push(arg.clone()),
441 }
442 i += 1;
443 }
444 Ok(f)
445}
446
447const HELP: &str = "\
448usage: ai [-m model] [-s system] [-P provider] [-t max-tokens] [-T temp]
449 [-o timeout] [-n] [-k] [-b] [-v var | -a array] [--] prompt...
450 ai -c | -K | -H | -G | -S key=value | -M pattern=response
451
452 -m MODEL model id -s SYS system prompt
453 -P PROVIDER anthropic|openai|local|ollama|gemini
454 -t N max output tokens -T FLOAT temperature
455 -o SECS request timeout -n bypass the cache
456 -k cache the system prefix -b buffer, do not stream
457 -v VAR assign to scalar VAR -a ARRAY assign lines to ARRAY
458 -c cost and token report -K clear the cache
459 -H recent call history -G show config
460 -S key=value set a session default -M pat=rsp install a mock
461
462With no prompt words, or a lone `-`, the prompt is read from stdin.
463";
464
465fn cost_report() -> String {
468 format!(
469 "usd={:.6}\ninput_tokens={}\noutput_tokens={}\ncache_creation_tokens={}\n\
470 cache_read_tokens={}\ncache_hits={}\ncache_misses={}\n",
471 current_cost_usd(),
472 INPUT_TOKENS.load(Ordering::Relaxed),
473 OUTPUT_TOKENS.load(Ordering::Relaxed),
474 CACHE_CREATION_TOKENS.load(Ordering::Relaxed),
475 CACHE_READ_TOKENS.load(Ordering::Relaxed),
476 CACHE_HITS.load(Ordering::Relaxed),
477 CACHE_MISSES.load(Ordering::Relaxed),
478 )
479}
480
481fn history_report() -> String {
487 let g = history().lock();
488 let mut out = String::new();
489 for e in g.iter() {
490 out.push_str(&format!(
491 "{}\t{}\t{:.6}\t{}\t{}\t{}\n",
492 e.provider,
493 e.model,
494 e.usd,
495 e.response_chars,
496 if e.cache_hit { "hit" } else { "miss" },
497 e.prompt.replace(['\t', '\n'], " "),
498 ));
499 }
500 out
501}
502
503pub fn run(args: &[String]) -> Result<Output> {
510 let flags = parse_flags(args)?;
511
512 for assignment in &flags.set {
513 config_set(assignment)?;
514 }
515 if let Some(report) = flags.report {
516 return report_output(report);
517 }
518 if !flags.set.is_empty() && flags.words.is_empty() {
520 return Ok(Output::Printed);
521 }
522 if flags.var.is_some() && flags.array.is_some() {
523 return usage("-v and -a are mutually exclusive");
524 }
525
526 let call = resolve(&flags)?;
527 let want_buffer = flags.var.is_some() || flags.array.is_some() || flags.buffered;
528
529 let stdout = std::io::stdout();
530 let mut sink = if want_buffer {
531 Sink::Buffer(String::new())
532 } else {
533 Sink::Stdout(stdout.lock())
534 };
535 let text = execute(&call, &mut sink)?;
536
537 match (flags.var, flags.array) {
538 (Some(var), _) => Ok(Output::Scalar(var, text)),
539 (_, Some(arr)) => Ok(Output::Array(
540 arr,
541 text.lines().map(str::to_string).collect(),
542 )),
543 _ => {
544 let mut out = std::io::stdout().lock();
548 if flags.buffered {
549 let _ = out.write_all(text.as_bytes());
550 }
551 if !text.ends_with('\n') {
552 let _ = out.write_all(b"\n");
553 }
554 let _ = out.flush();
555 Ok(Output::Printed)
556 }
557 }
558}
559
560fn report_output(report: Report) -> Result<Output> {
561 let text = match report {
562 Report::Cost => cost_report(),
563 Report::History => history_report(),
564 Report::Config => config_dump(),
565 Report::Help => HELP.to_string(),
566 Report::ClearCache => {
567 cache().lock().clear();
568 String::new()
569 }
570 Report::Mock(spec) => {
571 let Some((pattern, response)) = spec.split_once('=') else {
572 return usage(format!("-M wants pattern=response, got `{}`", spec));
573 };
574 match regex::Regex::new(pattern) {
575 Ok(re) => mocks().lock().push((re, response.to_string())),
576 Err(e) => return usage(format!("-M bad pattern `{}`: {}", pattern, e)),
577 }
578 String::new()
579 }
580 };
581 if !text.is_empty() {
582 let mut out = std::io::stdout().lock();
583 let _ = out.write_all(text.as_bytes());
584 let _ = out.flush();
585 }
586 Ok(Output::Printed)
587}
588
589fn resolve(flags: &Flags) -> Result<Call> {
591 let cfg = runtime().lock().clone();
592 let prompt = if flags.words.is_empty() || flags.words == ["-"] {
593 read_stdin_prompt()?
594 } else {
595 flags.words.join(" ")
596 };
597 if prompt.trim().is_empty() {
598 return usage("empty prompt");
599 }
600 Ok(Call {
601 provider: flags.provider.clone().unwrap_or(cfg.provider),
602 model: flags.model.clone().unwrap_or(cfg.model),
603 system: flags.system.clone().unwrap_or_default(),
604 prompt,
605 max_tokens: flags.max_tokens.unwrap_or(cfg.max_tokens),
606 temperature: flags.temperature.unwrap_or(-1.0),
607 timeout: flags.timeout.unwrap_or(cfg.timeout),
608 cache: cfg.cache && !flags.no_cache,
609 cache_control: flags.cache_control,
610 stream: !flags.buffered && flags.var.is_none() && flags.array.is_none(),
613 })
614}
615
616fn read_stdin_prompt() -> Result<String> {
617 use std::io::Read;
618 let mut buf = String::new();
619 match std::io::stdin().read_to_string(&mut buf) {
620 Ok(_) => Ok(buf),
621 Err(e) => failed(format!("stdin: {}", e)),
622 }
623}
624
625fn execute(call: &Call, sink: &mut Sink) -> Result<String> {
628 if let Some(response) = match_mock(&call.prompt) {
629 sink.push(&response);
630 record_history(HistoryEntry {
631 provider: call.provider.clone(),
632 model: call.model.clone(),
633 prompt: truncate(&call.prompt, 200),
634 response_chars: response.chars().count(),
635 usd: 0.0,
636 cache_hit: false,
637 });
638 return Ok(response);
639 }
640 if mock_only_mode() {
641 return failed(format!(
642 "ZSHRS_AI_MODE=mock-only and no mock matched prompt {:?}",
643 truncate(&call.prompt, 60)
644 ));
645 }
646
647 let key = cache_key(&call.provider, &call.model, &call.system, &call.prompt);
648 if call.cache {
649 let hit = cache().lock().get(&key).cloned();
650 if let Some(hit) = hit {
651 CACHE_HITS.fetch_add(1, Ordering::Relaxed);
652 sink.push(&hit);
653 record_history(HistoryEntry {
654 provider: call.provider.clone(),
655 model: call.model.clone(),
656 prompt: truncate(&call.prompt, 200),
657 response_chars: hit.chars().count(),
658 usd: 0.0,
659 cache_hit: true,
660 });
661 return Ok(hit);
662 }
663 CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
664 }
665
666 let ceiling = runtime().lock().max_cost_run;
667 if ceiling > 0.0 && current_cost_usd() >= ceiling {
668 return failed(format!(
669 "max_cost_run={:.2} exceeded (spent ${:.4} so far)",
670 ceiling,
671 current_cost_usd()
672 ));
673 }
674
675 let before = current_cost_usd();
676 let text = match call.provider.as_str() {
677 "anthropic" => {
678 if call.stream {
679 call_anthropic_stream(call, sink)?
680 } else {
681 let t = call_anthropic(call)?;
682 sink.push(&t);
683 t
684 }
685 }
686 "openai" => {
687 let t = call_openai(
688 call,
689 "https://api.openai.com/v1/chat/completions",
690 "OPENAI_API_KEY",
691 )?;
692 sink.push(&t);
693 t
694 }
695 "openai_compat" | "compat" | "local" => {
696 let base = base_url_or(
697 call,
698 "ZSHRS_AI_BASE_URL",
699 "http://localhost:1234/v1/chat/completions",
700 );
701 let t = call_openai(call, &base, "ZSHRS_AI_LOCAL_KEY")?;
702 sink.push(&t);
703 t
704 }
705 "ollama" => {
706 let t = call_ollama(call)?;
707 sink.push(&t);
708 t
709 }
710 "gemini" | "google" => {
711 let t = call_gemini(call)?;
712 sink.push(&t);
713 t
714 }
715 other => {
716 return usage(format!(
717 "unknown provider `{}` (anthropic, openai, local, ollama, gemini)",
718 other
719 ));
720 }
721 };
722
723 if call.cache {
724 cache().lock().insert(key, text.clone());
725 }
726 record_history(HistoryEntry {
727 provider: call.provider.clone(),
728 model: call.model.clone(),
729 prompt: truncate(&call.prompt, 200),
730 response_chars: text.chars().count(),
731 usd: current_cost_usd() - before,
732 cache_hit: false,
733 });
734 Ok(text)
735}
736
737fn base_url_or(call: &Call, env: &str, fallback: &str) -> String {
739 let configured = runtime().lock().base_url.clone();
740 if !configured.is_empty() {
741 return configured;
742 }
743 let _ = call;
744 std::env::var(env).unwrap_or_else(|_| fallback.to_string())
745}
746
747fn agent(timeout: i64) -> ureq::Agent {
748 ureq::AgentBuilder::new()
749 .timeout(Duration::from_secs(timeout.max(1) as u64))
750 .build()
751}
752
753const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages";
756const ANTHROPIC_VERSION: &str = "2023-06-01";
757
758fn anthropic_key(call: &Call) -> Result<String> {
759 let env = runtime().lock().api_key_env.clone();
760 let _ = call;
761 std::env::var(&env).map_err(|_| AiError::Failed(format!("${} is not set", env)))
762}
763
764fn anthropic_body(call: &Call, stream: bool) -> serde_json::Value {
766 let mut body = serde_json::json!({
767 "model": call.model,
768 "max_tokens": call.max_tokens,
769 "messages": [{ "role": "user", "content": call.prompt }],
770 });
771 if !call.system.is_empty() {
772 body["system"] = if call.cache_control {
773 serde_json::json!([{
777 "type": "text",
778 "text": call.system,
779 "cache_control": { "type": "ephemeral" },
780 }])
781 } else {
782 serde_json::Value::String(call.system.clone())
783 };
784 }
785 if call.temperature >= 0.0 {
786 body["temperature"] = serde_json::Value::from(call.temperature);
787 }
788 if stream {
789 body["stream"] = serde_json::Value::Bool(true);
790 }
791 body
792}
793
794fn should_retry(status: u16) -> bool {
795 matches!(status, 429 | 500 | 502 | 503 | 504)
796}
797
798fn retry_delay(attempt: u32) -> Duration {
800 Duration::from_secs((1u64 << attempt.min(5)).min(30))
801}
802
803const MAX_ATTEMPTS: u32 = 4;
804
805fn call_anthropic(call: &Call) -> Result<String> {
806 let key = anthropic_key(call)?;
807 let agent = agent(call.timeout);
808 let body = anthropic_body(call, false);
809
810 let mut last: Option<AiError> = None;
811 let mut json = None;
812 for attempt in 0..MAX_ATTEMPTS {
813 match agent
814 .post(ANTHROPIC_URL)
815 .set("x-api-key", &key)
816 .set("anthropic-version", ANTHROPIC_VERSION)
817 .set("content-type", "application/json")
818 .send_json(body.clone())
819 {
820 Ok(resp) => match resp.into_json::<serde_json::Value>() {
821 Ok(v) => {
822 json = Some(v);
823 break;
824 }
825 Err(e) => return failed(format!("anthropic decode: {}", e)),
826 },
827 Err(ureq::Error::Status(code, resp)) => {
828 if attempt + 1 < MAX_ATTEMPTS && should_retry(code) {
829 std::thread::sleep(retry_delay(attempt));
830 continue;
831 }
832 let text = resp.into_string().unwrap_or_default();
833 last = Some(AiError::Failed(format!(
834 "anthropic {}: {}",
835 code,
836 truncate(&text, 200)
837 )));
838 break;
839 }
840 Err(ureq::Error::Transport(t)) => {
841 if attempt + 1 < MAX_ATTEMPTS {
842 std::thread::sleep(retry_delay(attempt));
843 continue;
844 }
845 last = Some(AiError::Failed(format!("anthropic transport: {}", t)));
846 break;
847 }
848 }
849 }
850 let Some(json) = json else {
851 return Err(last.unwrap_or_else(|| AiError::Failed("anthropic call failed".into())));
852 };
853
854 let usage = &json["usage"];
855 bill(
856 &call.model,
857 usage["input_tokens"].as_u64().unwrap_or(0),
858 usage["output_tokens"].as_u64().unwrap_or(0),
859 usage["cache_creation_input_tokens"].as_u64().unwrap_or(0),
860 usage["cache_read_input_tokens"].as_u64().unwrap_or(0),
861 );
862
863 let mut out = String::new();
864 if let Some(blocks) = json["content"].as_array() {
865 for block in blocks {
866 if block["type"].as_str() == Some("text") {
867 if let Some(t) = block["text"].as_str() {
868 out.push_str(t);
869 }
870 }
871 }
872 }
873 if out.is_empty() {
874 if let Some(reason) = json["stop_reason"].as_str() {
878 if reason == "refusal" {
879 return failed("anthropic declined the request (stop_reason=refusal)");
880 }
881 }
882 return failed(format!(
883 "anthropic returned no content: {}",
884 truncate(&json.to_string(), 200)
885 ));
886 }
887 Ok(out)
888}
889
890fn call_anthropic_stream(call: &Call, sink: &mut Sink) -> Result<String> {
894 let key = anthropic_key(call)?;
895 let resp = agent(call.timeout)
896 .post(ANTHROPIC_URL)
897 .set("x-api-key", &key)
898 .set("anthropic-version", ANTHROPIC_VERSION)
899 .set("content-type", "application/json")
900 .set("accept", "text/event-stream")
901 .send_json(anthropic_body(call, true));
902 let resp = match resp {
903 Ok(r) => r,
904 Err(ureq::Error::Status(code, r)) => {
905 let text = r.into_string().unwrap_or_default();
906 return failed(format!("anthropic {}: {}", code, truncate(&text, 200)));
907 }
908 Err(ureq::Error::Transport(t)) => {
909 return failed(format!("anthropic transport: {}", t));
910 }
911 };
912 let reader = std::io::BufReader::new(resp.into_reader());
913 decode_sse(&call.model, reader, sink)
914}
915
916fn decode_sse<R: std::io::BufRead>(model: &str, mut reader: R, sink: &mut Sink) -> Result<String> {
925 let mut text = String::new();
926 let mut input_tokens = 0u64;
927 let mut output_tokens = 0u64;
928 let mut cache_creation = 0u64;
929 let mut cache_read = 0u64;
930 let mut error: Option<String> = None;
931 loop {
932 let mut line = String::new();
933 match reader.read_line(&mut line) {
934 Ok(0) => break,
935 Ok(_) => {}
936 Err(e) => {
937 error = Some(format!("anthropic stream: {}", e));
938 break;
939 }
940 }
941 let Some(payload) = line.trim_end().strip_prefix("data: ") else {
942 continue;
943 };
944 if payload == "[DONE]" {
945 break;
946 }
947 let Ok(event) = serde_json::from_str::<serde_json::Value>(payload) else {
948 continue;
949 };
950 match event["type"].as_str() {
951 Some("content_block_delta") => {
952 if let Some(t) = event["delta"]["text"].as_str() {
953 sink.push(t);
954 text.push_str(t);
955 }
956 }
957 Some("message_start") => {
958 let usage = &event["message"]["usage"];
959 input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
960 cache_creation = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
961 cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
962 }
963 Some("message_delta") => {
964 output_tokens = event["usage"]["output_tokens"].as_u64().unwrap_or(0);
965 }
966 Some("error") => {
969 error = Some(format!(
970 "anthropic stream: {}",
971 truncate(event["error"]["message"].as_str().unwrap_or("unknown"), 200)
972 ));
973 break;
974 }
975 _ => {}
976 }
977 }
978 bill(
981 model,
982 input_tokens,
983 output_tokens,
984 cache_creation,
985 cache_read,
986 );
987 if let Some(e) = error {
988 return failed(e);
989 }
990 Ok(text)
991}
992
993fn call_openai(call: &Call, url: &str, key_env: &str) -> Result<String> {
996 let api_key = std::env::var(key_env).unwrap_or_default();
1000
1001 let mut messages = Vec::new();
1002 if !call.system.is_empty() {
1003 messages.push(serde_json::json!({"role": "system", "content": call.system}));
1004 }
1005 messages.push(serde_json::json!({"role": "user", "content": call.prompt}));
1006
1007 let mut body = serde_json::json!({
1008 "model": call.model,
1009 "max_tokens": call.max_tokens,
1010 "messages": messages,
1011 });
1012 if call.temperature >= 0.0 {
1013 body["temperature"] = serde_json::Value::from(call.temperature);
1014 }
1015
1016 let mut req = agent(call.timeout)
1017 .post(url)
1018 .set("content-type", "application/json");
1019 if !api_key.is_empty() {
1020 req = req.set("authorization", &format!("Bearer {}", api_key));
1021 }
1022 let json: serde_json::Value = req
1023 .send_json(body)
1024 .map_err(|e| AiError::Failed(format!("openai request: {}", e)))?
1025 .into_json()
1026 .map_err(|e| AiError::Failed(format!("openai decode: {}", e)))?;
1027
1028 let usage = &json["usage"];
1029 bill(
1030 &call.model,
1031 usage["prompt_tokens"].as_u64().unwrap_or(0),
1032 usage["completion_tokens"].as_u64().unwrap_or(0),
1033 0,
1034 0,
1035 );
1036
1037 let text = json["choices"][0]["message"]["content"]
1038 .as_str()
1039 .unwrap_or("")
1040 .to_string();
1041 if text.is_empty() {
1042 return failed(format!(
1043 "openai returned no content: {}",
1044 truncate(&json.to_string(), 200)
1045 ));
1046 }
1047 Ok(text)
1048}
1049
1050fn call_ollama(call: &Call) -> Result<String> {
1053 let base = {
1054 let configured = runtime().lock().base_url.clone();
1055 if !configured.is_empty() {
1056 configured
1057 } else {
1058 std::env::var("OLLAMA_HOST")
1059 .map(|h| {
1060 if h.starts_with("http") {
1061 h
1062 } else {
1063 format!("http://{}", h)
1064 }
1065 })
1066 .unwrap_or_else(|_| "http://localhost:11434".to_string())
1067 }
1068 };
1069 let url = format!("{}/api/generate", base.trim_end_matches('/'));
1070
1071 let model = if call.model.is_empty()
1075 || call.model.starts_with("claude")
1076 || call.model.starts_with("gpt")
1077 {
1078 "llama3.2"
1079 } else {
1080 &call.model
1081 };
1082
1083 let mut body = serde_json::json!({
1084 "model": model,
1085 "prompt": call.prompt,
1086 "stream": false,
1087 "options": { "num_predict": call.max_tokens },
1088 });
1089 if !call.system.is_empty() {
1090 body["system"] = serde_json::Value::String(call.system.clone());
1091 }
1092 if call.temperature >= 0.0 {
1093 body["options"]["temperature"] = serde_json::Value::from(call.temperature);
1094 }
1095
1096 let json: serde_json::Value = agent(call.timeout)
1097 .post(&url)
1098 .set("content-type", "application/json")
1099 .send_json(body)
1100 .map_err(|e| AiError::Failed(format!("ollama request: {}", e)))?
1101 .into_json()
1102 .map_err(|e| AiError::Failed(format!("ollama decode: {}", e)))?;
1103
1104 INPUT_TOKENS.fetch_add(
1106 json["prompt_eval_count"].as_u64().unwrap_or(0),
1107 Ordering::Relaxed,
1108 );
1109 OUTPUT_TOKENS.fetch_add(json["eval_count"].as_u64().unwrap_or(0), Ordering::Relaxed);
1110
1111 let text = json["response"].as_str().unwrap_or("").to_string();
1112 if text.is_empty() {
1113 return failed(format!(
1114 "ollama returned no content: {}",
1115 truncate(&json.to_string(), 200)
1116 ));
1117 }
1118 Ok(text)
1119}
1120
1121fn call_gemini(call: &Call) -> Result<String> {
1124 let api_key = std::env::var("GOOGLE_API_KEY")
1125 .or_else(|_| std::env::var("GEMINI_API_KEY"))
1126 .map_err(|_| AiError::Failed("$GOOGLE_API_KEY (or $GEMINI_API_KEY) is not set".into()))?;
1127
1128 let model = if call.model.starts_with("claude") || call.model.starts_with("gpt") {
1129 "gemini-2.5-flash"
1130 } else {
1131 &call.model
1132 };
1133 let url = format!(
1134 "https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
1135 model, api_key
1136 );
1137
1138 let mut generation_config = serde_json::json!({ "maxOutputTokens": call.max_tokens });
1139 if call.temperature >= 0.0 {
1140 generation_config["temperature"] = serde_json::Value::from(call.temperature);
1141 }
1142 let mut body = serde_json::json!({
1143 "contents": [{ "parts": [{ "text": call.prompt }] }],
1144 "generationConfig": generation_config,
1145 });
1146 if !call.system.is_empty() {
1147 body["systemInstruction"] = serde_json::json!({ "parts": [{ "text": call.system }] });
1148 }
1149
1150 let json: serde_json::Value = agent(call.timeout)
1151 .post(&url)
1152 .set("content-type", "application/json")
1153 .send_json(body)
1154 .map_err(|e| AiError::Failed(format!("gemini request: {}", e)))?
1155 .into_json()
1156 .map_err(|e| AiError::Failed(format!("gemini decode: {}", e)))?;
1157
1158 let usage = &json["usageMetadata"];
1159 bill(
1160 model,
1161 usage["promptTokenCount"].as_u64().unwrap_or(0),
1162 usage["candidatesTokenCount"].as_u64().unwrap_or(0),
1163 0,
1164 0,
1165 );
1166
1167 let mut text = String::new();
1168 if let Some(parts) = json["candidates"][0]["content"]["parts"].as_array() {
1169 for part in parts {
1170 if let Some(t) = part["text"].as_str() {
1171 text.push_str(t);
1172 }
1173 }
1174 }
1175 if text.is_empty() {
1176 return failed(format!(
1177 "gemini returned no content: {}",
1178 truncate(&json.to_string(), 200)
1179 ));
1180 }
1181 Ok(text)
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 #[must_use = "bind the guard (`let _g = reset();`) or the tests run in parallel again"]
1198 fn reset() -> std::sync::MutexGuard<'static, ()> {
1199 let g = crate::test_util::global_state_lock();
1200 COST_USD_MICROS.store(0, Ordering::Relaxed);
1201 INPUT_TOKENS.store(0, Ordering::Relaxed);
1202 OUTPUT_TOKENS.store(0, Ordering::Relaxed);
1203 CACHE_CREATION_TOKENS.store(0, Ordering::Relaxed);
1204 CACHE_READ_TOKENS.store(0, Ordering::Relaxed);
1205 CACHE_HITS.store(0, Ordering::Relaxed);
1206 CACHE_MISSES.store(0, Ordering::Relaxed);
1207 cache().lock().clear();
1208 mocks().lock().clear();
1209 history().lock().clear();
1210 g
1211 }
1212
1213 fn args(s: &[&str]) -> Vec<String> {
1214 s.iter().map(|x| x.to_string()).collect()
1215 }
1216
1217 #[test]
1218 fn opus_5_is_priced_at_the_published_rate_not_the_stryke_claude_3_rate() {
1219 assert_eq!(price_per_mtok("claude-opus-5"), (5.00, 25.00));
1223 assert_eq!(price_per_mtok("claude-sonnet-5"), (2.00, 10.00));
1224 assert_eq!(price_per_mtok("claude-haiku-4-5"), (1.00, 5.00));
1225 assert_eq!(price_per_mtok("claude-fable-5-1"), (10.00, 50.00));
1226 assert_eq!(price_per_mtok("claude-sonnet-4-6"), (3.00, 15.00));
1229 assert_eq!(price_per_mtok("some-model-from-2031"), (3.00, 15.00));
1232 }
1233
1234 #[test]
1235 fn cache_tokens_bill_at_write_and_read_multipliers() {
1236 let _g = reset();
1237 bill("claude-opus-5", 1_000_000, 0, 1_000_000, 1_000_000);
1240 assert!(
1241 (current_cost_usd() - 11.75).abs() < 0.000_01,
1242 "got {}",
1243 current_cost_usd()
1244 );
1245 assert_eq!(CACHE_CREATION_TOKENS.load(Ordering::Relaxed), 1_000_000);
1246 assert_eq!(CACHE_READ_TOKENS.load(Ordering::Relaxed), 1_000_000);
1247 }
1248
1249 #[test]
1250 fn cache_key_is_unambiguous_across_field_boundaries() {
1251 assert_ne!(
1254 cache_key("anthropic", "m", "ab", "c"),
1255 cache_key("anthropic", "m", "a", "bc")
1256 );
1257 assert_eq!(
1258 cache_key("anthropic", "m", "s", "p"),
1259 cache_key("anthropic", "m", "s", "p")
1260 );
1261 }
1262
1263 #[test]
1264 fn flags_parse_into_the_right_slots() {
1265 let f = parse_flags(&args(&[
1266 "-m",
1267 "claude-sonnet-5",
1268 "-s",
1269 "be terse",
1270 "-t",
1271 "128",
1272 "-T",
1273 "0.2",
1274 "-n",
1275 "-b",
1276 "-v",
1277 "reply",
1278 "hello",
1279 "world",
1280 ]))
1281 .expect("parse");
1282 assert_eq!(f.model.as_deref(), Some("claude-sonnet-5"));
1283 assert_eq!(f.system.as_deref(), Some("be terse"));
1284 assert_eq!(f.max_tokens, Some(128));
1285 assert_eq!(f.temperature, Some(0.2));
1286 assert!(f.no_cache && f.buffered);
1287 assert_eq!(f.var.as_deref(), Some("reply"));
1288 assert_eq!(f.words, vec!["hello", "world"]);
1289 }
1290
1291 #[test]
1292 fn double_dash_stops_flag_parsing_so_a_prompt_may_start_with_a_dash() {
1293 let f = parse_flags(&args(&["--", "-m", "is not a flag here"])).expect("parse");
1294 assert_eq!(f.words, vec!["-m", "is not a flag here"]);
1295 assert!(f.model.is_none());
1296 }
1297
1298 #[test]
1299 fn an_unknown_flag_is_a_usage_error_not_a_prompt_word() {
1300 let err = parse_flags(&args(&["-Z", "hello"])).expect_err("should reject");
1302 assert_eq!(err.status(), 2);
1303 assert!(err.reason().contains("-Z"), "{}", err.reason());
1304 }
1305
1306 #[test]
1307 fn a_flag_missing_its_argument_is_a_usage_error() {
1308 let err = parse_flags(&args(&["hello", "-m"])).expect_err("should reject");
1309 assert_eq!(err.status(), 2);
1310 assert!(err.reason().contains("-m"), "{}", err.reason());
1311 }
1312
1313 #[test]
1314 fn mock_mode_answers_without_touching_the_network() {
1315 let _g = reset();
1316 std::env::set_var("ZSHRS_AI_MODE", "mock-only");
1317 mocks().lock().push((
1318 regex::Regex::new("capital of France").unwrap(),
1319 "Paris".into(),
1320 ));
1321
1322 let out = run(&args(&["-v", "reply", "what is the capital of France?"]))
1323 .expect("mock should answer");
1324 match out {
1325 Output::Scalar(var, text) => {
1326 assert_eq!(var, "reply");
1327 assert_eq!(text, "Paris");
1328 }
1329 _ => panic!("expected a scalar assignment"),
1330 }
1331 assert_eq!(current_cost_usd(), 0.0);
1333 assert_eq!(history().lock().len(), 1);
1334 std::env::remove_var("ZSHRS_AI_MODE");
1335 }
1336
1337 #[test]
1338 fn mock_only_mode_refuses_rather_than_calling_out() {
1339 let _g = reset();
1340 std::env::set_var("ZSHRS_AI_MODE", "mock-only");
1341 let err = run(&args(&["-v", "x", "nothing matches this"])).expect_err("should refuse");
1342 assert_eq!(err.status(), 1);
1343 assert!(err.reason().contains("mock-only"), "{}", err.reason());
1344 std::env::remove_var("ZSHRS_AI_MODE");
1345 }
1346
1347 #[test]
1348 fn array_output_splits_the_response_on_lines() {
1349 let _g = reset();
1350 std::env::set_var("ZSHRS_AI_MODE", "mock-only");
1351 mocks().lock().push((
1352 regex::Regex::new("^list").unwrap(),
1353 "one\ntwo\nthree".into(),
1354 ));
1355
1356 match run(&args(&["-a", "items", "list three things"])).expect("mock") {
1357 Output::Array(var, lines) => {
1358 assert_eq!(var, "items");
1359 assert_eq!(lines, vec!["one", "two", "three"]);
1360 }
1361 _ => panic!("expected an array assignment"),
1362 }
1363 std::env::remove_var("ZSHRS_AI_MODE");
1364 }
1365
1366 #[test]
1367 fn v_and_a_together_are_rejected() {
1368 let err = run(&args(&["-v", "s", "-a", "arr", "hi"])).expect_err("should reject");
1369 assert_eq!(err.status(), 2);
1370 }
1371
1372 #[test]
1373 fn sse_decode_assembles_deltas_and_bills_the_whole_stream() {
1374 let _g = reset();
1375 let body = concat!(
1380 "event: message_start\n",
1381 r#"data: {"type":"message_start","message":{"usage":{"input_tokens":1000000,"cache_read_input_tokens":0}}}"#,
1382 "\n\n",
1383 "event: content_block_delta\n",
1384 r#"data: {"type":"content_block_delta","delta":{"text":"Hello, "}}"#,
1385 "\n\n",
1386 r#"data: {"type":"content_block_delta","delta":{"text":"world"}}"#,
1387 "\n\n",
1388 r#"data: {"type":"message_delta","usage":{"output_tokens":1000000}}"#,
1389 "\n\n",
1390 );
1391 let mut sink = Sink::Buffer(String::new());
1392 let text = decode_sse("claude-opus-5", std::io::Cursor::new(body), &mut sink)
1393 .expect("stream should decode");
1394
1395 assert_eq!(text, "Hello, world");
1396 match sink {
1397 Sink::Buffer(b) => assert_eq!(b, "Hello, world"),
1398 _ => panic!("expected the buffer sink"),
1399 }
1400 assert_eq!(INPUT_TOKENS.load(Ordering::Relaxed), 1_000_000);
1401 assert_eq!(OUTPUT_TOKENS.load(Ordering::Relaxed), 1_000_000);
1402 assert!(
1404 (current_cost_usd() - 30.0).abs() < 0.000_01,
1405 "got {}",
1406 current_cost_usd()
1407 );
1408 }
1409
1410 #[test]
1411 fn a_mid_stream_error_event_fails_the_call_but_still_bills_what_arrived() {
1412 let _g = reset();
1413 let body = concat!(
1414 r#"data: {"type":"message_start","message":{"usage":{"input_tokens":1000000}}}"#,
1415 "\n\n",
1416 r#"data: {"type":"content_block_delta","delta":{"text":"partial"}}"#,
1417 "\n\n",
1418 r#"data: {"type":"error","error":{"message":"overloaded_error"}}"#,
1419 "\n\n",
1420 );
1421 let mut sink = Sink::Buffer(String::new());
1422 let err = decode_sse("claude-opus-5", std::io::Cursor::new(body), &mut sink)
1423 .expect_err("an error event must fail the call");
1424 assert_eq!(err.status(), 1);
1425 assert!(
1426 err.reason().contains("overloaded_error"),
1427 "{}",
1428 err.reason()
1429 );
1430 assert_eq!(INPUT_TOKENS.load(Ordering::Relaxed), 1_000_000);
1432 }
1433
1434 #[test]
1435 fn sse_ignores_comment_and_event_lines_and_undecodable_payloads() {
1436 let _g = reset();
1437 let body = concat!(
1438 ": ping\n",
1439 "event: content_block_delta\n",
1440 "data: {not json at all\n",
1441 r#"data: {"type":"content_block_delta","delta":{"text":"ok"}}"#,
1442 "\n\n",
1443 );
1444 let mut sink = Sink::Buffer(String::new());
1445 let text = decode_sse("claude-opus-5", std::io::Cursor::new(body), &mut sink)
1446 .expect("garbage lines are skipped, not fatal");
1447 assert_eq!(text, "ok");
1448 }
1449
1450 #[test]
1451 fn anthropic_body_sets_cache_control_only_with_dash_k() {
1452 let mut call = Call {
1453 provider: "anthropic".into(),
1454 model: "claude-opus-5".into(),
1455 system: "be terse".into(),
1456 prompt: "hi".into(),
1457 max_tokens: 64,
1458 temperature: -1.0,
1459 timeout: 30,
1460 cache: true,
1461 cache_control: false,
1462 stream: false,
1463 };
1464 let plain = anthropic_body(&call, false);
1465 assert!(plain["system"].is_string());
1466 assert!(
1467 plain.get("temperature").is_none(),
1468 "unset temp must be absent"
1469 );
1470 assert!(plain.get("stream").is_none());
1471
1472 call.cache_control = true;
1473 call.temperature = 0.0;
1474 let cached = anthropic_body(&call, true);
1475 assert_eq!(
1476 cached["system"][0]["cache_control"]["type"].as_str(),
1477 Some("ephemeral")
1478 );
1479 assert_eq!(cached["temperature"].as_f64(), Some(0.0));
1481 assert_eq!(cached["stream"].as_bool(), Some(true));
1482 }
1483
1484 #[test]
1485 fn retry_policy_matches_the_stryke_port() {
1486 for code in [429, 500, 502, 503, 504] {
1487 assert!(should_retry(code), "{} should retry", code);
1488 }
1489 for code in [400, 401, 403, 404, 413, 422] {
1490 assert!(!should_retry(code), "{} must not retry", code);
1491 }
1492 assert_eq!(retry_delay(0), Duration::from_secs(1));
1493 assert_eq!(retry_delay(3), Duration::from_secs(8));
1494 assert_eq!(retry_delay(9), Duration::from_secs(30), "capped at 30s");
1495 }
1496
1497 #[test]
1498 fn config_set_rejects_an_unknown_key_instead_of_dropping_it() {
1499 let err = config_set("modle=claude-opus-5").expect_err("typo must not pass");
1500 assert_eq!(err.status(), 2);
1501 assert!(err.reason().contains("modle"), "{}", err.reason());
1502 assert!(config_set("noequals").is_err());
1503 }
1504}