1pub mod completion;
32pub mod config;
33pub mod fake;
34pub mod message;
35pub mod provider;
36pub mod providers;
37pub mod request;
38pub mod structured;
39pub mod tool;
40pub mod transcribe;
41
42pub use completion::{Completion, StopReason, ToolCall, Usage};
43pub use config::{ApiKey, Settings};
44pub use fake::Fake;
45pub use message::{Content, Conversation, Message, Role};
46pub use provider::{BoxFuture, Provider, StreamDelta, TextStream};
47pub use providers::{Anthropic, Ollama, OpenAi};
48pub use request::Request;
49pub use structured::generate_as;
50pub use tool::{DEFAULT_MAX_ROUNDS, Schema, Tool, Toolbox, run_tools};
51pub use transcribe::{Audio, Deepgram, FakeTranscriber, Transcriber, Transcript, Transcription, Whisper};
52
53use rustlavel_client::Client;
54use rustlavel_core::{Config, Error, Json, Result};
55use std::sync::Arc;
56
57#[derive(Clone)]
63pub struct Ai {
64 provider: Arc<dyn Provider>,
65 model: String,
66 fake: Option<Arc<Fake>>,
68}
69
70impl Ai {
71 pub fn provider(provider: impl Provider) -> Ai {
73 Ai::shared(Arc::new(provider))
74 }
75
76 pub fn shared(provider: Arc<dyn Provider>) -> Ai {
78 let model = provider.default_model().to_string();
79 Ai { provider, model, fake: None }
80 }
81
82 pub fn from_config(config: &Config) -> Result<Ai> {
87 Ai::from_settings(Settings::resolve(config))
88 }
89
90 pub fn from_settings(settings: Settings) -> Result<Ai> {
92 settings.require_key()?;
93
94 let client = Client::new()
97 .retries(2)
98 .timeout(std::time::Duration::from_secs(120));
99
100 let provider: Arc<dyn Provider> = match settings.provider.as_str() {
101 "anthropic" | "claude" => Arc::new(
102 Anthropic::new(settings.api_key.clone())
103 .base_url(&settings.base_url)
104 .client(client),
105 ),
106 "openai" => Arc::new(
107 OpenAi::new(settings.api_key.clone()).base_url(&settings.base_url).client(client),
108 ),
109 "ollama" => Arc::new(Ollama::new().base_url(&settings.base_url).client(client)),
110 other => {
111 return Err(Error::msg(format!(
112 "unknown AI provider `{other}`. \
113 Set `ai.provider` to anthropic, openai or ollama."
114 )));
115 }
116 };
117
118 Ok(Ai { provider, model: settings.model, fake: None })
119 }
120
121 pub fn fake() -> Ai {
123 Ai::fake_with(Fake::new())
124 }
125
126 pub fn fake_with(fake: Fake) -> Ai {
128 let fake = Arc::new(fake);
129 Ai {
130 provider: fake.clone(),
131 model: "fake".to_string(),
132 fake: Some(fake),
133 }
134 }
135
136 pub fn faked(&self) -> Option<&Arc<Fake>> {
138 self.fake.as_ref()
139 }
140
141 pub fn name(&self) -> &'static str {
143 self.provider.name()
144 }
145
146 pub fn default_model(&self) -> &str {
148 &self.model
149 }
150
151 pub fn using(mut self, model: impl Into<String>) -> Ai {
153 self.model = model.into();
154 self
155 }
156
157 pub fn ask(&self) -> Generation {
159 Generation {
160 ai: self.clone(),
161 request: Request::new(&self.model),
162 toolbox: None,
163 max_rounds: DEFAULT_MAX_ROUNDS,
164 }
165 }
166
167 pub fn prompt(&self, text: impl Into<String>) -> Generation {
169 self.ask().prompt(text)
170 }
171
172 pub fn model(&self, model: impl Into<String>) -> Generation {
174 self.ask().model(model)
175 }
176
177 pub fn chat(&self, conversation: Conversation) -> Generation {
179 self.ask().conversation(conversation)
180 }
181}
182
183impl std::fmt::Debug for Ai {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.debug_struct("Ai")
186 .field("provider", &self.provider.name())
187 .field("model", &self.model)
188 .finish()
189 }
190}
191
192pub struct Generation {
197 ai: Ai,
198 request: Request,
199 toolbox: Option<Toolbox>,
200 max_rounds: usize,
201}
202
203impl Generation {
204 pub fn model(mut self, model: impl Into<String>) -> Generation {
205 self.request.model = model.into();
206 self
207 }
208
209 pub fn system(mut self, system: impl Into<String>) -> Generation {
210 self.request.system = Some(system.into());
211 self
212 }
213
214 pub fn prompt(mut self, text: impl Into<String>) -> Generation {
216 self.request.messages.push(Message::user(text));
217 self
218 }
219
220 pub fn message(mut self, message: Message) -> Generation {
221 self.request.messages.push(message);
222 self
223 }
224
225 pub fn conversation(mut self, conversation: Conversation) -> Generation {
226 self.request.messages.extend(conversation.into_messages());
227 self
228 }
229
230 pub fn temperature(mut self, temperature: f64) -> Generation {
231 self.request.temperature = Some(temperature);
232 self
233 }
234
235 pub fn max_tokens(mut self, max_tokens: u32) -> Generation {
236 self.request.max_tokens = Some(max_tokens);
237 self
238 }
239
240 pub fn stop(mut self, sequence: impl Into<String>) -> Generation {
241 self.request.stop_sequences.push(sequence.into());
242 self
243 }
244
245 pub fn tool(mut self, tool: Tool) -> Generation {
248 self.request.tools.push(tool);
249 self
250 }
251
252 pub fn tools(mut self, toolbox: Toolbox) -> Generation {
255 self.toolbox = Some(toolbox);
256 self
257 }
258
259 pub fn max_rounds(mut self, max_rounds: usize) -> Generation {
261 self.max_rounds = max_rounds;
262 self
263 }
264
265 pub fn request(&self) -> &Request {
267 &self.request
268 }
269
270 pub async fn generate(self) -> Result<Completion> {
272 match &self.toolbox {
273 Some(toolbox) => {
274 run_tools(self.ai.provider.as_ref(), &self.request, toolbox, self.max_rounds).await
275 }
276 None => self.ai.provider.complete(&self.request).await,
277 }
278 }
279
280 pub async fn text(self) -> Result<String> {
282 Ok(self.generate().await?.text)
283 }
284
285 pub async fn stream(self) -> Result<TextStream> {
290 self.ai.provider.stream(&self.request).await
291 }
292
293 pub async fn generate_as(self, schema: &Schema) -> Result<Json> {
298 structured::generate_as(self.ai.provider.as_ref(), &self.request, schema).await
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use rustlavel_core::events::{self, Event};
306 use std::sync::Mutex;
307
308 #[tokio::test]
309 async fn the_common_case_is_one_line() {
310 let ai = Ai::fake_with(Fake::new().always("The sky scatters blue light."));
311
312 assert_eq!(ai.prompt("Why is the sky blue?").text().await.unwrap(), "The sky scatters blue light.");
313 }
314
315 #[tokio::test]
316 async fn the_builder_carries_every_setting_onto_the_request() {
317 let ai = Ai::fake_with(Fake::new().always("ok"));
318
319 ai.model("claude-fable-5")
320 .system("Be brief.")
321 .prompt("Why is the sky blue?")
322 .temperature(0.2)
323 .max_tokens(1024)
324 .stop("END")
325 .generate()
326 .await
327 .unwrap();
328
329 let sent = ai.faked().unwrap().last_request().unwrap();
330 assert_eq!(sent.model, "claude-fable-5");
331 assert_eq!(sent.system.as_deref(), Some("Be brief."));
332 assert_eq!(sent.temperature, Some(0.2));
333 assert_eq!(sent.max_tokens, Some(1024));
334 assert_eq!(sent.stop_sequences, vec!["END".to_string()]);
335 assert_eq!(sent.last_user_text().as_deref(), Some("Why is the sky blue?"));
336 }
337
338 #[tokio::test]
339 async fn a_conversation_can_be_continued() {
340 let ai = Ai::fake_with(Fake::new().always("Because of Rayleigh scattering."));
341 let conversation = Conversation::new().user("Why is the sky blue?").assistant("Physics.");
342
343 let answer = ai.chat(conversation).prompt("Go on.").text().await.unwrap();
344
345 assert_eq!(answer, "Because of Rayleigh scattering.");
346 assert_eq!(ai.faked().unwrap().last_request().unwrap().messages.len(), 3);
347 }
348
349 #[tokio::test]
350 async fn streams_deltas_the_caller_can_loop_over() {
351 let ai = Ai::fake_with(Fake::new().streams(["Once ", "upon ", "a time."]));
352
353 let mut stream = ai.prompt("Tell me a story").stream().await.unwrap();
354 let mut seen = Vec::new();
355 while let Some(delta) = stream.next().await.unwrap() {
356 seen.push(delta);
357 }
358
359 assert_eq!(seen, vec!["Once ", "upon ", "a time."]);
360 }
361
362 #[tokio::test]
363 async fn runs_the_tool_loop_until_the_model_answers_in_words() {
364 let ai = Ai::fake_with(
365 Fake::new()
366 .calls_tool("get_weather", Json::object([("city", "Oslo".into())]))
367 .reply_with(Completion::text("It is 7 degrees in Oslo.").with_usage(Usage::new(30, 8))),
368 );
369
370 let toolbox = Toolbox::new().add(
371 Tool::new("get_weather", "Look up today's weather").string("city", "The city"),
372 |input: Json| async move {
373 let city = input.get("city").and_then(Json::as_str).unwrap_or("").to_string();
374 Ok(Json::object([("city", Json::from(city)), ("degrees", 7.into())]))
375 },
376 );
377
378 let completion = ai
379 .prompt("What is the weather in Oslo?")
380 .tools(toolbox)
381 .generate()
382 .await
383 .unwrap();
384
385 assert_eq!(completion.text, "It is 7 degrees in Oslo.");
386 assert!(!completion.wants_tools());
387
388 let fake = ai.faked().unwrap();
389 fake.assert_count(2);
390
391 let second = fake.last_request().unwrap();
393 assert_eq!(second.messages.len(), 3);
394 assert_eq!(second.messages[1].role, Role::Assistant);
395 assert_eq!(second.messages[1].tool_uses().count(), 1);
396 assert_eq!(second.messages[2].role, Role::Tool);
397 assert!(second.messages[2].content[0] != Content::Text(String::new()));
398 assert_eq!(second.tools.len(), 1);
400 }
401
402 #[tokio::test]
403 async fn a_failing_tool_is_reported_to_the_model_rather_than_the_caller() {
404 let ai = Ai::fake_with(
405 Fake::new()
406 .calls_tool("get_weather", Json::object([("city", "Atlantis".into())]))
407 .reply("I could not find that city."),
408 );
409
410 let toolbox = Toolbox::new().add(
411 Tool::new("get_weather", "Look up today's weather").string("city", "The city"),
412 |_: Json| async { Err(rustlavel_core::Error::msg("no such city")) },
413 );
414
415 let completion =
416 ai.prompt("Weather in Atlantis?").tools(toolbox).generate().await.unwrap();
417
418 assert_eq!(completion.text, "I could not find that city.");
419 let second = ai.faked().unwrap().last_request().unwrap();
420 assert!(second.messages[2].text().is_empty());
421 match &second.messages[2].content[0] {
422 Content::ToolResult { is_error, output, .. } => {
423 assert!(is_error);
424 assert_eq!(output.as_str(), Some("no such city"));
425 }
426 other => panic!("expected a tool result, got {other:?}"),
427 }
428 }
429
430 #[tokio::test]
431 async fn a_tool_loop_that_will_not_end_is_capped() {
432 let fake = Fake::new();
433 let ai = Ai::fake_with(
434 (0..5).fold(fake, |fake, _| {
435 fake.calls_tool("spin", Json::object::<&str, _>([]))
436 }),
437 );
438
439 let toolbox = Toolbox::new()
440 .add(Tool::new("spin", "Spin forever"), |_: Json| async { Ok(Json::Null) });
441
442 let error = ai
443 .prompt("Spin")
444 .tools(toolbox)
445 .max_rounds(3)
446 .generate()
447 .await
448 .unwrap_err()
449 .to_string();
450
451 assert!(error.contains("3 rounds"), "{error}");
452 ai.faked().unwrap().assert_count(3);
453 }
454
455 #[tokio::test]
456 async fn tool_usage_is_summed_across_the_whole_loop() {
457 let ai = Ai::fake_with(
458 Fake::new()
459 .reply_with(
460 Completion::default()
461 .with_tool_call(ToolCall::new("call_fake", "noop", Json::Null))
462 .with_usage(Usage::new(10, 4)),
463 )
464 .reply_with(Completion::text("done").with_usage(Usage::new(20, 6))),
465 );
466
467 let toolbox =
468 Toolbox::new().add(Tool::new("noop", "Does nothing"), |_: Json| async { Ok(Json::Null) });
469
470 let completion = ai.prompt("Go").tools(toolbox).generate().await.unwrap();
471
472 assert_eq!(completion.usage, Usage::new(30, 10));
473 }
474
475 #[tokio::test]
476 async fn generates_structured_output_through_the_builder() {
477 let ai = Ai::fake_with(Fake::new().always(r#"{"title":"Rust","score":9}"#));
478
479 let value = ai
480 .prompt("Rate this article")
481 .generate_as(&Schema::object().string("title", "Its title").integer("score", "1 to 10"))
482 .await
483 .unwrap();
484
485 assert_eq!(value.get("title").unwrap().as_str(), Some("Rust"));
486 assert_eq!(value.get("score").unwrap().as_i64(), Some(9));
487 }
488
489 #[test]
490 fn the_configured_provider_is_built_from_settings() {
491 let config = Config::new();
492 config.set("ai.provider", "openai");
493 config.set("ai.api_key", "sk-test");
494 config.set("ai.model", "gpt-4o-mini");
495
496 let ai = Ai::from_config(&config).unwrap();
497
498 assert_eq!(ai.name(), "openai");
499 assert_eq!(ai.default_model(), "gpt-4o-mini");
500 assert!(format!("{ai:?}").contains("openai"));
501 }
502
503 #[test]
504 fn an_unknown_provider_says_which_ones_exist() {
505 let config = Config::new();
506 config.set("ai.provider", "skynet");
507
508 let error = Ai::from_config(&config).unwrap_err().to_string();
509
510 assert!(error.contains("skynet"), "{error}");
511 assert!(error.contains("anthropic, openai or ollama"), "{error}");
512 }
513
514 #[test]
515 fn a_missing_key_is_caught_at_boot_and_never_printed() {
516 let config = Config::new();
517 config.set("ai.provider", "anthropic");
518 config.set("ai.api_key", "");
519
520 let error = Ai::from_config(&config).unwrap_err().to_string();
521
522 assert!(error.contains("ANTHROPIC_API_KEY"), "{error}");
523 }
524
525 #[test]
526 fn ollama_needs_no_key_and_defaults_to_a_local_model() {
527 let config = Config::new();
528 config.set("ai.provider", "ollama");
529
530 let ai = Ai::from_config(&config).unwrap();
531
532 assert_eq!(ai.name(), "ollama");
533 assert_eq!(ai.default_model(), "llama3.2");
534 }
535
536 #[tokio::test]
537 async fn a_provider_can_be_handed_over_directly_and_its_model_overridden() {
538 let ai = Ai::provider(Fake::new().always("ok")).using("claude-haiku-4-5-20251001");
539
540 assert_eq!(ai.default_model(), "claude-haiku-4-5-20251001");
541 assert_eq!(ai.ask().prompt("Hi").request().model, "claude-haiku-4-5-20251001");
542 assert!(ai.faked().is_none());
543 assert_eq!(ai.prompt("Hi").text().await.unwrap(), "ok");
544 }
545
546 const EVENT_TEST_MODEL: &str = "claude-sonnet-5-event-test";
549
550 #[tokio::test]
560 async fn a_call_is_reported_without_the_prompt_or_the_key() {
561 use rustlavel_client::fake::{Fake as HttpFake, FakeResponse};
562
563 let seen: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
564 let sink = Arc::clone(&seen);
565
566 events::clear_subscribers();
567 events::subscribe(move |event: &Event| {
568 let mine = event.field("model").and_then(Json::as_str) == Some(EVENT_TEST_MODEL);
569 if event.kind == "ai.call" && mine {
570 sink.lock().unwrap().push(event.clone());
571 }
572 });
573
574 let answer = Json::parse(
575 &format!(
576 r#"{{"model":"{EVENT_TEST_MODEL}","content":[{{"type":"text","text":"Blue."}}],
577 "stop_reason":"end_turn","usage":{{"input_tokens":14,"output_tokens":9}}}}"#
578 ),
579 )
580 .unwrap();
581
582 let provider = Anthropic::new("sk-ant-super-secret")
583 .client(Client::new().faking(HttpFake::new().fallback(FakeResponse::json(answer))));
584
585 Ai::provider(provider)
586 .prompt("A very secret prompt about the sky")
587 .generate()
588 .await
589 .unwrap();
590
591 let events = seen.lock().unwrap().clone();
592 events::clear_subscribers();
593
594 assert_eq!(events.len(), 1, "expected exactly one ai.call event");
595 let event = &events[0];
596 assert_eq!(event.field("provider").and_then(Json::as_str), Some("anthropic"));
597 assert_eq!(event.field("model").and_then(Json::as_str), Some(EVENT_TEST_MODEL));
598 assert_eq!(event.field("input_tokens").and_then(Json::as_i64), Some(14));
599 assert_eq!(event.field("output_tokens").and_then(Json::as_i64), Some(9));
600 assert_eq!(event.field("streamed").and_then(Json::as_bool), Some(false));
601 assert!(event.duration_ms().is_some());
602
603 let rendered = format!("{event:?}");
605 assert!(!rendered.contains("sk-ant-super-secret"), "{rendered}");
606 assert!(!rendered.contains("secret prompt"), "{rendered}");
607 }
608}