Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
97 KiB
JSON
1 line
97 KiB
JSON
{"repo": "ad-si/cai", "files": {"src/lib.rs": {"6ebc7d6a8c9753f4ae7e37d02235ab085d684647": {"t": 1786067479.3346078, "n": 94015, "h": "72fdc56ed2389c1d", "c": "pub mod agent;\nmod highlight;\nmod types;\n\nuse base64::Engine;\nuse std::collections::HashMap;\nuse std::env;\nuse std::error::Error;\nuse std::io::Write;\nuse std::str;\nuse std::time::Instant;\n\nuse chrono::Utc;\nuse color_print::{cformat, cprintln};\nuse config::Config;\nuse futures::future::join_all;\nuse futures::StreamExt;\nuse reqwest::Response;\nuse serde_derive::{Deserialize, Serialize};\nuse serde_json::{json, Map, Value};\npub use types::is_deepseek_model;\npub use types::Commands;\nuse xdg::BaseDirectories;\n\n/// Filler words to exclude from generated filenames\nconst FILLER_WORDS: &[&str] = &[\n \"a\", \"an\", \"the\", \"of\", \"in\", \"on\", \"at\", \"to\", \"for\", \"with\", \"and\", \"or\",\n \"is\", \"it\", \"that\", \"this\", \"as\", \"by\", \"from\",\n];\n\n/// Generate a short name from a prompt for use in filenames\n/// (lowercase, underscores, filters filler words, max 30 chars)\nfn prompt_to_short_name(prompt: &str) -> String {\n prompt\n .to_lowercase()\n .chars()\n .map(|c| if c.is_alphanumeric() { c } else { '_' })\n .collect::<String>()\n .split('_')\n .filter(|s| !s.is_empty() && !FILLER_WORDS.contains(s))\n .collect::<Vec<&str>>()\n .join(\"_\")\n .chars()\n .take(30)\n .collect()\n}\n\n/// Format elapsed time for display - show in seconds if > 10 seconds, otherwise in milliseconds\nfn format_elapsed_time(elapsed_millis: u128) -> (String, &'static str) {\n if elapsed_millis > 10_000 {\n let seconds = elapsed_millis as f64 / 1000.0;\n (format!(\"{:.1}\", seconds), \"s\")\n } else {\n (elapsed_millis.to_string(), \"ms\")\n }\n}\n\n/// Get a provider base URL from config, with fallback to default\nfn get_base_url(\n full_config: &HashMap<String, String>,\n key: &str,\n default: &str,\n) -> String {\n full_config\n .get(key)\n .filter(|s| !s.is_empty())\n .map(|s| s.trim_end_matches('/').to_string())\n .unwrap_or_else(|| default.to_string())\n}\n\n#[derive(Serialize, Debug, PartialEq, Clone)]\npub struct ExecOptions {\n pub is_raw: bool, // Raw output mode (no metadata and no syntax highlighting)\n pub is_json: bool, // JSON output mode\n pub json_schema: Option<Value>, // JSON schema of expected output\n pub subcommand: Option<Commands>, // Optional subcommand that was executed\n pub is_streaming: bool, // Stream tokens as they arrive\n}\n\nimpl Default for ExecOptions {\n fn default() -> Self {\n Self {\n is_raw: false,\n is_json: false,\n json_schema: None,\n subcommand: None,\n is_streaming: true,\n }\n }\n}\n\n#[derive(Serialize, Debug, PartialEq, Default, Clone, Copy)]\npub enum Provider {\n #[default]\n Anthropic,\n Cerebras,\n DeepSeek,\n Google,\n Groq,\n OpenAI,\n Llamafile,\n Ollama,\n XAI,\n Perplexity,\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Cerebras => write!(f, \"Cerebras\"),\n Provider::DeepSeek => write!(f, \"DeepSeek\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Llamafile => write!(f, \"Llamafile\"),\n Provider::Ollama => write!(f, \"Ollama\"),\n Provider::OpenAI => write!(f, \"OpenAI\"),\n Provider::XAI => write!(f, \"xAI\"),\n Provider::Perplexity => write!(f, \"Perplexity\"),\n }\n }\n}\n\n#[derive(Serialize, Debug, PartialEq, Clone)]\npub enum Model {\n Model(Provider, String),\n}\n\nimpl Default for Model {\n fn default() -> Model {\n Model::Model(Provider::Cerebras, \"gpt-oss-120b\".to_owned())\n }\n}\n\nimpl std::fmt::Display for Model {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Model::Model(provider, model_id) => {\n if model_id.is_empty() {\n write!(f, \"{provider}\")\n } else {\n write!(f, \"{provider} {model_id}\")\n }\n }\n }\n }\n}\n\n#[derive(Serialize, Debug, Clone)]\nstruct AiRequest {\n provider: Provider,\n url: String,\n model: String,\n prompt: String,\n max_tokens: u32,\n api_key: String,\n}\n\nimpl Default for AiRequest {\n fn default() -> AiRequest {\n AiRequest {\n provider: Default::default(),\n url: Default::default(),\n model: Default::default(),\n prompt: Default::default(),\n max_tokens: 4096,\n api_key: Default::default(),\n }\n }\n}\n\n#[derive(Deserialize, Debug)]\nstruct AiMessage {\n // role: String,\n content: String,\n}\n\n#[derive(Deserialize, Debug)]\nstruct AiChoice {\n // index: u32,\n message: AiMessage,\n // logprobs: Option<Value>,\n // finish_reason: String,\n}\n\n#[derive(Deserialize, Debug)]\nstruct SearchResult {\n title: String,\n url: String,\n date: Option<String>,\n last_updated: Option<String>,\n}\n\n#[derive(Deserialize, Debug)]\nstruct AiResponse {\n choices: Vec<AiChoice>,\n search_results: Option<Vec<SearchResult>>,\n}\n\n/// For Anthropic's API\n/// (https://docs.anthropic.com/claude/reference/messages_post)\n#[derive(Deserialize, Debug)]\nstruct AnthropicAiContent {\n text: String,\n}\n\n#[derive(Deserialize, Debug)]\nstruct AnthropicAiResponse {\n content: Vec<AnthropicAiContent>,\n}\n\nfn default_req_for_model(\n model: &Model,\n full_config: &HashMap<String, String>,\n) -> AiRequest {\n let Model::Model(provider, model_id) = model;\n\n match provider {\n Provider::Anthropic => {\n let base_url = get_base_url(\n full_config,\n \"anthropic_base_url\",\n \"https://api.anthropic.com/v1\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/messages\"),\n model: types::get_anthropic_model(model_id).to_string(),\n ..Default::default()\n }\n }\n Provider::Cerebras => {\n let base_url = get_base_url(\n full_config,\n \"cerebras_base_url\",\n \"https://api.cerebras.ai/v1\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n model: types::get_cerebras_model(model_id).to_string(),\n ..Default::default()\n }\n }\n Provider::DeepSeek => {\n let base_url = get_base_url(\n full_config,\n \"deepseek_base_url\",\n \"https://api.deepseek.com\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n model: types::get_deepseek_model(model_id).to_string(),\n ..Default::default()\n }\n }\n Provider::Google => {\n let resolved_model = types::get_google_model(model_id);\n let base_url = get_base_url(\n full_config,\n \"google_base_url\",\n \"https://generativelanguage.googleapis.com/v1beta\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/models\"),\n model: resolved_model.to_string(),\n ..Default::default()\n }\n }\n Provider::Groq => {\n let base_url = get_base_url(\n full_config,\n \"groq_base_url\",\n \"https://api.groq.com/openai/v1\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n model: types::get_groq_model(model_id).to_string(),\n ..Default::default()\n }\n }\n Provider::Llamafile => {\n let base_url = get_base_url(\n full_config,\n \"llamafile_base_url\",\n \"http://localhost:8080/v1\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n ..Default::default()\n }\n }\n Provider::Ollama => {\n let base_url = get_base_url(\n full_config,\n \"ollama_base_url\",\n \"http://localhost:11434/v1\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n model: types::get_ollama_model(model_id).to_string(),\n ..Default::default()\n }\n }\n Provider::OpenAI => {\n let resolved_model = types::get_openai_model(model_id);\n let base_url = get_base_url(\n full_config,\n \"openai_base_url\",\n \"https://api.openai.com/v1\",\n );\n let url = if resolved_model.contains(\"-tts\") {\n format!(\"{base_url}/audio/speech\")\n } else if resolved_model.starts_with(\"gpt-image\")\n || resolved_model.starts_with(\"dall\")\n {\n format!(\"{base_url}/images/generations\")\n } else {\n format!(\"{base_url}/chat/completions\")\n };\n AiRequest {\n provider: *provider,\n url,\n model: resolved_model.to_string(),\n ..Default::default()\n }\n }\n Provider::XAI => {\n let resolved_model = types::get_xai_model(model_id);\n let base_url =\n get_base_url(full_config, \"xai_base_url\", \"https://api.x.ai/v1\");\n let url = if resolved_model == \"grok-2-image\" {\n format!(\"{base_url}/images/generations\")\n } else {\n format!(\"{base_url}/chat/completions\")\n };\n AiRequest {\n provider: *provider,\n url,\n model: resolved_model.to_string(),\n ..Default::default()\n }\n }\n Provider::Perplexity => {\n let base_url = get_base_url(\n full_config,\n \"perplexity_base_url\",\n \"https://api.perplexity.ai\",\n );\n AiRequest {\n provider: *provider,\n url: format!(\"{base_url}/chat/completions\"),\n model: types::get_perplexity_model(model_id).to_string(),\n ..Default::default()\n }\n }\n }\n}\n\nfn get_key_setup_msg(secrets_path_str: &str) -> String {\n format!(\n \"An API key must be provided. Use one of the following options:\\n\\\n \\n\\\n 1. Set one or more API keys in {secrets_path_str}\\n\\\n (`anthropic_api_key`, `google_api_key`, `groq_api_key`, `openai_api_key`, `perplexity_api_key`)\\n\\\n 2. Set one or more cai specific env variables\\n\\\n (CAI_ANTHROPIC_API_KEY, CAI_GOOGLE_API_KEY, CAI_GROQ_API_KEY, CAI_OPENAI_API_KEY, CAI_PERPLEXITY_API_KEY)\\n\\\n 3. Set one or more generic env variables\\n\\\n (ANTHROPIC_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY, OPENAI_API_KEY, PERPLEXITY_API_KEY)\\n\\\n \",\n )\n}\n\nfn get_api_request(\n full_config: &HashMap<String, String>,\n secrets_path_str: &str,\n model: &Model,\n) -> Result<AiRequest, String> {\n let dummy_key = \"DUMMY_KEY\".to_string();\n let Model::Model(provider, _) = model;\n\n {\n match provider {\n Provider::Anthropic => full_config.get(\"anthropic_api_key\"),\n Provider::Cerebras => full_config.get(\"cerebras_api_key\"),\n Provider::DeepSeek => full_config.get(\"deepseek_api_key\"),\n Provider::Google => full_config.get(\"google_api_key\"),\n Provider::Groq => full_config.get(\"groq_api_key\"),\n Provider::Llamafile => Some(&dummy_key),\n Provider::Ollama => Some(&dummy_key),\n Provider::OpenAI => full_config.get(\"openai_api_key\"),\n Provider::XAI => full_config.get(\"xai_api_key\"),\n Provider::Perplexity => full_config.get(\"perplexity_api_key\"),\n }\n }\n .and_then(|api_key| {\n if api_key.is_empty() {\n None\n } else {\n Some(api_key.to_string())\n }\n })\n .map(|api_key| api_key.to_string())\n .ok_or(get_key_setup_msg(secrets_path_str))\n .map(|api_key| AiRequest {\n api_key: api_key.clone(),\n ..(default_req_for_model(model, full_config)).clone()\n })\n}\n\nfn get_used_model(model: &Model) -> String {\n let Model::Model(provider, model_id) = model;\n\n if model_id.is_empty() {\n cformat!(\"<bold>🧠 {}</bold>\", provider)\n } else {\n let full_model_id = match provider {\n Provider::Anthropic => types::get_anthropic_model(model_id),\n Provider::Cerebras => types::get_cerebras_model(model_id),\n Provider::DeepSeek => types::get_deepseek_model(model_id),\n Provider::Google => types::get_google_model(model_id),\n Provider::Groq => types::get_groq_model(model_id),\n Provider::Llamafile => model_id,\n Provider::Ollama => types::get_ollama_model(model_id),\n Provider::OpenAI => types::get_openai_model(model_id),\n Provider::XAI => types::get_xai_model(model_id),\n Provider::Perplexity => types::get_perplexity_model(model_id),\n };\n cformat!(\"<bold>🧠 {} {}</bold>\", provider, full_model_id)\n }\n}\n\n/// Parse a provider name (case-insensitive) as used in config model overrides.\nfn provider_from_name(name: &str) -> Option<Provider> {\n match name.trim().to_lowercase().as_str() {\n \"anthropic\" => Some(Provider::Anthropic),\n \"cerebras\" => Some(Provider::Cerebras),\n \"deepseek\" => Some(Provider::DeepSeek),\n \"google\" => Some(Provider::Google),\n \"groq\" => Some(Provider::Groq),\n \"openai\" => Some(Provider::OpenAI),\n \"llamafile\" => Some(Provider::Llamafile),\n \"ollama\" => Some(Provider::Ollama),\n \"xai\" => Some(Provider::XAI),\n \"perplexity\" => Some(Provider::Perplexity),\n _ => None,\n }\n}\n\n/// Parse a model override string of the form `<provider> <model_id>`\n/// (e.g. `anthropic claude-opus-4-8`). A bare provider name with no model id\n/// is also accepted (e.g. `llamafile`).\nfn parse_model_override(value: &str) -> Option<Model> {\n let value = value.trim();\n match value.split_once(char::is_whitespace) {\n Some((provider, model_id)) => provider_from_name(provider)\n .map(|p| Model::Model(p, model_id.trim().to_string())),\n None => provider_from_name(value).map(|p| Model::Model(p, String::new())),\n }\n}\n\n/// Lazily loaded, process-wide configuration used to resolve shortcut model\n/// overrides. Falls back to an empty map if the config can't be read.\nstatic CONFIG_CACHE: std::sync::OnceLock<HashMap<String, String>> =\n std::sync::OnceLock::new();\n\nfn cached_config() -> &'static HashMap<String, String> {\n CONFIG_CACHE.get_or_init(|| {\n let secrets_path_str = get_secrets_path_str();\n get_full_config(&secrets_path_str).unwrap_or_default()\n })\n}\n\n/// Look up a per-shortcut model override from `~/.config/cai/config.yaml`.\n///\n/// Overrides live under a `shortcut_models:` table keyed by shortcut name,\n/// e.g.:\n/// ```yaml\n/// shortcut_models:\n/// fast: groq llama-3.1-8b-instant\n/// opus: openai gpt-4.1\n/// ```\n/// Returns `None` when the shortcut isn't overridable, no override is set, or\n/// the override value is malformed (a warning is printed in the latter case).\npub fn shortcut_model_override(cmd: &Commands) -> Option<Model> {\n let key = cmd.config_key()?;\n let raw = cached_config().get(&format!(\"shortcut_models.{key}\"))?;\n if raw.trim().is_empty() {\n return None;\n }\n match parse_model_override(raw) {\n Some(model) => Some(model),\n None => {\n eprintln!(\n \"⚠️ Invalid model override for `shortcut_models.{key}`: '{raw}'. \\\n Expected format '<provider> <model>', \\\n e.g. 'anthropic claude-opus-4-8'. Using default.\"\n );\n None\n }\n }\n}\n\n/// Resolve the model for a shortcut, preferring a config override over the\n/// built-in `default`.\npub fn shortcut_model(cmd: &Commands, default: Model) -> Model {\n shortcut_model_override(cmd).unwrap_or(default)\n}\n\nfn get_secrets_path_str() -> String {\n let xdg_dirs = BaseDirectories::with_prefix(\"cai\").unwrap();\n let secrets_path = xdg_dirs\n .place_config_file(\"secrets.yaml\")\n .expect(\"Couldn't create configuration directory\");\n let _ = std::fs::File::create_new(&secrets_path);\n secrets_path.to_str().unwrap().to_string()\n}\n\nfn get_config_path_str() -> String {\n let xdg_dirs = BaseDirectories::with_prefix(\"cai\").unwrap();\n let config_path = xdg_dirs\n .place_config_file(\"config.yaml\")\n .expect(\"Couldn't create configuration directory\");\n let _ = std::fs::File::create_new(&config_path);\n config_path.to_str().unwrap().to_string()\n}\n\npub fn get_full_config(\n secrets_path_str: &str,\n) -> Result<\n HashMap<std::string::String, std::string::String>,\n config::ConfigError,\n> {\n let config_path_str = get_config_path_str();\n let config = Config::builder()\n .set_default(\n \"anthropic_api_key\",\n env::var(\"ANTHROPIC_API_KEY\").unwrap_or_default(),\n )?\n .set_default(\n \"openai_api_key\",\n env::var(\"OPENAI_API_KEY\").unwrap_or_default(),\n )?\n .set_default(\n \"google_api_key\",\n env::var(\"GOOGLE_API_KEY\").unwrap_or_default(),\n )?\n .set_default(\n \"groq_api_key\", //\n env::var(\"GROQ_API_KEY\").unwrap_or_default(),\n )?\n .set_default(\n \"perplexity_api_key\", //\n env::var(\"PERPLEXITY_API_KEY\").unwrap_or_default(),\n )?\n .add_source(config::File::with_name(secrets_path_str))\n .add_source(config::File::with_name(&config_path_str).required(false))\n .add_source(config::Environment::with_prefix(\"CAI\"))\n .build()\n .unwrap();\n\n // Deserialize into the config crate's own value type first so that nested\n // tables (e.g. the `models:` section) don't break a flat string mapping.\n // Nested tables are then flattened into dotted keys (e.g. `models.fast`).\n let raw = config.try_deserialize::<HashMap<String, config::Value>>()?;\n let mut flat = HashMap::new();\n for (key, value) in raw {\n flatten_config_value(&key, value, &mut flat);\n }\n Ok(flat)\n}\n\n/// Flatten a (possibly nested) config value into dotted string keys.\n/// Tables recurse with a `parent.child` prefix; scalars are stringified;\n/// arrays and nulls are dropped.\nfn flatten_config_value(\n prefix: &str,\n value: config::Value,\n out: &mut HashMap<String, String>,\n) {\n match value.kind {\n config::ValueKind::Table(table) => {\n for (key, val) in table {\n let full_key = if prefix.is_empty() {\n key\n } else {\n format!(\"{prefix}.{key}\")\n };\n flatten_config_value(&full_key, val, out);\n }\n }\n config::ValueKind::Nil | config::ValueKind::Array(_) => {}\n _ => {\n if let Ok(string) = value.into_string() {\n out.insert(prefix.to_string(), string);\n }\n }\n }\n}\n\nfn get_http_req(\n optional_model: &Option<&Model>,\n secrets_path_str: &str,\n full_config: &HashMap<String, String>,\n) -> Result<(String, AiRequest), std::string::String> {\n match optional_model {\n Some(model) => {\n let used_model = get_used_model(model);\n get_api_request(full_config, secrets_path_str, model)\n .map(|req| (used_model, req))\n }\n // Prefer the `fast` shortcut's model (overridable via\n // `shortcut_models.fast`), then fall back to the first provider with a key.\n None => {\n let fast_model = shortcut_model(\n &Commands::Fast { prompt: vec![] },\n Model::Model(Provider::Cerebras, \"gpt-oss-120b\".to_owned()),\n );\n let req = get_api_request(full_config, secrets_path_str, &fast_model)\n .or(get_api_request(\n full_config,\n secrets_path_str,\n &Model::Model(Provider::Cerebras, \"gpt-oss-120b\".to_owned()),\n ))\n .or(get_api_request(\n full_config,\n secrets_path_str,\n &Model::Model(Provider::OpenAI, \"gpt-5-mini\".to_string()),\n ))\n .or(get_api_request(\n full_config,\n secrets_path_str,\n &Model::Model(Provider::Anthropic, \"claude-haiku-4-5\".to_string()),\n ))?;\n let used_model = get_used_model(\n &Model::Model(req.provider, req.model.clone()), //\n );\n Ok((used_model, req))\n }\n }\n}\n\nfn get_req_body_obj(\n opts: &ExecOptions,\n http_req: &AiRequest,\n user_input: &str,\n) -> Value {\n // Handle case where input is already a complete JSON string\n if let Ok(json) = serde_json::from_str(user_input) {\n return json;\n }\n\n // Special handling for Google's Gemini API\n if http_req.provider == Provider::Google {\n let mut contents = Map::new();\n contents.insert(\"role\".to_string(), \"user\".into());\n contents.insert(\n \"parts\".to_string(),\n Value::Array(vec![Value::Object(Map::from_iter([(\n \"text\".to_string(),\n Value::String(user_input.to_string()),\n )]))]),\n );\n\n let mut generation_config = Map::new();\n generation_config.insert(\n \"maxOutputTokens\".to_string(),\n Value::Number(http_req.max_tokens.into()),\n );\n\n // Add image generation specific config for image models\n if http_req.model.contains(\"-image\") {\n generation_config.insert(\n \"responseModalities\".to_string(),\n Value::Array(vec![Value::String(\"IMAGE\".to_string())]),\n );\n }\n\n let mut map = Map::new();\n map.insert(\n \"contents\".to_string(),\n Value::Array(vec![Value::Object(contents)]),\n );\n map.insert(\n \"generationConfig\".to_string(),\n Value::Object(generation_config),\n );\n\n return Value::Object(map);\n }\n\n // Special handling for OpenAI TTS models\n if http_req.provider == Provider::OpenAI && http_req.model.contains(\"-tts\") {\n let mut map = Map::new();\n map.insert(\"model\".to_string(), Value::String(http_req.model.clone()));\n map.insert(\"input\".to_string(), Value::String(user_input.to_string()));\n map.insert(\"voice\".to_string(), Value::String(\"alloy\".to_string()));\n return Value::Object(map);\n }\n\n // Special handling for OpenAI image generation models (gpt-image and DALL-E)\n let is_image_generation = matches!(&opts.subcommand, Some(Commands::Openai { model, .. }) if model == \"image\")\n || matches!(&opts.subcommand, Some(Commands::Image { .. }));\n\n if http_req.provider == Provider::OpenAI\n && (is_image_generation\n || http_req.model.starts_with(\"gpt-image\")\n || http_req.model.starts_with(\"dall-e\"))\n {\n let mut map = Map::new();\n map.insert(\"model\".to_string(), Value::String(http_req.model.clone()));\n map.insert(\"prompt\".to_string(), Value::String(user_input.to_string()));\n\n if let Some(Commands::Image {\n background: Some(bg),\n ..\n }) = &opts.subcommand\n {\n map.insert(\"background\".to_string(), Value::String(bg.clone()));\n }\n\n return Value::Object(map);\n }\n\n // Special handling for xAI grok-2-image model (use images API)\n if http_req.provider == Provider::XAI && http_req.model == \"grok-2-image\" {\n let mut map = Map::new();\n map.insert(\"model\".to_string(), Value::String(http_req.model.clone()));\n map.insert(\"prompt\".to_string(), Value::String(user_input.to_string()));\n map.insert(\"n\".to_string(), Value::Number(1.into()));\n\n return Value::Object(map);\n }\n\n // For all other providers\n let mut map = Map::new();\n map.insert(\"model\".to_string(), Value::String(http_req.model.clone()));\n // OpenAI o1, o3, o4, and gpt-5 models require max_completion_tokens instead of max_tokens\n if http_req.provider == Provider::OpenAI\n && (http_req.model.starts_with(\"o1\")\n || http_req.model.starts_with(\"o3\")\n || http_req.model.starts_with(\"o4\")\n || http_req.model.starts_with(\"gpt-5\"))\n {\n map.insert(\n \"max_completion_tokens\".to_string(),\n Value::Number(http_req.max_tokens.into()),\n );\n } else {\n map.insert(\n \"max_tokens\".to_string(),\n Value::Number(http_req.max_tokens.into()),\n );\n }\n\n if opts.is_json {\n match http_req.provider {\n Provider::OpenAI | Provider::Groq | Provider::Ollama => {\n map.insert(\n \"response_format\".to_string(),\n Value::Object(Map::from_iter([(\n \"type\".to_string(),\n Value::String(\"json_object\".to_string()),\n )])),\n );\n }\n provider => {\n eprintln!(\n \"{}\",\n cformat!(\"<red>ERROR: {provider} doesn't support a JSON mode</red>\",)\n );\n std::process::exit(1);\n }\n }\n }\n\n if opts.json_schema.is_some() {\n match http_req.provider {\n Provider::OpenAI | Provider::Ollama => {\n let mut json_schema = Map::new();\n json_schema.insert(\"type\".to_string(), \"json_schema\".into());\n json_schema.insert(\n \"json_schema\".to_string(),\n opts.json_schema.clone().unwrap(), //\n );\n\n map.insert(\"response_format\".to_string(), Value::Object(json_schema));\n }\n provider => {\n eprintln!(\n \"{}\",\n cformat!(\n \"<red>ERROR: {provider} doesn't support a JSON schema mode</red>\",\n )\n );\n std::process::exit(1);\n }\n }\n }\n\n map.insert(\n \"messages\".to_string(),\n Value::Array(vec![Value::Object(Map::from_iter([\n (\"role\".to_string(), \"user\".into()),\n (\"content\".to_string(), Value::String(user_input.to_string())),\n ]))]),\n );\n\n Value::Object(map)\n}\n\nasync fn exec_request(\n http_req: &AiRequest,\n req_body_obj: &Value,\n is_streaming: bool,\n) -> Result<Response, reqwest::Error> {\n let client = reqwest::Client::new();\n let req_base = client.post(http_req.url.clone()).json(&req_body_obj);\n let req = match http_req.provider {\n Provider::Anthropic => req_base\n .header(\"anthropic-version\", \"2023-06-01\")\n .header(\"x-api-key\", &http_req.api_key),\n Provider::Google => {\n // For Google's Gemini API we need to append the model name and the\n // generation action to the URL along with the API key as a query\n // parameter. When streaming, use \":streamGenerateContent\" with SSE.\n let model = &http_req.model;\n let url = if is_streaming {\n format!(\n \"{}/{model}:streamGenerateContent?alt=sse&key={}\",\n http_req.url, http_req.api_key\n )\n } else {\n format!(\n \"{}/{model}:generateContent?key={}\",\n http_req.url, http_req.api_key\n )\n };\n client.post(url).json(&req_body_obj)\n }\n _ => req_base.bearer_auth(&http_req.api_key),\n };\n req.send().await\n}\n\n/// Whether a request returns text (vs binary like images or audio)\nfn is_text_response(http_req: &AiRequest, opts: &ExecOptions) -> bool {\n // OpenAI TTS\n if http_req.provider == Provider::OpenAI && http_req.model.contains(\"-tts\") {\n return false;\n }\n\n // OpenAI image generation (gpt-image, DALL-E, or `cai openai image …`)\n let is_image_generation =\n matches!(\n &opts.subcommand,\n Some(Commands::Openai { model, .. }) if model == \"image\"\n ) || matches!(&opts.subcommand, Some(Commands::Image { .. }));\n if http_req.provider == Provider::OpenAI\n && (is_image_generation\n || http_req.model.starts_with(\"gpt-image\")\n || http_req.model.starts_with(\"dall-e\"))\n {\n return false;\n }\n\n // xAI grok-2-image\n if http_req.provider == Provider::XAI && http_req.model == \"grok-2-image\" {\n return false;\n }\n\n // Google Gemini image generation\n if http_req.provider == Provider::Google && http_req.model.contains(\"-image\")\n {\n return false;\n }\n\n true\n}\n\n/// Detect whether the terminal has a light background by querying it via\n/// OSC 11. Falls back to dark on any error (e.g. no TTY, unsupported term).\nfn is_light_terminal() -> bool {\n matches!(terminal_light::luma(), Ok(luma) if luma > 0.5)\n}\n\n/// Render style tuned to the detected terminal background.\n///\n/// Code blocks get a subtle background tint via `dark` so they stand out\n/// from prose; everything else (table headers, surrounding fill) stays\n/// transparent so the terminal's own background shows through.\nfn transparent_render_style(\n light_terminal: bool,\n) -> streamdown_render::RenderStyle {\n let (bright, head, symbol, grey, code_bg) = if light_terminal {\n (\"#0050a0\", \"#005500\", \"#7d2eb6\", \"#606060\", \"#e8e8e8\")\n } else {\n (\"#87ceeb\", \"#98fb98\", \"#dda0dd\", \"#808080\", \"#1a1a2e\")\n };\n streamdown_render::RenderStyle {\n bright: bright.to_string(),\n head: head.to_string(),\n symbol: symbol.to_string(),\n grey: grey.to_string(),\n dark: code_bg.to_string(),\n // Empty hex strings make `bg_color` emit nothing for these slots,\n // keeping table headers and other fills transparent.\n mid: String::new(),\n light: String::new(),\n }\n}\n\n/// Wraps a writer so every `write` is followed by a flush, ensuring streamed\n/// content reaches the terminal as soon as the renderer emits it.\nstruct AutoFlush<W: Write>(W);\n\nimpl<W: Write> Write for AutoFlush<W> {\n fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n let n = self.0.write(buf)?;\n self.0.flush()?;\n Ok(n)\n }\n\n fn flush(&mut self) -> std::io::Result<()> {\n self.0.flush()\n }\n}\n\n/// Render one or more newline-separated lines through the streamdown\n/// markdown renderer, emitting events for any complete lines and keeping\n/// any trailing partial line in `line_buf` for the next chunk.\nfn render_through_streamdown(\n line_buf: &mut String,\n parser: &mut streamdown_parser::Parser,\n renderer: &mut streamdown_render::Renderer<AutoFlush<std::io::Stdout>>,\n flush_partial: bool,\n) -> std::io::Result<()> {\n while let Some(pos) = line_buf.find('\\n') {\n let line: String = line_buf.drain(..pos + 1).collect();\n let line_no_nl = &line[..line.len() - 1];\n for event in parser.parse_line(line_no_nl) {\n renderer.render_event(&event)?;\n }\n }\n if flush_partial && !line_buf.is_empty() {\n let remaining = std::mem::take(line_buf);\n for event in parser.parse_line(&remaining) {\n renderer.render_event(&event)?;\n }\n }\n Ok(())\n}\n\n/// Read an SSE response stream and emit text deltas as they arrive.\n/// In non-raw mode, output is rendered as markdown via `streamdown` so\n/// headings, code blocks, lists, etc. are styled even while streaming.\n/// In raw mode, deltas go to stdout unmodified for downstream piping.\n/// Returns the accumulated full text and any search results encountered.\nasync fn stream_text_response(\n resp: Response,\n provider: Provider,\n is_raw: bool,\n) -> Result<(String, Option<Vec<SearchResult>>), Box<dyn Error + Send + Sync>> {\n let mut full_text = String::new();\n let mut search_results: Option<Vec<SearchResult>> = None;\n let mut buffer: Vec<u8> = Vec::new();\n let mut byte_stream = resp.bytes_stream();\n\n let width = textwrap::termwidth();\n let light = is_light_terminal();\n let mut md_parser = streamdown_parser::Parser::new();\n let mut md_renderer = (!is_raw).then(|| {\n let mut r =\n streamdown_render::Renderer::new(AutoFlush(std::io::stdout()), width);\n r.set_style(transparent_render_style(light));\n if light {\n r.set_theme(\"InspiredGitHub\");\n }\n r\n });\n let mut line_buf = String::new();\n\n while let Some(chunk_result) = byte_stream.next().await {\n let chunk = chunk_result?;\n buffer.extend_from_slice(&chunk);\n\n // Parse complete SSE events (separated by blank lines).\n loop {\n let boundary = buffer\n .windows(2)\n .position(|w| w == b\"\\n\\n\")\n .map(|p| (p, 2))\n .or_else(|| {\n buffer\n .windows(4)\n .position(|w| w == b\"\\r\\n\\r\\n\")\n .map(|p| (p, 4))\n });\n let (pos, sep_len) = match boundary {\n Some(b) => b,\n None => break,\n };\n\n let event_bytes: Vec<u8> = buffer.drain(..pos + sep_len).collect();\n let event_str = match std::str::from_utf8(&event_bytes) {\n Ok(s) => s,\n Err(_) => continue,\n };\n\n for line in event_str.lines() {\n let data = match line.strip_prefix(\"data:\") {\n Some(d) => d.strip_prefix(' ').unwrap_or(d),\n None => continue,\n };\n if data == \"[DONE]\" {\n continue;\n }\n let json: Value = match serde_json::from_str(data) {\n Ok(v) => v,\n Err(_) => continue,\n };\n\n let text_delta: Option<String> = match provider {\n Provider::Anthropic => {\n if json[\"type\"].as_str() == Some(\"content_block_delta\") {\n json[\"delta\"][\"text\"].as_str().map(str::to_string)\n } else {\n None\n }\n }\n Provider::Google => json[\"candidates\"][0][\"content\"][\"parts\"][0]\n [\"text\"]\n .as_str()\n .map(str::to_string),\n _ => json[\"choices\"][0][\"delta\"][\"content\"]\n .as_str()\n .map(str::to_string),\n };\n\n if let Some(results) = json[\"search_results\"].as_array() {\n let parsed: Vec<SearchResult> = results\n .iter()\n .filter_map(|v| serde_json::from_value(v.clone()).ok())\n .collect();\n if !parsed.is_empty() {\n search_results = Some(parsed);\n }\n }\n\n if let Some(text) = text_delta {\n full_text.push_str(&text);\n if let Some(renderer) = md_renderer.as_mut() {\n line_buf.push_str(&text);\n render_through_streamdown(\n &mut line_buf,\n &mut md_parser,\n renderer,\n false,\n )?;\n } else {\n let mut stdout = std::io::stdout();\n stdout.write_all(text.as_bytes())?;\n stdout.flush()?;\n }\n }\n }\n }\n }\n\n // End of stream: render any trailing partial line, then close any\n // still-open blocks (lists, code fences, …) via `finalize()`.\n if let Some(renderer) = md_renderer.as_mut() {\n render_through_streamdown(&mut line_buf, &mut md_parser, renderer, true)?;\n for event in md_parser.finalize() {\n renderer.render_event(&event)?;\n }\n }\n\n Ok((full_text, search_results))\n}\n\npub async fn exec_tool(\n optional_model: &Option<&Model>,\n opts: &ExecOptions,\n user_input: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let start = Instant::now();\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let (used_model, http_req) =\n get_http_req(optional_model, &secrets_path_str, &full_config)?;\n\n // This is checked here, so that the missing API key message comes first\n if user_input.is_empty() {\n Err(\"No prompt was provided\")?;\n }\n\n let should_stream = opts.is_streaming && is_text_response(&http_req, opts);\n\n let mut req_body_obj = get_req_body_obj(opts, &http_req, user_input);\n // Google controls streaming via the URL (:streamGenerateContent), not a\n // body field — adding `stream: true` there is rejected as an unknown field.\n if should_stream && http_req.provider != Provider::Google {\n if let Some(obj) = req_body_obj.as_object_mut() {\n obj.insert(\"stream\".to_string(), Value::Bool(true));\n }\n }\n\n let resp = exec_request(&http_req, &req_body_obj, should_stream).await?;\n let subcommand = opts\n .subcommand\n .as_ref()\n .and_then(|x| x.to_string_pretty())\n .map(|subcom| format!(\"➡️ {subcom} | \"))\n .unwrap_or_default();\n\n if !&resp.status().is_success() {\n let elapsed_millis = start.elapsed().as_millis();\n let (elapsed_time, time_unit) = format_elapsed_time(elapsed_millis);\n let resp_json = resp.json::<Value>().await?;\n let resp_formatted = serde_json::to_string_pretty(&resp_json).unwrap();\n Err(cformat!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\\\n \\n{resp_formatted}\",\n elapsed_time,\n time_unit,\n ))?;\n } else if should_stream {\n if !opts.is_raw {\n cprintln!(\"<bold>{subcommand}{used_model}</bold>\\n\");\n }\n\n let (_full_text, search_results) =\n stream_text_response(resp, http_req.provider, opts.is_raw).await?;\n\n let elapsed_millis = start.elapsed().as_millis();\n let (elapsed_time, time_unit) = format_elapsed_time(elapsed_millis);\n\n if opts.is_raw {\n println!();\n } else {\n cprintln!(\"\\n<bold>⏱️ {} {}</bold>\", elapsed_time, time_unit);\n\n if let Some(results) = search_results {\n if !results.is_empty() {\n println!(\"\\n## Search Results\\n\");\n for (i, result) in results.iter().enumerate() {\n let index = i + 1;\n println!(\n \"[{index}] {title} ({url})\",\n title = result.title,\n url = result.url\n );\n if let Some(date) = &result.date {\n println!(\" Date: {date}\");\n }\n if let Some(last_updated) = &result.last_updated {\n println!(\" Updated: {last_updated}\");\n }\n }\n }\n }\n println!();\n }\n return Ok(());\n } else {\n let elapsed_millis = start.elapsed().as_millis();\n let (elapsed_time, time_unit) = format_elapsed_time(elapsed_millis);\n // Special handling for OpenAI TTS models - they return audio data\n if http_req.provider == Provider::OpenAI && http_req.model.contains(\"-tts\")\n {\n let audio_data = resp.bytes().await?;\n\n // Generate timestamp prefix in format: 2025-08-17t1943\n let now = Utc::now();\n let timestamp_prefix = now.format(\"%Y-%m-%dt%H%M\").to_string();\n\n // Find a unique filename with timestamp prefix\n let mut filename = format!(\"{timestamp_prefix}_output.mp3\");\n let mut counter = 1;\n while std::path::Path::new(&filename).exists() {\n filename = format!(\"{timestamp_prefix}_output_{counter}.mp3\");\n counter += 1;\n }\n\n std::fs::write(&filename, &audio_data)?;\n\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n println!(\"Audio generated and saved to: {filename}\");\n return Ok(());\n }\n\n // Check if this is an image generation request\n let is_image_generation = matches!(&opts.subcommand, Some(Commands::Openai { model, .. }) if model == \"image\")\n || matches!(&opts.subcommand, Some(Commands::Image { .. }));\n\n // Special handling for OpenAI image generation models\n if http_req.provider == Provider::OpenAI\n && (http_req.model.starts_with(\"dall-e\")\n || http_req.model.starts_with(\"gpt-image\")\n || is_image_generation)\n {\n let response_json = resp.json::<Value>().await?;\n\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n\n // Handle Images API format with base64 (gpt-image and dall-e models)\n if let Some(data) = response_json[\"data\"].as_array() {\n let mut image_count = 0;\n for image_data in data {\n image_count += 1;\n\n // Check for base64 format first\n if let Some(image_base64) = image_data[\"b64_json\"].as_str() {\n use base64::{engine::general_purpose, Engine as _};\n match general_purpose::STANDARD.decode(image_base64) {\n Ok(image_bytes) => {\n // Generate timestamp prefix in format: 2025-08-17t1943\n let now = Utc::now();\n let timestamp_prefix = now.format(\"%Y-%m-%dt%H%M\").to_string();\n\n // Extract original user prompt from subcommand if available\n // (for Photo/Image commands, user_input contains system instructions)\n let original_prompt = match &opts.subcommand {\n Some(Commands::Photo { prompt }) => prompt.join(\" \"),\n Some(Commands::Image { prompt, .. }) => prompt.join(\" \"),\n _ => user_input.to_string(),\n };\n\n let short_name = prompt_to_short_name(&original_prompt);\n\n // Find the next available filename\n let mut counter = 1;\n let mut filename =\n format!(\"{timestamp_prefix}_{short_name}.png\");\n while std::path::Path::new(&filename).exists() {\n counter += 1;\n filename =\n format!(\"{timestamp_prefix}_{short_name}_{counter}.png\");\n }\n\n match std::fs::write(&filename, image_bytes) {\n Ok(_) => println!(\"Generated image saved to: {filename}\"),\n Err(e) => println!(\"Failed to save image {image_count}: {e}\"),\n }\n }\n Err(e) => {\n println!(\"Failed to decode base64 for image {image_count}: {e}\")\n }\n }\n }\n // Fall back to URL format if base64 not present\n else if let Some(url) = image_data[\"url\"].as_str() {\n println!(\"Generated image {}: {}\", image_count, url);\n }\n }\n }\n\n return Ok(());\n }\n\n // Special handling for xAI grok-2-image model\n if http_req.provider == Provider::XAI && http_req.model == \"grok-2-image\" {\n let response_json = resp.json::<Value>().await?;\n\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n\n // xAI uses a similar format to DALL-E with data array containing URLs\n if let Some(data) = response_json[\"data\"].as_array() {\n for (i, image) in data.iter().enumerate() {\n if let Some(url) = image[\"url\"].as_str() {\n println!(\"Generated image {}: {}\", i + 1, url);\n }\n }\n }\n\n return Ok(());\n }\n\n // Special handling for Google Gemini image generation\n if http_req.provider == Provider::Google\n && http_req.model.contains(\"-image\")\n {\n let response_json = resp.json::<Value>().await?;\n\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n\n // Google Gemini returns images in the response as inline data\n if let Some(candidates) = response_json[\"candidates\"].as_array() {\n for candidate in candidates {\n if let Some(parts) = candidate[\"content\"][\"parts\"].as_array() {\n let mut image_count = 0;\n for part in parts {\n if let Some(inline_data) = part[\"inlineData\"].as_object() {\n if let Some(data_base64) = inline_data[\"data\"].as_str() {\n image_count += 1;\n use base64::{engine::general_purpose, Engine as _};\n match general_purpose::STANDARD.decode(data_base64) {\n Ok(image_bytes) => {\n // Generate timestamp prefix in format: 2025-08-17t1943\n let now = Utc::now();\n let timestamp_prefix =\n now.format(\"%Y-%m-%dt%H%M\").to_string();\n\n // Extract original user prompt from subcommand if available\n let original_prompt = match &opts.subcommand {\n Some(Commands::GoogleImage { prompt }) => {\n prompt.join(\" \")\n }\n _ => user_input.to_string(),\n };\n\n let short_name = prompt_to_short_name(&original_prompt);\n\n // Find a unique filename with timestamp prefix\n let mut filename =\n format!(\"{timestamp_prefix}_{short_name}.png\");\n let mut counter = 1;\n while std::path::Path::new(&filename).exists() {\n filename = format!(\n \"{timestamp_prefix}_{short_name}_{counter}.png\"\n );\n counter += 1;\n }\n\n match std::fs::write(&filename, image_bytes) {\n Ok(_) => {\n println!(\"Generated image saved to: {filename}\")\n }\n Err(e) => {\n println!(\"Failed to save image {image_count}: {e}\")\n }\n }\n }\n Err(e) => println!(\n \"Failed to decode base64 for image {image_count}: {e}\"\n ),\n }\n }\n }\n }\n }\n }\n }\n\n return Ok(());\n }\n\n let (msg, search_results) = match http_req.provider {\n Provider::Anthropic => {\n let anth_response = resp.json::<AnthropicAiResponse>().await?;\n (anth_response.content[0].text.clone(), None)\n }\n Provider::Google => {\n // Handle Google's unique response format\n let response_text = resp.text().await?;\n let response_json: Value = serde_json::from_str(&response_text)?;\n\n // Extract the text from the Gemini response format\n let text = response_json[\"candidates\"][0][\"content\"][\"parts\"][0]\n [\"text\"]\n .as_str()\n .unwrap_or_default()\n .to_string();\n (text, None)\n }\n _ => {\n let ai_response = resp.json::<AiResponse>().await?;\n let msg = ai_response.choices[0].message.content.clone();\n let search_results = ai_response.search_results;\n (msg, search_results)\n }\n };\n\n if opts.is_raw {\n println!(\"{msg}\");\n } else {\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n highlight::text_via_bat(&msg);\n\n // Display search results for Perplexity models\n if let Some(results) = search_results {\n if !results.is_empty() {\n println!(\"\\n\\n## Search Results\\n\");\n for (i, result) in results.iter().enumerate() {\n let index = i + 1;\n println!(\n \"[{index}] {title} ({url})\",\n title = result.title,\n url = result.url\n );\n if let Some(date) = &result.date {\n println!(\" Date: {date}\");\n }\n if let Some(last_updated) = &result.last_updated {\n println!(\" Updated: {last_updated}\");\n }\n }\n }\n }\n\n println!(\"\\n\");\n }\n }\n Ok(())\n}\n\npub async fn submit_prompt(\n optional_model: &Option<&Model>,\n opts: &ExecOptions,\n user_input: &str,\n) {\n // Necessary to wrap the execution function,\n // because a `main` function that returns a `Result` quotes any errors.\n match exec_tool(optional_model, opts, user_input).await {\n Ok(_) => (),\n Err(err) => {\n let model_str = optional_model\n .as_ref()\n .map(|x| x.to_string())\n .unwrap_or(\"\".to_string());\n eprintln!(\n \"{}\",\n cformat!(\"<bold>🧠 {model_str}</bold><red>\\nERROR:\\n{}</red>\\n\", err)\n );\n std::process::exit(1);\n }\n }\n}\n\npub async fn generate_changelog(\n opts: &ExecOptions,\n commit_hash: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let output = std::process::Command::new(\"git\")\n .args([\n \"log\",\n \"--date=short\",\n \"--pretty=format:%cd - %s%d\", // date - subject (refs)\n &format!(\"{commit_hash}..HEAD\"),\n ])\n .output()\n .expect(\"Failed to execute git command\");\n\n let changelog = String::from_utf8_lossy(&output.stdout);\n\n let prompt = format!(\n \"Summarize the following git commit log into a concise markdown changelog.\\n\n Only include user-facing changes (i.e. no code refactorings or similar).\\n\n Use the tags to group the changes, and if there are no tags use the dates.\\n\n Include the date and the tag in the header.\\n\n Don't sub-categorize the changes, just list them.\\n\n Insert a blank line after each header and sub-header.\\n\n \\n\\n{changelog}\"\n );\n\n let model = Model::Model(Provider::OpenAI, \"gpt-5\".to_string());\n\n exec_tool(&Some(&model), opts, &prompt).await\n}\n\n#[derive(Deserialize)]\npub struct FileAnalysis {\n pub description: String,\n pub timestamp: Option<String>,\n}\n\nconst OCR_PROMPT: &str = \"Extract and return all text from this image. \\\n Just the text and no explanation!\";\n\n/// Extract all text from an image via OCR with a vision model\n/// and return it as a string.\nasync fn ocr_image_to_text(\n file_path: &str,\n) -> Result<String, Box<dyn Error + Send + Sync>> {\n let file_content = std::fs::read(file_path)?;\n let base64_content =\n base64::engine::general_purpose::STANDARD.encode(&file_content);\n let mime_type = get_image_mime_type(file_path);\n\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n\n // OpenAI's vision API doesn't accept HEIC/HEIF, but Google Gemini does\n if mime_type == \"image/heic\" || mime_type == \"image/heif\" {\n eprintln!(\n \"{}\",\n cformat!(\n \"<dim>{file_path}: \\\n Extracting text from image with Google Gemini …</dim>\"\n )\n );\n let model =\n Model::Model(Provider::Google, \"gemini-3-pro-preview\".to_string());\n let (_used_model, http_req) =\n get_http_req(&Some(&model), &secrets_path_str, &full_config)?;\n\n let req_body_obj = json!({\n \"contents\": [{\n \"parts\": [\n { \"text\": OCR_PROMPT },\n {\n \"inlineData\": {\n \"mimeType\": mime_type,\n \"data\": base64_content\n }\n }\n ]\n }],\n \"generationConfig\": {\n \"mediaResolution\": \"media_resolution_high\"\n }\n });\n\n let resp = exec_request(&http_req, &req_body_obj, false).await?;\n\n if resp.status().is_success() {\n let json_val = resp.json::<Value>().await?;\n Ok(\n json_val[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"]\n .as_str()\n .unwrap_or_default()\n .to_string(),\n )\n } else {\n let json_val = resp.json::<Value>().await?;\n let json_str = serde_json::to_string_pretty(&json_val).unwrap();\n Err(json_str.into())\n }\n } else {\n eprintln!(\n \"{}\",\n cformat!(\n \"<dim>{file_path}: Extracting text from image with OpenAI …</dim>\"\n )\n );\n let model = Model::Model(Provider::OpenAI, \"gpt-5-mini\".to_string());\n let (_used_model, http_req) =\n get_http_req(&Some(&model), &secrets_path_str, &full_config)?;\n\n let req_body_obj = json!({\n \"model\": http_req.model,\n \"max_completion_tokens\": http_req.max_tokens,\n \"messages\": [{\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": OCR_PROMPT\n },\n {\n \"type\": \"image_url\",\n \"image_url\": {\n \"url\": format!(\"data:{mime_type};base64,{base64_content}\")\n }\n }\n ]\n }]\n });\n\n let resp = exec_request(&http_req, &req_body_obj, false).await?;\n\n if resp.status().is_success() {\n let ai_response = resp.json::<AiResponse>().await?;\n Ok(ai_response.choices[0].message.content.clone())\n } else {\n let json_val = resp.json::<Value>().await?;\n let json_str = serde_json::to_string_pretty(&json_val).unwrap();\n Err(json_str.into())\n }\n }\n}\n\nfn is_image_file(file_path: &str) -> bool {\n let lower = file_path.to_lowercase();\n [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".heic\", \".heif\"]\n .iter()\n .any(|ext| lower.ends_with(ext))\n}\n\npub async fn analyze_file_content(\n opts: &ExecOptions,\n file_path: &str,\n) -> Result<FileAnalysis, Box<dyn Error + Send + Sync>> {\n let content = if file_path.to_lowercase().ends_with(\".pdf\") {\n eprintln!(\n \"{}\",\n cformat!(\"<dim>{file_path}: Extracting text from PDF …</dim>\")\n );\n pdf_extract::extract_text(file_path)\n .map_err(|e| format!(\"Failed to extract PDF text: {e}\"))?\n } else if is_image_file(file_path) {\n let ocr_text = ocr_image_to_text(file_path).await?;\n if ocr_text.trim().is_empty() {\n // No text in the image -> let callers use their non-text fallback\n return Err(Box::new(std::io::Error::new(\n std::io::ErrorKind::InvalidData,\n \"Image contains no extractable text\",\n )));\n }\n ocr_text\n } else {\n std::fs::read_to_string(file_path)?\n };\n\n let prompt = format!(\n \"Analyze following file content and return a file analysis JSON object:\\n\\\n \\n\\\n {content}\\n\",\n );\n let mut opts = opts.clone();\n\n opts.json_schema = Some(json!({\n \"name\": \"file_analysis\",\n \"strict\": true,\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\":\n \"A short (1-4 words) description that captures its main purpose. \\\n If it's a receipt or an invoice, \\\n start with the name of the company or person that created it. \\\n Do not use overly generic terms like \\\n analysis, summary, transaction, document, etc.\",\n },\n \"timestamp\": {\n \"type\": \"string\",\n \"description\": \"Any timestamp/date found in the content. \\\n If it includes only a date use the `YYYY-MM-DD` format. \\\n If it includes date and time use the `YYYY-MM-DDThh:mmZ` format. \\\n Note that in German dates are usually written as `DD.MM.YYYY`.\",\n }\n },\n \"required\": [ \"description\", \"timestamp\" ],\n \"additionalProperties\": false,\n },\n }));\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let (_used_model, http_req) = get_http_req(\n &Some(&Model::Model(Provider::OpenAI, \"gpt-5-mini\".to_string())),\n &secrets_path_str,\n &full_config,\n )?;\n eprintln!(\n \"{}\",\n cformat!(\"<dim>{file_path}: Generating description and timestamp …</dim>\")\n );\n let req_body_obj = get_req_body_obj(&opts, &http_req, &prompt);\n let resp = exec_request(&http_req, &req_body_obj, false).await?;\n\n if resp.status().is_success() {\n let ai_response = resp.json::<AiResponse>().await?;\n let content = ai_response.choices[0].message.content.clone();\n let analysis: FileAnalysis =\n serde_json::from_str(&content).map_err(|e| {\n format!(\n \"Failed to parse LLM response as JSON\\n\n Response: {content}\\n\n Error: {e}\\n\",\n )\n })?;\n Ok(analysis)\n } else {\n let json_val = resp.json::<Value>().await?;\n let json_str = serde_json::to_string_pretty(&json_val).unwrap();\n Err(json_str.into())\n }\n}\n\npub async fn extract_text_from_file(\n opts: &ExecOptions,\n file_path: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let file_content = std::fs::read(file_path)?;\n let base64_content =\n base64::engine::general_purpose::STANDARD.encode(&file_content);\n let model_id = \"gpt-5\";\n let model = &Model::Model(Provider::OpenAI, model_id.to_string());\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let prompt = json!({\n \"model\": format!(\"{model_id}\"),\n \"max_tokens\": default_req_for_model(model, &full_config).max_tokens,\n \"messages\": [{\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Extract and return all text from this image.\"\n },\n {\n \"type\": \"image_url\",\n \"image_url\": {\n \"url\": format!(\"data:image/jpeg;base64,{base64_content}\")\n }\n }\n ]\n }]\n })\n .to_string();\n\n exec_tool(&Some(model), opts, &prompt).await\n}\n\npub async fn google_ocr_file(\n opts: &ExecOptions,\n file_path: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let file_content = std::fs::read(file_path)?;\n let base64_content =\n base64::engine::general_purpose::STANDARD.encode(&file_content);\n\n let mime_type = get_image_mime_type(file_path);\n\n let model_id = \"gemini-3-pro-preview\";\n let model = &Model::Model(Provider::Google, model_id.to_string());\n\n // Build the request JSON according to the Google Gemini API format\n // The mediaResolution should be specified at the generationConfig level\n let prompt = json!({\n \"contents\": [{\n \"parts\": [\n { \"text\": \"Extract and return all text from this image.\n Just the text and no explanation!\" },\n {\n \"inlineData\": {\n \"mimeType\": mime_type,\n \"data\": base64_content\n }\n }\n ]\n }],\n \"generationConfig\": {\n \"mediaResolution\": \"media_resolution_high\"\n }\n })\n .to_string();\n\n exec_tool(&Some(model), opts, &prompt).await\n}\n\npub async fn transcribe_audio_file(\n opts: &ExecOptions,\n file_path: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let model = &Model::Model(Provider::OpenAI, \"gpt-4o-transcribe\".to_string());\n let (_used_model, http_req) =\n get_http_req(&Some(model), &secrets_path_str, &full_config)?;\n\n let file = std::fs::read(file_path)?;\n let part = reqwest::multipart::Part::bytes(file)\n .file_name(file_path.to_string())\n .mime_str(\"audio/mpeg\")?;\n\n let form = reqwest::multipart::Form::new()\n .text(\"model\", http_req.model.clone())\n .part(\"file\", part);\n\n let client = reqwest::Client::new();\n let base_url =\n get_base_url(&full_config, \"openai_base_url\", \"https://api.openai.com/v1\");\n let transcription_url = format!(\"{base_url}/audio/transcriptions\");\n let resp = client\n .post(&transcription_url)\n .bearer_auth(&http_req.api_key)\n .multipart(form)\n .send()\n .await?;\n\n if resp.status().is_success() {\n let resp_json = resp.json::<Value>().await?;\n let text = format!(\"{}\\n\", resp_json[\"text\"].as_str().unwrap_or_default());\n if opts.is_raw {\n println!(\"{text}\");\n } else {\n highlight::text_via_bat(&text);\n }\n } else {\n let resp_json = resp.json::<Value>().await?;\n let resp_formatted = serde_json::to_string_pretty(&resp_json).unwrap();\n Err(resp_formatted)?;\n }\n\n Ok(())\n}\n\nfn get_image_mime_type(file_path: &str) -> &'static str {\n let lower = file_path.to_lowercase();\n if lower.ends_with(\".png\") {\n \"image/png\"\n } else if lower.ends_with(\".jpg\") || lower.ends_with(\".jpeg\") {\n \"image/jpeg\"\n } else if lower.ends_with(\".webp\") {\n \"image/webp\"\n } else if lower.ends_with(\".gif\") {\n \"image/gif\"\n } else if lower.ends_with(\".heic\") {\n \"image/heic\"\n } else if lower.ends_with(\".heif\") {\n \"image/heif\"\n } else {\n \"image/png\"\n }\n}\n\npub async fn edit_images(\n opts: &ExecOptions,\n image_files: &[String],\n prompt: &str,\n background: Option<&str>,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let start = Instant::now();\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let model_id = if background.is_some() {\n \"gpt-image-1.5\"\n } else {\n \"gpt-image-2\"\n };\n let model = &Model::Model(Provider::OpenAI, model_id.to_string());\n let (used_model, http_req) =\n get_http_req(&Some(model), &secrets_path_str, &full_config)?;\n\n let mut form = reqwest::multipart::Form::new()\n .text(\"model\", http_req.model.clone())\n .text(\"prompt\", prompt.to_string());\n\n if let Some(bg) = background {\n form = form.text(\"background\", bg.to_string());\n }\n\n for file_path in image_files {\n let file_bytes = std::fs::read(file_path)\n .map_err(|e| format!(\"Failed to read image file '{file_path}': {e}\"))?;\n let mime_type = get_image_mime_type(file_path);\n let file_name = std::path::Path::new(file_path)\n .file_name()\n .and_then(|n| n.to_str())\n .unwrap_or(file_path)\n .to_string();\n let part = reqwest::multipart::Part::bytes(file_bytes)\n .file_name(file_name)\n .mime_str(mime_type)?;\n form = form.part(\"image[]\", part);\n }\n\n let client = reqwest::Client::new();\n let base_url =\n get_base_url(&full_config, \"openai_base_url\", \"https://api.openai.com/v1\");\n let url = format!(\"{base_url}/images/edits\");\n\n let resp = client\n .post(&url)\n .bearer_auth(&http_req.api_key)\n .multipart(form)\n .send()\n .await?;\n\n let elapsed_millis = start.elapsed().as_millis();\n let (elapsed_time, time_unit) = format_elapsed_time(elapsed_millis);\n let subcommand = opts\n .subcommand\n .as_ref()\n .and_then(|x| x.to_string_pretty())\n .map(|subcom| format!(\"➡️ {subcom} | \"))\n .unwrap_or_default();\n\n let status = resp.status();\n let response_json = resp.json::<Value>().await?;\n\n if !status.is_success() {\n let resp_formatted = serde_json::to_string_pretty(&response_json).unwrap();\n Err(cformat!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\\\n \\n{resp_formatted}\",\n elapsed_time,\n time_unit,\n ))?;\n }\n\n cprintln!(\n \"<bold>{subcommand}{used_model} | ⏱️ {} {}</bold>\\n\",\n elapsed_time,\n time_unit,\n );\n\n if let Some(data) = response_json[\"data\"].as_array() {\n let mut image_count = 0;\n for image_data in data {\n image_count += 1;\n if let Some(image_base64) = image_data[\"b64_json\"].as_str() {\n use base64::{engine::general_purpose, Engine as _};\n match general_purpose::STANDARD.decode(image_base64) {\n Ok(image_bytes) => {\n let now = Utc::now();\n let timestamp_prefix = now.format(\"%Y-%m-%dt%H%M\").to_string();\n let short_name = prompt_to_short_name(prompt);\n\n let mut counter = 1;\n let mut filename = format!(\"{timestamp_prefix}_{short_name}.png\");\n while std::path::Path::new(&filename).exists() {\n counter += 1;\n filename =\n format!(\"{timestamp_prefix}_{short_name}_{counter}.png\");\n }\n\n match std::fs::write(&filename, image_bytes) {\n Ok(_) => println!(\"Edited image saved to: {filename}\"),\n Err(e) => println!(\"Failed to save image {image_count}: {e}\"),\n }\n }\n Err(e) => {\n println!(\"Failed to decode base64 for image {image_count}: {e}\")\n }\n }\n } else if let Some(url) = image_data[\"url\"].as_str() {\n println!(\"Edited image {}: {}\", image_count, url);\n }\n }\n }\n\n Ok(())\n}\n\npub async fn prompt_with_lang_cntxt(\n opts: &ExecOptions,\n cmd: &Commands,\n prompt: &[String],\n) {\n let prog_lang = cmd.to_string_pretty().unwrap_or_default();\n let system_prompt = format!(\n \"You're a professional {prog_lang} developer.\\n\n Answer the following question in the context of {prog_lang}.\\n\n Keep your answer concise and to the point.\\n\"\n );\n\n let model = shortcut_model(\n cmd,\n Model::Model(Provider::Anthropic, \"claude-haiku-4-5\".to_string()),\n );\n\n if let Err(err) = exec_tool(\n &Some(&model),\n opts,\n &(system_prompt.to_owned() + &prompt.join(\" \")), //\n )\n .await\n {\n eprintln!(\"Error prompting with OCaml context: {err}\");\n std::process::exit(1);\n }\n}\n\n/// Strip surrounding markdown code fences from a generated command.\nfn strip_code_fences(text: &str) -> String {\n let trimmed = text.trim();\n if let Some(after) = trimmed.strip_prefix(\"```\") {\n // Drop an optional language tag on the opening fence's first line.\n let after = after\n .split_once('\\n')\n .map(|(_, rest)| rest)\n .unwrap_or(after);\n let inner = after.trim_end().trim_end_matches(\"```\");\n return inner.trim().to_string();\n }\n trimmed.to_string()\n}\n\nasync fn generate_command(\n http_req: &AiRequest,\n raw_opts: &ExecOptions,\n prompt: &str,\n) -> Result<String, Box<dyn Error + Send + Sync>> {\n let req_body_obj = get_req_body_obj(raw_opts, http_req, prompt);\n let resp = exec_request(http_req, &req_body_obj, false).await?;\n\n if !resp.status().is_success() {\n let resp_json = resp.json::<Value>().await?;\n let resp_formatted = serde_json::to_string_pretty(&resp_json).unwrap();\n return Err(format!(\"Failed to generate command: {resp_formatted}\").into());\n }\n\n let ai_response = resp.json::<AiResponse>().await?;\n let command = strip_code_fences(&ai_response.choices[0].message.content);\n\n if command.is_empty() {\n return Err(\"LLM returned an empty command.\".into());\n }\n\n Ok(command)\n}\n\n/// Run `command` via `bash -c`, streaming its stdout/stderr to the terminal\n/// live while also capturing them so the output can be inspected afterwards.\n/// stdin is inherited so interactive commands keep working.\nfn exec_and_capture(\n command: &str,\n) -> std::io::Result<(std::process::ExitStatus, String, String)> {\n use std::io::Read;\n use std::process::Stdio;\n\n let mut child = std::process::Command::new(\"bash\")\n .arg(\"-c\")\n .arg(command)\n .stdin(Stdio::inherit())\n .stdout(Stdio::piped())\n .stderr(Stdio::piped())\n .spawn()?;\n\n // Tee a child pipe to the given terminal stream while capturing the bytes.\n fn tee<R: Read + Send + 'static, W: Write + Send + 'static>(\n mut reader: R,\n mut writer: W,\n ) -> std::thread::JoinHandle<String> {\n std::thread::spawn(move || {\n let mut captured = Vec::new();\n let mut chunk = [0u8; 4096];\n loop {\n match reader.read(&mut chunk) {\n Ok(0) | Err(_) => break,\n Ok(n) => {\n let _ = writer.write_all(&chunk[..n]);\n let _ = writer.flush();\n captured.extend_from_slice(&chunk[..n]);\n }\n }\n }\n String::from_utf8_lossy(&captured).into_owned()\n })\n }\n\n let child_stdout = child.stdout.take().expect(\"piped stdout\");\n let child_stderr = child.stderr.take().expect(\"piped stderr\");\n let stdout_handle = tee(child_stdout, std::io::stdout());\n let stderr_handle = tee(child_stderr, std::io::stderr());\n\n let status = child.wait()?;\n let captured_stdout = stdout_handle.join().unwrap_or_default();\n let captured_stderr = stderr_handle.join().unwrap_or_default();\n Ok((status, captured_stdout, captured_stderr))\n}\n\n/// Keep only the last `max_chars` characters, prefixing with a marker when\n/// truncated, so a large error log doesn't blow up the follow-up prompt.\nfn tail_chars(text: &str, max_chars: usize) -> String {\n let chars: Vec<char> = text.chars().collect();\n if chars.len() <= max_chars {\n text.to_string()\n } else {\n let tail: String = chars[chars.len() - max_chars..].iter().collect();\n format!(\"[... truncated ...]\\n{tail}\")\n }\n}\n\n/// Resolve the on-disk paths of common shell tools so the model can tell\n/// which implementation it is dealing with (GNU coreutils vs BSD, or the exact\n/// package on Nix systems, where the store path encodes it). Returns a\n/// formatted, newline-separated `tool: /path` list, or an empty string if\n/// detection fails.\nfn detect_tool_locations() -> String {\n const TOOLS: &[&str] = &[\n \"bash\", \"sed\", \"awk\", \"grep\", \"find\", \"stat\", \"date\", \"xargs\", \"sort\",\n \"ls\", \"readlink\", \"head\", \"tail\", \"cut\",\n ];\n // Only report absolute paths: a bare name means a builtin/alias/function,\n // which carries no GNU-vs-BSD signal and would just be noise.\n let script = TOOLS\n .iter()\n .map(|t| {\n format!(\n \"p=$(command -v {t} 2>/dev/null); \\\n case \\\"$p\\\" in /*) echo \\\"{t}: $p\\\";; esac\"\n )\n })\n .collect::<Vec<_>>()\n .join(\"; \");\n\n match std::process::Command::new(\"bash\")\n .arg(\"-c\")\n .arg(&script)\n .output()\n {\n Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),\n Err(_) => String::new(),\n }\n}\n\npub async fn run_shell_command(\n opts: &ExecOptions,\n prompt_text: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n let os = std::env::consts::OS;\n let arch = std::env::consts::ARCH;\n\n let tool_locations = detect_tool_locations();\n let tools_section = if tool_locations.is_empty() {\n String::new()\n } else {\n format!(\n \"\\n\\nResolved paths of common tools on this system, provided ONLY so you \\\n can pick the correct flag syntax — e.g. a `coreutils`/`findutils` (GNU) \\\n path means GNU flags, a macOS `/usr/bin` path or a `*-bsd-*` path means \\\n BSD flags. Do NOT put these absolute paths in the command: always invoke \\\n tools by their bare name (`find`, `sort`, …) and let `PATH` resolve them:\\n\\\n {tool_locations}\"\n )\n };\n\n let rules = format!(\n \"Rules:\\n\\\n - Respond with ONLY the command. No explanations, no comments, \\\n no markdown code fences.\\n\\\n - The command will be executed via `bash -c` on `{os}` ({arch}), \\\n so use bash/POSIX syntax.\\n\\\n - It is fine to span multiple lines if needed for a heredoc \\\n or multi-statement pipeline.{tools_section}\"\n );\n\n let initial_prompt = format!(\n \"You are a shell command generator. \\\n Given the user's request, output a single shell command \\\n (or pipeline) that fulfills it.\\n\\n\\\n {rules}\\n\\n\\\n User request: {prompt_text}\"\n );\n\n let model = Model::Model(Provider::OpenAI, \"gpt-5\".to_string());\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let (_used_model, http_req) =\n get_http_req(&Some(&model), &secrets_path_str, &full_config)?;\n\n let mut raw_opts = opts.clone();\n raw_opts.is_raw = true;\n raw_opts.is_streaming = false;\n\n let mut command =\n generate_command(&http_req, &raw_opts, &initial_prompt).await?;\n\n loop {\n println!();\n bat::PrettyPrinter::new()\n .input_from_bytes(command.as_bytes())\n .language(\"bash\")\n .print()\n .ok();\n println!(\"\\n\");\n print!(\"Execute command? [y]es, [n]o, [c]hange: \");\n std::io::stdout().flush()?;\n\n let mut input = String::new();\n std::io::stdin().read_line(&mut input)?;\n let choice = input.trim().to_lowercase();\n\n match choice.as_str() {\n \"\" | \"y\" | \"yes\" | \"e\" | \"execute\" => {\n let (status, _stdout, stderr) = exec_and_capture(&command)?;\n if status.success() {\n return Ok(());\n }\n\n let code_str = status\n .code()\n .map(|c| c.to_string())\n .unwrap_or_else(|| \"signal\".to_string());\n eprintln!(\"\\nCommand failed (exit {code_str}).\");\n print!(\"Ask AI to fix it from the error output? [y]es, [n]o: \");\n std::io::stdout().flush()?;\n\n let mut fix_input = String::new();\n std::io::stdin().read_line(&mut fix_input)?;\n match fix_input.trim().to_lowercase().as_str() {\n \"\" | \"y\" | \"yes\" => {\n let captured_stderr = if stderr.trim().is_empty() {\n \"(no stderr output)\".to_string()\n } else {\n tail_chars(stderr.trim(), 4000)\n };\n let fix_prompt = format!(\n \"You are a shell command generator. \\\n The user originally requested: {prompt_text}\\n\\n\\\n You generated this command:\\n{command}\\n\\n\\\n It failed with exit code {code_str} \\\n and produced this stderr:\\n{captured_stderr}\\n\\n\\\n Generate a corrected shell command that fixes the error.\\n\\n\\\n {rules}\"\n );\n command =\n generate_command(&http_req, &raw_opts, &fix_prompt).await?;\n }\n _ => {\n if let Some(code) = status.code() {\n std::process::exit(code);\n }\n return Err(\"Command terminated by signal\".into());\n }\n }\n }\n \"c\" | \"change\" => {\n print!(\"Describe the change: \");\n std::io::stdout().flush()?;\n let mut suggestion = String::new();\n std::io::stdin().read_line(&mut suggestion)?;\n let suggestion = suggestion.trim();\n if suggestion.is_empty() {\n eprintln!(\"No change suggestion provided. Keeping previous command.\");\n continue;\n }\n let refine_prompt = format!(\n \"You are a shell command generator. \\\n The user originally requested: {prompt_text}\\n\\n\\\n The previous command you generated was:\\n{command}\\n\\n\\\n The user wants the following change: {suggestion}\\n\\n\\\n Generate an updated shell command.\\n\\n\\\n {rules}\"\n );\n command =\n generate_command(&http_req, &raw_opts, &refine_prompt).await?;\n }\n \"a\" | \"n\" | \"no\" | \"abort\" => {\n println!(\"Aborted.\");\n return Ok(());\n }\n other => {\n eprintln!(\n \"Unrecognized choice '{other}'. \\\n Please answer with 'y', 'n', or 'c'.\"\n );\n }\n }\n }\n}\n\npub async fn create_commits(\n opts: &ExecOptions,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n // Get status of modified files (excluding untracked files)\n let status_output = std::process::Command::new(\"git\")\n .args([\"status\", \"--porcelain\"])\n .output()\n .expect(\"Failed to execute git status\");\n\n let status = String::from_utf8_lossy(&status_output.stdout);\n\n // Filter for modified files only (M, MM, or AM status)\n let modified_files: Vec<String> = status\n .lines()\n .filter(|line| {\n let trimmed = line.trim();\n // Include files that are modified (M), added and modified (AM), or modified in both index and working tree (MM)\n // Exclude untracked files (??)\n trimmed.starts_with(\"M \")\n || trimmed.starts_with(\"MM\")\n || trimmed.starts_with(\"AM\")\n || trimmed.starts_with(\" M\")\n })\n .map(|line| line[3..].to_string()) // Skip the status prefix\n .collect();\n\n if modified_files.is_empty() {\n println!(\"No modified files to commit.\");\n return Ok(());\n }\n\n println!(\"Found {} modified file(s):\\n\", modified_files.len());\n for file in &modified_files {\n println!(\" - {}\", file);\n }\n println!();\n\n // Get the diff for all modified files\n let diff_output = std::process::Command::new(\"git\")\n .args([\"diff\", \"HEAD\"])\n .output()\n .expect(\"Failed to execute git diff\");\n\n let diff = String::from_utf8_lossy(&diff_output.stdout);\n\n // Ask AI to analyze the diff and suggest commit groupings\n let analysis_prompt = format!(\n \"Analyze the following git diff and determine if the changes should be split into multiple commits.\\n\\\n If the changes are related and form a coherent unit, suggest ONE commit.\\n\\\n If there are multiple unrelated changes, suggest how to group the files into separate commits.\\n\\\n \\n\\\n For each commit group, provide:\\n\\\n 1. A list of file paths to include\\n\\\n 2. A concise commit message (50 chars or less for the summary)\\n\\\n 3. Optional: A longer description if needed\\n\\\n \\n\\\n Respond in JSON format:\\n\\\n {{\\n\\\n \\\"commits\\\": [\\n\\\n {{\\n\\\n \\\"files\\\": [\\\"path/to/file1.rs\\\", \\\"path/to/file2.rs\\\"],\\n\\\n \\\"message\\\": \\\"Brief summary of changes\\\",\\n\\\n \\\"description\\\": \\\"Optional longer description\\\"\\n\\\n }}\\n\\\n ]\\n\\\n }}\\n\\\n \\n\\\n Git diff:\\n{diff}\"\n );\n\n let model = Model::Model(Provider::OpenAI, \"gpt-5\".to_string());\n\n // Get AI analysis of commit groupings\n let json_schema = json!({\n \"name\": \"commit_analysis\",\n \"strict\": true,\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"commits\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"files\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"message\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" }\n },\n \"required\": [\"files\", \"message\", \"description\"],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\"commits\"],\n \"additionalProperties\": false\n }\n });\n\n let mut analysis_opts = opts.clone();\n analysis_opts.is_json = true;\n analysis_opts.json_schema = Some(json_schema);\n\n // Capture the JSON response\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let (_used_model, http_req) =\n get_http_req(&Some(&model), &secrets_path_str, &full_config)?;\n let req_body_obj =\n get_req_body_obj(&analysis_opts, &http_req, &analysis_prompt);\n let response = exec_request(&http_req, &req_body_obj, false).await?;\n\n // Parse the response using the standard AiResponse struct\n let ai_response = response\n .json::<AiResponse>()\n .await\n .map_err(|e| format!(\"Failed to decode API response: {}\", e))?;\n\n if ai_response.choices.is_empty() {\n return Err(\"API returned no choices\".into());\n }\n\n let content = &ai_response.choices[0].message.content;\n\n #[derive(Deserialize)]\n struct CommitGroup {\n files: Vec<String>,\n message: String,\n description: Option<String>,\n }\n\n #[derive(Deserialize)]\n struct CommitAnalysis {\n commits: Vec<CommitGroup>,\n }\n\n let analysis: CommitAnalysis =\n serde_json::from_str(content).map_err(|e| {\n format!(\n \"Failed to parse commit analysis: {}. Response: {}\",\n e, content\n )\n })?;\n\n if analysis.commits.is_empty() {\n println!(\"No commits suggested by AI.\");\n return Ok(());\n }\n\n println!(\"AI suggests {} commit(s):\\n\", analysis.commits.len());\n\n // Process each suggested commit\n for (idx, commit_group) in analysis.commits.iter().enumerate() {\n println!(\"─────────────────────────────────────────────\");\n println!(\"Commit {}/{}\", idx + 1, analysis.commits.len());\n println!(\"─────────────────────────────────────────────\");\n println!(\"Files:\");\n for file in &commit_group.files {\n println!(\" - {}\", file);\n }\n println!(\"\\nCommit message:\");\n println!(\" {}\", commit_group.message);\n if let Some(desc) = &commit_group.description {\n if !desc.is_empty() {\n println!(\"\\n {}\", desc);\n }\n }\n println!();\n\n // Prompt user for approval\n print!(\"Proceed with this commit? [Y/n]: \");\n std::io::Write::flush(&mut std::io::stdout())?;\n\n let mut input = String::new();\n std::io::stdin().read_line(&mut input)?;\n let input = input.trim().to_lowercase();\n\n if input == \"n\" || input == \"no\" {\n println!(\"Skipped.\\n\");\n continue;\n }\n\n // Stage the files\n for file in &commit_group.files {\n let add_output = std::process::Command::new(\"git\")\n .args([\"add\", file])\n .output()?;\n\n if !add_output.status.success() {\n eprintln!(\"Warning: Failed to stage {}\", file);\n }\n }\n\n // Create the commit\n let full_message = if let Some(desc) = &commit_group.description {\n if !desc.is_empty() {\n format!(\"{}\\n\\n{}\", commit_group.message, desc)\n } else {\n commit_group.message.clone()\n }\n } else {\n commit_group.message.clone()\n };\n\n let commit_output = std::process::Command::new(\"git\")\n .args([\"commit\", \"-m\", &full_message])\n .output()?;\n\n if commit_output.status.success() {\n println!(\"✓ Commit created successfully.\\n\");\n } else {\n let error = String::from_utf8_lossy(&commit_output.stderr);\n eprintln!(\"✗ Failed to create commit: {}\\n\", error);\n }\n }\n\n println!(\"─────────────────────────────────────────────\");\n println!(\"All commits processed.\");\n\n Ok(())\n}\n\npub async fn query_database(\n opts: &ExecOptions,\n database_path: &str,\n prompt_text: &str,\n) -> Result<(), Box<dyn Error + Send + Sync>> {\n use rusqlite::Connection;\n\n // Open the database\n let conn = Connection::open(database_path).map_err(|e| {\n format!(\"Failed to open database '{}': {}\", database_path, e)\n })?;\n\n // Get the database schema\n let mut schema_parts: Vec<String> = Vec::new();\n\n // Get all table names\n let mut stmt = conn\n .prepare(\"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name\")\n .map_err(|e| format!(\"Failed to query schema: {}\", e))?;\n\n let table_names: Vec<String> = stmt\n .query_map([], |row| row.get(0))\n .map_err(|e| format!(\"Failed to get table names: {}\", e))?\n .filter_map(|r| r.ok())\n .collect();\n\n // Get CREATE TABLE statement for each table\n for table_name in &table_names {\n let sql: String = conn\n .query_row(\n \"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1\",\n [table_name],\n |row| row.get(0),\n )\n .map_err(|e| {\n format!(\"Failed to get schema for table '{}': {}\", table_name, e)\n })?;\n schema_parts.push(sql);\n }\n\n let schema = schema_parts.join(\"\\n\\n\");\n\n // Build the prompt for the LLM to generate SQL\n let sql_prompt = format!(\n \"You are a SQLite expert. Given the following database schema, \\\n generate a SQL query to answer the user's question.\\n\\n\\\n IMPORTANT: Respond with ONLY the SQL query, no explanations, \\\n no markdown code blocks, no comments. Just the raw SQL.\\n\\n\\\n Schema:\\n{schema}\\n\\n\\\n Question: {prompt_text}\"\n );\n\n // Use OpenAI to generate the SQL query\n let model = Model::Model(Provider::OpenAI, \"gpt-5\".to_string());\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let (_used_model, http_req) =\n get_http_req(&Some(&model), &secrets_path_str, &full_config)?;\n\n let mut raw_opts = opts.clone();\n raw_opts.is_raw = true;\n\n let req_body_obj = get_req_body_obj(&raw_opts, &http_req, &sql_prompt);\n let resp = exec_request(&http_req, &req_body_obj, false).await?;\n\n if !resp.status().is_success() {\n let resp_json = resp.json::<Value>().await?;\n let resp_formatted = serde_json::to_string_pretty(&resp_json).unwrap();\n return Err(format!(\"Failed to generate SQL: {}\", resp_formatted).into());\n }\n\n let ai_response = resp.json::<AiResponse>().await?;\n let generated_sql = ai_response.choices[0]\n .message\n .content\n .trim()\n .trim_start_matches(\"```sql\")\n .trim_start_matches(\"```\")\n .trim_end_matches(\"```\")\n .trim()\n .to_string();\n\n if !opts.is_raw {\n cprintln!(\"<bold>Generated SQL:</bold>\");\n println!(\"{}\\n\", generated_sql);\n }\n\n // Execute the generated SQL query\n let mut stmt = conn\n .prepare(&generated_sql)\n .map_err(|e| format!(\"SQL error: {}\", e))?;\n\n let column_count = stmt.column_count();\n let column_names: Vec<String> =\n stmt.column_names().iter().map(|s| s.to_string()).collect();\n\n // Execute and collect results\n let rows_result = stmt.query_map([], |row| {\n let mut row_values: Vec<String> = Vec::new();\n for i in 0..column_count {\n let value: rusqlite::types::Value = row.get(i)?;\n let str_value = match value {\n rusqlite::types::Value::Null => \"NULL\".to_string(),\n rusqlite::types::Value::Integer(i) => i.to_string(),\n rusqlite::types::Value::Real(f) => f.to_string(),\n rusqlite::types::Value::Text(s) => s,\n rusqlite::types::Value::Blob(b) => format!(\"<blob {} bytes>\", b.len()),\n };\n row_values.push(str_value);\n }\n Ok(row_values)\n });\n\n let rows: Vec<Vec<String>> = rows_result\n .map_err(|e| format!(\"Query execution error: {}\", e))?\n .filter_map(|r| r.ok())\n .collect();\n\n if !opts.is_raw {\n cprintln!(\"<bold>Results ({} rows):</bold>\", rows.len());\n }\n\n // Calculate column widths for pretty printing\n let mut col_widths: Vec<usize> =\n column_names.iter().map(|n| n.len()).collect();\n for row in &rows {\n for (i, val) in row.iter().enumerate() {\n if val.len() > col_widths[i] {\n col_widths[i] = val.len();\n }\n }\n }\n\n // Print header\n let header: Vec<String> = column_names\n .iter()\n .enumerate()\n .map(|(i, name)| format!(\"{:width$}\", name, width = col_widths[i]))\n .collect();\n println!(\"{}\", header.join(\" | \"));\n\n // Print separator\n let separator: Vec<String> =\n col_widths.iter().map(|w| \"-\".repeat(*w)).collect();\n println!(\"{}\", separator.join(\"-+-\"));\n\n // Print rows\n for row in &rows {\n let formatted: Vec<String> = row\n .iter()\n .enumerate()\n .map(|(i, val)| format!(\"{:width$}\", val, width = col_widths[i]))\n .collect();\n println!(\"{}\", formatted.join(\" | \"));\n }\n\n if !opts.is_raw {\n println!();\n }\n\n Ok(())\n}\n\n/// How a provider authenticates requests to its `models` listing endpoint.\nenum ModelsAuth {\n Bearer,\n AnthropicKey,\n GoogleQuery,\n None,\n}\n\nasync fn fetch_provider_models(\n client: &reqwest::Client,\n url: &str,\n api_key: Option<String>,\n auth: ModelsAuth,\n) -> Result<Vec<String>, String> {\n let resp = match auth {\n ModelsAuth::Bearer => {\n let key = api_key.ok_or(\"No API key configured\")?;\n client.get(url).bearer_auth(&key).send().await\n }\n ModelsAuth::AnthropicKey => {\n let key = api_key.ok_or(\"No API key configured\")?;\n client\n .get(url)\n .header(\"x-api-key\", &key)\n .header(\"anthropic-version\", \"2023-06-01\")\n .send()\n .await\n }\n ModelsAuth::GoogleQuery => {\n let key = api_key.ok_or(\"No API key configured\")?;\n client.get(format!(\"{url}?key={key}\")).send().await\n }\n ModelsAuth::None => client.get(url).send().await,\n };\n\n let resp = resp.map_err(|e| format!(\"Request failed: {e}\"))?;\n let status = resp.status();\n if !status.is_success() {\n let body = resp.text().await.unwrap_or_default();\n let snippet: String = body.chars().take(200).collect();\n return Err(format!(\"HTTP {status}: {snippet}\"));\n }\n\n let json: Value = resp\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse JSON: {e}\"))?;\n\n // Common response shapes:\n // {\"data\": [{\"id\": \"...\"}]} OpenAI, Anthropic, Groq, Mistral\n // {\"models\": [{\"name\": \"...\"}]} Gemini, Ollama\n if let Some(arr) = json.get(\"data\").and_then(|v| v.as_array()) {\n return Ok(\n arr\n .iter()\n .filter_map(|m| m.get(\"id\").and_then(|i| i.as_str()).map(String::from))\n .collect(),\n );\n }\n if let Some(arr) = json.get(\"models\").and_then(|v| v.as_array()) {\n return Ok(\n arr\n .iter()\n .filter_map(|m| {\n m.get(\"name\")\n .or_else(|| m.get(\"id\"))\n .and_then(|n| n.as_str())\n // Gemini prefixes ids with \"models/\"; strip it for display.\n .map(|s| s.strip_prefix(\"models/\").unwrap_or(s).to_string())\n })\n .collect(),\n );\n }\n\n Err(format!(\"Unexpected response shape: {json}\"))\n}\n\nfn get_models_key(\n cfg: &HashMap<String, String>,\n cfg_key: &str,\n env_key: &str,\n) -> Option<String> {\n cfg\n .get(cfg_key)\n .cloned()\n .or_else(|| env::var(env_key).ok())\n .filter(|s| !s.is_empty())\n}\n\npub async fn list_models() -> Result<(), Box<dyn Error + Send + Sync>> {\n let secrets_path_str = get_secrets_path_str();\n let full_config = get_full_config(&secrets_path_str)?;\n let client = reqwest::Client::new();\n\n let openai_url = format!(\n \"{}/models\",\n get_base_url(&full_config, \"openai_base_url\", \"https://api.openai.com/v1\")\n );\n let anthropic_url = format!(\n \"{}/models\",\n get_base_url(\n &full_config,\n \"anthropic_base_url\",\n \"https://api.anthropic.com/v1\"\n )\n );\n let groq_url = format!(\n \"{}/models\",\n get_base_url(\n &full_config,\n \"groq_base_url\",\n \"https://api.groq.com/openai/v1\"\n )\n );\n let gemini_url = format!(\n \"{}/models\",\n get_base_url(\n &full_config,\n \"google_base_url\",\n \"https://generativelanguage.googleapis.com/v1beta\"\n )\n );\n let cerebras_url = format!(\n \"{}/models\",\n get_base_url(\n &full_config,\n \"cerebras_base_url\",\n \"https://api.cerebras.ai/v1\"\n )\n );\n let deepseek_url = format!(\n \"{}/models\",\n get_base_url(\n &full_config,\n \"deepseek_base_url\",\n \"https://api.deepseek.com\"\n )\n );\n let xai_url = format!(\n \"{}/models\",\n get_base_url(&full_config, \"xai_base_url\", \"https://api.x.ai/v1\")\n );\n // Perplexity's chat completions live at `{base}/chat/completions` with\n // the default base lacking `/v1`, but the models endpoint sits under\n // `/v1/models`, so it's hardcoded here.\n let perplexity_url = \"https://api.perplexity.ai/v1/models\".to_string();\n // Ollama's models endpoint lives under `/api/tags`, not the\n // OpenAI-compatible `/v1` prefix used for chat completions.\n let ollama_base =\n get_base_url(&full_config, \"ollama_base_url\", \"http://localhost:11434/v1\");\n let ollama_host = ollama_base.trim_end_matches(\"/v1\").to_string();\n let ollama_url = format!(\"{ollama_host}/api/tags\");\n\n let providers: Vec<(&'static str, String, Option<String>, ModelsAuth)> = vec![\n (\n \"OpenAI\",\n openai_url,\n get_models_key(&full_config, \"openai_api_key\", \"OPENAI_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n (\n \"Anthropic\",\n anthropic_url,\n get_models_key(&full_config, \"anthropic_api_key\", \"ANTHROPIC_API_KEY\"),\n ModelsAuth::AnthropicKey,\n ),\n (\n \"Google Gemini\",\n gemini_url,\n get_models_key(&full_config, \"google_api_key\", \"GOOGLE_API_KEY\"),\n ModelsAuth::GoogleQuery,\n ),\n (\n \"Groq\",\n groq_url,\n get_models_key(&full_config, \"groq_api_key\", \"GROQ_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n (\n \"Cerebras\",\n cerebras_url,\n get_models_key(&full_config, \"cerebras_api_key\", \"CEREBRAS_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n (\n \"DeepSeek\",\n deepseek_url,\n get_models_key(&full_config, \"deepseek_api_key\", \"DEEPSEEK_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n (\n \"xAI\",\n xai_url,\n get_models_key(&full_config, \"xai_api_key\", \"XAI_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n // Perplexity's models endpoint is public — no key required.\n (\"Perplexity\", perplexity_url, None, ModelsAuth::None),\n (\"Ollama\", ollama_url, None, ModelsAuth::None),\n (\n \"Mistral\",\n \"https://api.mistral.ai/v1/models\".to_string(),\n get_models_key(&full_config, \"mistral_api_key\", \"MISTRAL_API_KEY\"),\n ModelsAuth::Bearer,\n ),\n ];\n\n let mut handles = Vec::new();\n for (name, url, key, auth) in providers {\n let client = client.clone();\n handles.push(tokio::spawn(async move {\n (name, fetch_provider_models(&client, &url, key, auth).await)\n }));\n }\n\n let results = join_all(handles).await;\n\n for handle_result in results {\n let (provider, models_result) = handle_result?;\n cprintln!(\"<bold,underline>{}</bold,underline>\", provider);\n match models_result {\n Ok(mut models) => {\n if models.is_empty() {\n println!(\" (no models returned)\");\n } else {\n models.sort();\n for m in &models {\n println!(\" {m}\");\n }\n }\n }\n Err(e) => cprintln!(\" <red>{}</red>\", e),\n }\n println!();\n }\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_submit_empty_prompt() {\n let prompt = \"\";\n let result = exec_tool(\n &Some(&Model::Model(Provider::OpenAI, \"gpt-4o-mini\".to_owned())),\n &ExecOptions::default(),\n prompt,\n )\n .await;\n assert!(result.is_err());\n }\n\n #[test]\n fn test_o_models_use_max_completion_tokens() {\n let test_cases = vec![\n (\"o3\", true),\n (\"o4-mini\", true),\n (\"gpt-5\", true),\n (\"gpt-5-mini\", true),\n (\"gpt-5-nano\", true),\n (\"gpt-4o\", false),\n (\"gpt-4.1\", false),\n ];\n\n for (model, should_use_max_completion) in test_cases {\n let http_req = AiRequest {\n provider: Provider::OpenAI,\n model: model.to_string(),\n max_tokens: 100,\n ..Default::default()\n };\n\n let opts = ExecOptions::default();\n\n let body = get_req_body_obj(&opts, &http_req, \"test\");\n let has_max_completion = body\n .as_object()\n .unwrap()\n .contains_key(\"max_completion_tokens\");\n let has_max_tokens = body.as_object().unwrap().contains_key(\"max_tokens\");\n\n assert_eq!(\n has_max_completion, should_use_max_completion,\n \"Failed for model {model}\"\n );\n assert_eq!(\n has_max_tokens, !should_use_max_completion,\n \"Failed for model {model}\"\n );\n }\n }\n\n #[test]\n fn test_parse_model_override() {\n assert_eq!(\n parse_model_override(\"anthropic claude-opus-4-8\"),\n Some(Model::Model(\n Provider::Anthropic,\n \"claude-opus-4-8\".to_string()\n ))\n );\n // Case-insensitive provider, surrounding whitespace trimmed\n assert_eq!(\n parse_model_override(\" OpenAI gpt-4.1 \"),\n Some(Model::Model(Provider::OpenAI, \"gpt-4.1\".to_string()))\n );\n // Model id may itself contain a slash\n assert_eq!(\n parse_model_override(\"groq openai/gpt-oss-120b\"),\n Some(Model::Model(\n Provider::Groq,\n \"openai/gpt-oss-120b\".to_string()\n ))\n );\n // Bare provider with no model id (e.g. llamafile)\n assert_eq!(\n parse_model_override(\"llamafile\"),\n Some(Model::Model(Provider::Llamafile, String::new()))\n );\n // Unknown provider is rejected\n assert_eq!(parse_model_override(\"acme some-model\"), None);\n assert_eq!(parse_model_override(\"not-a-provider\"), None);\n }\n\n #[test]\n fn test_flatten_config_value() {\n // A nested `shortcut_models` table is flattened into dotted keys, scalars\n // are stringified, and arrays/nulls are dropped.\n let shortcut_models = config::Value::from(HashMap::from([\n (\"fast\".to_string(), \"groq llama\".to_string()),\n (\"opus\".to_string(), \"openai gpt-4.1\".to_string()),\n ]));\n let mut out = HashMap::new();\n flatten_config_value(\"openai_api_key\", \"secret\".into(), &mut out);\n flatten_config_value(\"shortcut_models\", shortcut_models, &mut out);\n\n assert_eq!(\n out.get(\"openai_api_key\").map(String::as_str),\n Some(\"secret\")\n );\n assert_eq!(\n out.get(\"shortcut_models.fast\").map(String::as_str),\n Some(\"groq llama\")\n );\n assert_eq!(\n out.get(\"shortcut_models.opus\").map(String::as_str),\n Some(\"openai gpt-4.1\")\n );\n }\n\n #[test]\n fn test_config_key_overridable_and_not() {\n // Shortcut with a default model is overridable\n assert_eq!(\n Commands::ClaudeOpus { prompt: vec![] }.config_key(),\n Some(\"opus\")\n );\n assert_eq!(Commands::Py { prompt: vec![] }.config_key(), Some(\"py\"));\n // Commands taking an explicit model arg are not overridable\n assert_eq!(\n Commands::Anthropic {\n model: \"x\".to_string(),\n prompt: vec![]\n }\n .config_key(),\n None\n );\n }\n\n #[test]\n fn test_format_elapsed_time() {\n // Test milliseconds (≤ 10 seconds)\n assert_eq!(format_elapsed_time(0), (\"0\".to_string(), \"ms\"));\n assert_eq!(format_elapsed_time(1), (\"1\".to_string(), \"ms\"));\n assert_eq!(format_elapsed_time(999), (\"999\".to_string(), \"ms\"));\n assert_eq!(format_elapsed_time(5000), (\"5000\".to_string(), \"ms\"));\n assert_eq!(format_elapsed_time(10000), (\"10000\".to_string(), \"ms\"));\n assert_eq!(format_elapsed_time(9999), (\"9999\".to_string(), \"ms\"));\n\n // Test seconds (> 10 seconds)\n assert_eq!(format_elapsed_time(10001), (\"10.0\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(15000), (\"15.0\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(15500), (\"15.5\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(15999), (\"16.0\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(60000), (\"60.0\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(60123), (\"60.1\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(65432), (\"65.4\".to_string(), \"s\"));\n assert_eq!(format_elapsed_time(120000), (\"120.0\".to_string(), \"s\"));\n }\n}\n"}}}} |