Skip to main content

fluers_core/
model.rs

1//! Model and provider abstraction.
2//!
3//! Mirrors `Model` / `ImageContent` from `pi-ai`: a pluggable interface that
4//! any concrete provider (OpenAI, Anthropic, local GGUF via mistralrs, …)
5//! implements. The agent loop talks only to [`ModelProvider`].
6
7use std::collections::BTreeMap;
8use std::pin::Pin;
9
10use async_trait::async_trait;
11use futures::stream::Stream;
12use serde::{Deserialize, Serialize};
13
14use crate::error::Result;
15use crate::message::AgentMessage;
16use crate::thinking::ThinkingLevel;
17
18/// A model identifier in `provider/model` form, e.g. `anthropic/claude-sonnet-4-6`.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Model {
21    /// Full id string, e.g. `anthropic/claude-sonnet-4-6`.
22    pub id: String,
23}
24
25impl Model {
26    /// Create a model id from a string.
27    #[must_use]
28    pub fn new(id: impl Into<String>) -> Self {
29        Self { id: id.into() }
30    }
31
32    /// The provider prefix, e.g. `anthropic`.
33    #[must_use]
34    pub fn provider(&self) -> &str {
35        self.id.split('/').next().unwrap_or("")
36    }
37}
38
39/// A request to a model.
40#[derive(Debug, Clone)]
41pub struct ModelRequest {
42    /// The model to call.
43    pub model: Model,
44    /// The conversation so far.
45    pub messages: Vec<AgentMessage>,
46    /// Tools the model may call.
47    pub tools: Vec<crate::tool::ToolDefinition>,
48    /// Reasoning effort.
49    pub thinking: ThinkingLevel,
50    /// Provider-specific overrides (temperature, max_tokens, …).
51    pub params: BTreeMap<String, serde_json::Value>,
52}
53
54/// One event in a streamed model response.
55#[derive(Debug, Clone)]
56pub enum StreamEvent {
57    /// A chunk of assistant text.
58    TextDelta(String),
59    /// The model issued a tool call (complete; providers accumulate deltas).
60    ToolCall(crate::tool::ToolCall),
61    /// A reasoning/thinking chunk.
62    ThinkingDelta(String),
63    /// The turn finished.
64    Done,
65}
66
67/// A boxed, sendable stream of [`StreamEvent`]s.
68pub type StreamEventStream = Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>;
69
70/// The final, non-streamed response from a model turn.
71///
72/// `messages` is the single source of truth; a streamed turn reassembles the
73/// same shape from its deltas.
74#[derive(Debug, Clone)]
75pub struct ModelResponse {
76    /// Assistant messages produced this turn.
77    pub messages: Vec<AgentMessage>,
78}
79
80impl ModelResponse {
81    /// An empty response.
82    #[must_use]
83    pub fn empty() -> Self {
84        Self {
85            messages: Vec::new(),
86        }
87    }
88}
89
90/// The provider abstraction. Implement this to add a backend.
91///
92/// Flue routes every model interaction through `pi-ai`'s `Model` interface;
93/// `fluers-core` does the same through this trait.
94#[async_trait]
95pub trait ModelProvider: Send + Sync {
96    /// Run a turn, returning the full response.
97    async fn invoke(&self, request: ModelRequest) -> Result<ModelResponse>;
98
99    /// Stream a turn as events. The default buffers [`invoke`]; providers
100    /// with native streaming override this to emit deltas as they arrive.
101    fn stream(&self, request: ModelRequest) -> StreamEventStream {
102        // Default: run `invoke` on a blocking task and replay a single `Done`.
103        // Providers override to emit real `TextDelta`/`ToolCall`/`ThinkingDelta`.
104        Box::pin(futures::stream::once(async move {
105            // NOTE: this default cannot await `invoke` without `&self` being
106            // `'static`; concrete providers override. Kept as a marker impl
107            // so the trait object compiles. The static-dispatch agent loop
108            // calls `invoke` directly when streaming is not requested.
109            let _ = request;
110            Ok(StreamEvent::Done)
111        }))
112    }
113}