aether_cli/generate_command/
mod.rs1use crate::output::OutputFormat;
2use futures::StreamExt;
3use llm::parser::ModelProviderParser;
4use llm::types::IsoString;
5use llm::{
6 ChatMessage, ContentBlock, Context, LlmError, LlmResponse, ModelSettings, ReasoningEffort, StreamingModelProvider,
7};
8use std::io::{Read, stdin};
9use std::process::ExitCode;
10use thiserror::Error;
11
12#[derive(clap::Args)]
13pub struct GenerateArgs {
14 #[arg(long)]
16 pub model: String,
17
18 #[arg(long, conflicts_with = "prompt_file", required_unless_present = "prompt_file")]
20 pub prompt: Option<String>,
21
22 #[arg(long, value_name = "PATH_OR_DASH", conflicts_with = "prompt", required_unless_present = "prompt")]
24 pub prompt_file: Option<String>,
25
26 #[arg(long)]
28 pub system: Option<String>,
29
30 #[command(flatten)]
32 pub model_settings: ModelSettingsArgs,
33
34 #[arg(long)]
37 pub reasoning_effort: Option<ReasoningEffort>,
38
39 #[arg(long, default_value = "text")]
42 pub output: OutputFormat,
43}
44
45#[derive(Debug, Clone, Default, clap::Args)]
46pub struct ModelSettingsArgs {
47 #[arg(long)]
49 pub temperature: Option<f32>,
50 #[arg(long)]
52 pub top_p: Option<f32>,
53 #[arg(long)]
55 pub max_tokens: Option<u32>,
56}
57
58impl From<ModelSettingsArgs> for ModelSettings {
59 fn from(args: ModelSettingsArgs) -> Self {
60 ModelSettings { temperature: args.temperature, top_p: args.top_p, max_tokens: args.max_tokens }
61 }
62}
63
64#[derive(Debug, Error)]
65pub enum GenerateCommandError {
66 #[error("provide exactly one of --prompt or --prompt-file")]
67 PromptSource,
68
69 #[error("failed to read prompt from {path}: {source}")]
70 ReadPrompt { path: String, source: std::io::Error },
71
72 #[error("failed to initialize model `{model}`: {source}")]
73 Model { model: String, source: LlmError },
74
75 #[error("model stream error: {0}")]
76 Stream(LlmError),
77}
78
79pub async fn run(args: GenerateArgs) -> Result<ExitCode, GenerateCommandError> {
82 let prompt = resolve_prompt(args.prompt.as_deref(), args.prompt_file.as_deref())?;
83 let (provider, _) = ModelProviderParser::default()
84 .parse(&args.model)
85 .await
86 .map_err(|source| GenerateCommandError::Model { model: args.model.clone(), source })?;
87
88 let messages = {
89 let mut messages = Vec::new();
90 if let Some(system) = args.system.as_deref() {
91 messages.push(ChatMessage::System { content: system.to_string(), timestamp: IsoString::now() });
92 }
93 messages.push(ChatMessage::User { content: vec![ContentBlock::text(&prompt)], timestamp: IsoString::now() });
94 messages
95 };
96
97 let mut context = Context::new(messages, vec![]);
98 context.set_model_settings(args.model_settings.clone().into());
99 context.set_reasoning_effort(args.reasoning_effort);
100
101 let mut stream = provider.stream_response(&context);
102 let mut text = String::new();
103 while let Some(result) = stream.next().await {
104 match result {
105 Ok(LlmResponse::Text { chunk }) => text.push_str(&chunk),
106 Err(error) => return Err(GenerateCommandError::Stream(error)),
107 _ => {}
108 }
109 }
110 println!("{}", format_output(&text, &args.model, args.output));
111 Ok(ExitCode::SUCCESS)
112}
113
114fn format_output(text: &str, model: &str, format: OutputFormat) -> String {
115 match format {
116 OutputFormat::Text => text.to_string(),
117 OutputFormat::Json => serde_json::json!({ "text": text, "model": model }).to_string(),
118 OutputFormat::Pretty => serde_json::to_string_pretty(&serde_json::json!({ "text": text, "model": model }))
119 .expect("generate response serializes to JSON"),
120 }
121}
122
123fn resolve_prompt(prompt: Option<&str>, prompt_file: Option<&str>) -> Result<String, GenerateCommandError> {
124 match (prompt, prompt_file) {
125 (Some(prompt), None) => Ok(prompt.to_string()),
126 (None, Some(prompt_file)) => read_prompt_file(prompt_file),
127 _ => Err(GenerateCommandError::PromptSource),
128 }
129}
130
131fn read_prompt_file(source: &str) -> Result<String, GenerateCommandError> {
132 if source == "-" {
133 let mut prompt = String::new();
134 stdin()
135 .read_to_string(&mut prompt)
136 .map_err(|error| GenerateCommandError::ReadPrompt { path: "-".to_string(), source: error })?;
137 return Ok(prompt);
138 }
139 std::fs::read_to_string(source)
140 .map_err(|error| GenerateCommandError::ReadPrompt { path: source.to_string(), source: error })
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use clap::Parser;
147
148 #[test]
149 fn format_output_wraps_json_and_passes_text_through() {
150 assert_eq!(format_output("hi", "anthropic:m", OutputFormat::Text), "hi");
151
152 let json: serde_json::Value =
153 serde_json::from_str(&format_output("hi", "anthropic:m", OutputFormat::Json)).unwrap();
154 assert_eq!(json["text"], "hi");
155 assert_eq!(json["model"], "anthropic:m");
156 }
157
158 #[tokio::test]
159 async fn run_errors_on_unknown_provider() {
160 let dir = tempfile::tempdir().unwrap();
161 let prompt_path = dir.path().join("prompt.txt");
162 std::fs::write(&prompt_path, "the prompt").unwrap();
163 let args = GenerateArgs {
164 model: "definitely-not-a-provider:nope".to_string(),
165 prompt: None,
166 prompt_file: Some(prompt_path.to_string_lossy().into_owned()),
167 system: None,
168 model_settings: ModelSettingsArgs::default(),
169 reasoning_effort: None,
170 output: OutputFormat::Json,
171 };
172
173 let error = run(args).await.unwrap_err();
174
175 assert!(matches!(error, GenerateCommandError::Model { .. }), "got: {error:?}");
176 }
177
178 #[derive(clap::Parser)]
179 struct TestCli {
180 #[command(flatten)]
181 args: GenerateArgs,
182 }
183
184 fn parse_args(argv: &[&str]) -> GenerateArgs {
185 TestCli::try_parse_from(argv).unwrap().args
186 }
187
188 #[test]
189 fn model_settings_and_reasoning_flags_parse_convert_and_validate() {
190 let set = parse_args(&[
191 "gen",
192 "--model",
193 "anthropic:m",
194 "--prompt",
195 "hi",
196 "--temperature",
197 "0",
198 "--top-p",
199 "0.5",
200 "--max-tokens",
201 "64",
202 "--reasoning-effort",
203 "high",
204 ]);
205 assert_eq!(
206 ModelSettings::from(set.model_settings),
207 ModelSettings { temperature: Some(0.0), top_p: Some(0.5), max_tokens: Some(64) }
208 );
209 assert_eq!(set.reasoning_effort, Some(ReasoningEffort::High));
210
211 let absent = parse_args(&["gen", "--model", "anthropic:m", "--prompt", "hi"]);
212 assert!(ModelSettings::from(absent.model_settings).is_empty());
213 assert_eq!(absent.reasoning_effort, None);
214
215 let bad =
216 TestCli::try_parse_from(["gen", "--model", "anthropic:m", "--prompt", "hi", "--reasoning-effort", "nope"]);
217 assert!(bad.is_err());
218 }
219
220 #[test]
221 fn resolve_prompt_uses_inline_prompt() {
222 assert_eq!(resolve_prompt(Some("say hi"), None).unwrap(), "say hi");
223 }
224
225 #[test]
226 fn resolve_prompt_reads_a_file() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join("prompt.txt");
229 std::fs::write(&path, "graded prompt").unwrap();
230
231 assert_eq!(resolve_prompt(None, Some(&path.to_string_lossy())).unwrap(), "graded prompt");
232 }
233
234 #[test]
235 fn resolve_prompt_reports_missing_file() {
236 let error = resolve_prompt(None, Some("/nonexistent/prompt.txt")).unwrap_err();
237
238 assert!(matches!(error, GenerateCommandError::ReadPrompt { .. }), "got: {error:?}");
239 }
240
241 #[test]
242 fn resolve_prompt_rejects_missing_prompt_source() {
243 let error = resolve_prompt(None, None).unwrap_err();
244
245 assert!(matches!(error, GenerateCommandError::PromptSource), "got: {error:?}");
246 }
247
248 #[test]
249 fn resolve_prompt_rejects_multiple_prompt_sources() {
250 let error = resolve_prompt(Some("inline"), Some("prompt.txt")).unwrap_err();
251
252 assert!(matches!(error, GenerateCommandError::PromptSource), "got: {error:?}");
253 }
254}