Skip to main content

zsh/extensions/
ai.rs

1//! The `ai` builtin — a language-model call as a shell primitive.
2//!
3//! Ported from `strykelang/strykelang/ai.rs` (the `ai`/`prompt` family):
4//! config resolution, the four providers, cost/token accounting, the
5//! response cache, mock mode, retry/backoff, and the SSE stream decoder
6//! all follow that source. What changes here is only the FRONT: stryke
7//! passes `StrykeValue` opt pairs and returns a value; a shell builtin
8//! takes argv and either writes to stdout or assigns to a parameter.
9//!
10//! zshrs-original — C zsh has no counterpart, so this lives under
11//! `src/extensions/` per `docs/PORT.md`.
12//!
13//! # Why a builtin and not a script
14//!
15//! `ai -v reply "..."` performs the request AND the assignment inside
16//! the shell process. The `curl | jq` equivalent costs two forks, two
17//! execs, a pipe, and a command substitution to get one string into one
18//! parameter. That elimination is the whole reason this is a builtin.
19//!
20//! # Deliberate divergence from the stryke source
21//!
22//! Two values in `strykelang/strykelang/ai.rs` are stale and are NOT
23//! ported verbatim:
24//!
25//!   * the default model (`ai.rs:66`, `claude-opus-4-5`) — here
26//!     `claude-opus-5`, via `config::AiConfig::default`;
27//!   * the price table (`ai.rs:141-157`), which still carries
28//!     Claude 3-era rates — see [`price_per_mtok`].
29//!
30//! Everything else is a faithful port.
31
32use 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/// Failure of an `ai` invocation, split by what the caller should do
40/// about it. The two arms map to distinct exit statuses.
41#[derive(Debug)]
42pub enum AiError {
43    /// Bad flags / missing prompt — the user typed something wrong.
44    /// Exit status 2.
45    Usage(String),
46    /// The call was well-formed but did not produce an answer: no API
47    /// key, HTTP error, transport failure, cost ceiling. Exit status 1.
48    Failed(String),
49}
50
51impl AiError {
52    /// The exit status this failure maps to.
53    pub fn status(&self) -> i32 {
54        match self {
55            AiError::Usage(_) => 2,
56            AiError::Failed(_) => 1,
57        }
58    }
59
60    /// The bare reason, for a `zshrs: ai: <reason>` stderr line.
61    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/// What a successful `ai` run produced, and where it has to go.
79///
80/// `Printed` already went to stdout (possibly a token at a time, as it
81/// arrived). The other two carry text the caller must install into a
82/// shell parameter — [`crate::vm_helper`] owns the parameter table, so
83/// this module hands the value back rather than reaching into it.
84#[derive(Debug)]
85pub enum Output {
86    /// Already written to stdout; nothing left to do.
87    Printed,
88    /// Assign to the named scalar parameter.
89    Scalar(String, String),
90    /// Assign to the named array parameter, one element per line.
91    Array(String, Vec<String>),
92}
93
94// ── Runtime configuration ──────────────────────────────────────────────
95//
96// `config::current()` is a process-wide cached snapshot of
97// `~/.zshrs/zshrs.toml` and is deliberately never re-read. `ai -S` has
98// to be able to change a default for the rest of the session, so the
99// live values sit in an overlay seeded from that snapshot.
100
101static 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
107/// Apply one `key=value` assignment to the session's `[ai]` defaults.
108/// Unknown keys are an error rather than a silent no-op — a typo in
109/// `ai -S modle=...` must not look like it worked.
110fn 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
138/// Render the session's `[ai]` defaults as `key=value` lines.
139fn 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
153// ── Cost tracking (stryke ai.rs:122-157) ───────────────────────────────
154
155static 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
169/// Published per-million-token rates, `(input, output)` in USD.
170///
171/// Hardcoded so cost accounting never depends on a network call, and
172/// keyed by prefix so a dated snapshot id resolves to its family. An
173/// unrecognised model falls back to Sonnet-class rates, which makes an
174/// unknown model over-report rather than under-report against the
175/// `max_cost_run` ceiling.
176///
177/// This is the one place the port deviates in VALUE from
178/// `strykelang/strykelang/ai.rs:141-157`: that table predates the
179/// Claude 4/5 families and still prices `claude-opus` at $15/$75 per
180/// MTok. Order matters — `claude-opus-4-5` must be tested before the
181/// bare `claude-opus` prefix.
182fn 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
200/// Bill one exchange against the running total.
201///
202/// The two cache figures are Anthropic-only and priced off the INPUT
203/// rate: a cache write costs 1.25x base, a cache read 0.10x
204/// (stryke ai.rs:1050-1053). Providers that report no cache usage pass
205/// zeroes and the term vanishes.
206fn 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
221// ── Response cache (stryke ai.rs:159-181) ──────────────────────────────
222
223static 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
231/// SHA-256 over the four inputs that determine a response, NUL-separated
232/// so `("ab", "c")` and `("a", "bc")` cannot collide.
233fn 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
243// ── Mock mode (stryke ai.rs:183-215) ───────────────────────────────────
244
245static 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
259/// True when `$ZSHRS_AI_MODE` forbids reaching the network at all.
260/// Tests set this so a missing mock is a hard error instead of a silent
261/// live call that costs money and needs a key.
262fn 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// ── Call history (stryke ai.rs:2548-2600) ──────────────────────────────
270
271#[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
303// ── Resolved per-call options ──────────────────────────────────────────
304
305/// One invocation's settings, after flags have been merged over the
306/// session defaults. Mirrors the `opt_str`/`opt_int`/`opt_bool` block at
307/// the top of stryke's `ai_prompt` (`ai.rs:227-236`).
308struct Call {
309    provider: String,
310    model: String,
311    system: String,
312    prompt: String,
313    max_tokens: i64,
314    /// Negative means "send no `temperature` field at all", which is how
315    /// stryke distinguishes unset from an explicit `0.0`.
316    temperature: f64,
317    timeout: i64,
318    cache: bool,
319    cache_control: bool,
320    stream: bool,
321}
322
323/// Where generated text goes as it arrives.
324enum Sink<'a> {
325    /// Straight to stdout, flushed per delta so a stream is visible as
326    /// it is produced rather than at end of turn.
327    Stdout(std::io::StdoutLock<'a>),
328    /// Accumulated for assignment to a shell parameter.
329    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// ── Flag parsing ───────────────────────────────────────────────────────
345
346/// Everything `parse_flags` extracted, before defaults are applied.
347#[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    /// A report/maintenance flag short-circuits the whole call.
362    report: Option<Report>,
363    set: Vec<String>,
364}
365
366/// The non-calling modes. These are flags rather than subcommand words
367/// on purpose: `ai cost of living in Oslo` has to stay a prompt.
368#[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            // A lone `-` is the conventional "read stdin" word, not a
434            // flag; anything else starting with `-` is a typo worth
435            // reporting rather than silently prompting with.
436            "-" => 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
465// ── Reports ────────────────────────────────────────────────────────────
466
467fn 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
481/// One call per line, tab-separated, so the output is `cut`-able and
482/// `read -A`-able — the shell-native equivalent of stryke's `ai_history`
483/// returning an arrayref of hashrefs (`ai.rs:2601-2634`). Tabs and
484/// newlines inside the recorded prompt are flattened to spaces so a
485/// multi-line prompt cannot forge extra columns or rows.
486fn 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
503// ── Entry point ────────────────────────────────────────────────────────
504
505/// Run one `ai` invocation. `args` is argv WITHOUT the builtin name.
506///
507/// Streams to stdout unless the result is destined for a parameter, in
508/// which case it is buffered and handed back for the caller to assign.
509pub 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    // `-S` on its own is a configuration command, not a prompt.
519    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            // Buffered-but-unassigned (`-b`) still has to reach stdout,
545            // and a streamed response needs the trailing newline the
546            // API never sends.
547            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
589/// Merge flags over the session defaults and pull the prompt together.
590fn 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        // Streaming is Anthropic-only; the other providers here are
611        // request/response, and a stream flag on them would be a lie.
612        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
625/// The dispatch order of stryke's `ai_prompt` (`ai.rs:238-395`), kept
626/// intact: mock, then cache, then cost ceiling, then the provider.
627fn 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
737/// Config `base_url`, else the named env var, else the built-in default.
738fn 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
753// ── Provider: Anthropic (stryke ai.rs:994-1106) ────────────────────────
754
755const 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
764/// Build the request body shared by the buffered and streaming paths.
765fn 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            // Turning the system prefix into a block list with
774            // `cache_control` is what makes a repeated system prompt
775            // bill at cache-read rates instead of full input rates.
776            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
798/// Exponential backoff, capped: 1s, 2s, 4s, 8s, 16s, 30s.
799fn 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        // A refusal is a 200 with no text block; say which, because
875        // "no content" alone sends the user hunting for a network fault
876        // that isn't there.
877        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
890/// Streaming variant. Deltas reach `sink` as they arrive; the assembled
891/// text still comes back so the cache and history see the same value the
892/// buffered path would have produced.
893fn 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
916/// Decode an Anthropic SSE body, pushing each text delta to `sink`.
917///
918/// Ported from stryke's `AnthropicStreamIter::next_item`
919/// (`ai.rs:482-541`) with one correction the shape makes possible:
920/// there, the token counters are locals inside `next_item`, so they are
921/// reset on every call and only whatever arrived during the LAST call is
922/// ever billed. Draining the stream in one loop lets the counters live
923/// across the whole response, which is what the accounting meant.
924fn 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            // An `error` event arrives mid-stream with a 200 already on
967            // the wire, so it cannot surface as an HTTP status.
968            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 whatever the stream did report, even on a mid-stream failure:
979    // those tokens were generated and will be invoiced.
980    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
993// ── Provider: OpenAI and compatibles (stryke ai.rs:1109-1173) ──────────
994
995fn call_openai(call: &Call, url: &str, key_env: &str) -> Result<String> {
996    // Local OpenAI-compatible servers (LM Studio, llama-server, vLLM)
997    // usually want no auth at all, so a missing key is not fatal here —
998    // send the request bare and let the server object if it cares.
999    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
1050// ── Provider: Ollama (stryke ai.rs:3447-3505) ──────────────────────────
1051
1052fn 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    // Ollama needs an Ollama-tagged model name. If the model still
1072    // carries a hosted-provider default the user never overrode, swap in
1073    // a small local one rather than sending a name Ollama cannot resolve.
1074    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    // Tokens are tracked, cost is not: the model ran on this machine.
1105    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
1121// ── Provider: Gemini (stryke ai.rs:3511-3585) ──────────────────────────
1122
1123fn 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    /// Every counter and table in this module is process-global, so the
1189    /// tests have to run one at a time against a known-zero baseline.
1190    ///
1191    /// Nothing enforced that "one at a time" until this returned the
1192    /// crate-wide guard. `cargo test --lib -- ai::tests` failed 2-3 of 17
1193    /// on any parallel run while passing 17/17 under `--test-threads=1`
1194    /// and passing individually: one test zeroed the billing counters, or
1195    /// cleared `mocks()`/`cache()`, in the middle of another's
1196    /// measurement. The caller binds the guard and holds it for the test.
1197    #[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        // The regression this pins: stryke's table (ai.rs:143) prices
1220        // any `claude-opus*` at $15/$75 per MTok, which over-bills Opus 5
1221        // by 3x and would trip `max_cost_run` two-thirds early.
1222        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        // Sonnet 4.6 keeps its own rate and must not fall into the
1227        // generic `claude-sonnet` arm below it.
1228        assert_eq!(price_per_mtok("claude-sonnet-4-6"), (3.00, 15.00));
1229        // An unknown model bills at Sonnet-class rates, which over-states
1230        // a cheap model rather than under-stating an expensive one.
1231        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        // 1M input, 0 output, 1M cache-write, 1M cache-read on Opus 5:
1238        // 5.00 + 0 + 5.00*1.25 + 5.00*0.10 = 11.75.
1239        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        // Without the NUL separators these two would hash identically,
1252        // and a system-prompt change would silently reuse the old answer.
1253        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        // Silently prompting with "-Z" would burn a paid call on a typo.
1301        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        // A mock is free and is not a cache hit.
1332        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        // The billing bug this pins: stryke keeps the token counters as
1376        // locals inside `next_item`, so only the final chunk's usage is
1377        // ever charged. Here `message_start` and `message_delta` sit at
1378        // opposite ends of the body and both must land.
1379        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        // 1M in at $5 + 1M out at $25.
1403        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        // Those input tokens were consumed and will be invoiced.
1431        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        // An explicit 0.0 must be sent; only a negative means "omit".
1480        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}