Skip to main content

rustlavel_ai/
lib.rs

1//! rustlavel-ai: one API for talking to language models.
2//!
3//! Laravel 13 shipped an AI SDK as a first-party package; this is the same idea
4//! in Rust. Anthropic, OpenAI and Ollama sit behind one [`Provider`] trait, so
5//! the difference between them is a line of configuration rather than a
6//! rewrite, and [`Ai::fake()`] means an application's tests need neither a key
7//! nor a network.
8//!
9//! The common case is one line:
10//!
11//! ```ignore
12//! let answer = Ai::from_config(&config)?.prompt("Summarise this article").text().await?;
13//! ```
14//!
15//! and so is streaming it:
16//!
17//! ```ignore
18//! let mut stream = ai.prompt("Tell me a story").stream().await?;
19//! while let Some(delta) = stream.next().await? {
20//!     print!("{delta}");
21//! }
22//! ```
23//!
24//! Tools, structured output and multi-turn conversations are the same builder
25//! with one more call on it. Speech is [`transcribe`]: the same shape over
26//! Whisper (OpenAI's, or a faster-whisper server of your own) and Deepgram,
27//! answering with every word and the moment it was said. Every call reports an `ai.call` event — provider,
28//! model, token counts, duration — so Telescope can show what the models cost
29//! without ever seeing a prompt or a key.
30
31pub 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/// The front door.
58///
59/// Holds a provider and the model to use with it. Cloning is cheap — the
60/// provider is shared — so an `Ai` can live in the application context and be
61/// handed to every handler.
62#[derive(Clone)]
63pub struct Ai {
64    provider: Arc<dyn Provider>,
65    model: String,
66    /// Kept separately so a test can reach the script it wrote.
67    fake: Option<Arc<Fake>>,
68}
69
70impl Ai {
71    /// Use a provider directly.
72    pub fn provider(provider: impl Provider) -> Ai {
73        Ai::shared(Arc::new(provider))
74    }
75
76    /// Use a provider that is already shared.
77    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    /// Build the provider named in the configuration.
83    ///
84    /// This is what an application calls at boot; see [`Settings`] for where
85    /// each value comes from.
86    pub fn from_config(config: &Config) -> Result<Ai> {
87        Ai::from_settings(Settings::resolve(config))
88    }
89
90    /// Build a provider from settings resolved elsewhere.
91    pub fn from_settings(settings: Settings) -> Result<Ai> {
92        settings.require_key()?;
93
94        // One client per provider, with retries: a model API is a remote
95        // service on someone else's bad day.
96        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    /// A provider that answers from a script, for tests.
122    pub fn fake() -> Ai {
123        Ai::fake_with(Fake::new())
124    }
125
126    /// A scripted provider, written out in advance.
127    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    /// The script behind [`Ai::fake`], to assert against after the fact.
137    pub fn faked(&self) -> Option<&Arc<Fake>> {
138        self.fake.as_ref()
139    }
140
141    /// The provider's name, as it appears in `ai.call` events.
142    pub fn name(&self) -> &'static str {
143        self.provider.name()
144    }
145
146    /// The model every call will use unless it says otherwise.
147    pub fn default_model(&self) -> &str {
148        &self.model
149    }
150
151    /// Change the model for every call made through this handle.
152    pub fn using(mut self, model: impl Into<String>) -> Ai {
153        self.model = model.into();
154        self
155    }
156
157    /// Start a call.
158    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    /// Start a call with the user's message already in it — the common case.
168    pub fn prompt(&self, text: impl Into<String>) -> Generation {
169        self.ask().prompt(text)
170    }
171
172    /// Start a call with a model other than the default.
173    pub fn model(&self, model: impl Into<String>) -> Generation {
174        self.ask().model(model)
175    }
176
177    /// Start a call from an existing conversation.
178    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
192/// One call being assembled.
193///
194/// Every setting is optional; `ai.prompt("…").generate().await` is a complete
195/// program.
196pub 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    /// What to ask. Sugar for a single user message.
215    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    /// Offer the model a tool it may ask for but that this crate will not run —
246    /// for a caller who wants to handle the tool call themselves.
247    pub fn tool(mut self, tool: Tool) -> Generation {
248        self.request.tools.push(tool);
249        self
250    }
251
252    /// Offer tools *and* run them: [`Generation::generate`] then loops until the
253    /// model answers in words.
254    pub fn tools(mut self, toolbox: Toolbox) -> Generation {
255        self.toolbox = Some(toolbox);
256        self
257    }
258
259    /// How many model turns the tool loop may take.
260    pub fn max_rounds(mut self, max_rounds: usize) -> Generation {
261        self.max_rounds = max_rounds;
262        self
263    }
264
265    /// The request as it will be sent, for tests and for inspection.
266    pub fn request(&self) -> &Request {
267        &self.request
268    }
269
270    /// Ask, and wait for the whole answer.
271    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    /// Ask, and keep only the words — the shortest useful call there is.
281    pub async fn text(self) -> Result<String> {
282        Ok(self.generate().await?.text)
283    }
284
285    /// Ask, and read the answer as it is written.
286    ///
287    /// Tools are not run while streaming: a tool call is not text, and a caller
288    /// who wants both should use [`Generation::generate`].
289    pub async fn stream(self) -> Result<TextStream> {
290        self.ai.provider.stream(&self.request).await
291    }
292
293    /// Ask for JSON matching a schema, and get it parsed.
294    ///
295    /// Retries once if the model's answer will not parse; see
296    /// [`structured::generate_as`].
297    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        // The second call replayed the assistant's request and the tool's answer.
392        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        // The tools were described to the model without the caller repeating them.
399        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    /// A model name no other test answers with, so this test's events can be
547    /// told from the concurrent suite's.
548    const EVENT_TEST_MODEL: &str = "claude-sonnet-5-event-test";
549
550    /// The event bus is process-global. Being the only test that *subscribes*
551    /// is not enough — every other test in this file makes a call too, and the
552    /// suite runs concurrently, so their `ai.call` events land in this one's
553    /// collection and the count comes out wrong on whichever runs interleave.
554    ///
555    /// So the fixture answers with a model name no other test uses, and the
556    /// subscriber keeps only that. Serialising with a mutex would work as
557    /// well, but it would have to be taken by all ten of the tests that make a
558    /// call, and the one somebody adds next year would not take it.
559    #[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        // Neither the prompt nor the key is anywhere in the event.
604        let rendered = format!("{event:?}");
605        assert!(!rendered.contains("sk-ant-super-secret"), "{rendered}");
606        assert!(!rendered.contains("secret prompt"), "{rendered}");
607    }
608}