Skip to main content

af_agent/
model.rs

1//! The chat-model abstraction the agent loop runs against.
2//!
3//! Decoupling the loop from a concrete client means: (a) the loop is unit-
4//! testable with a scripted mock — no network; (b) any backend (the real
5//! [`LlmClient`], a local model, a replay harness) plugs in by implementing one
6//! method.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::sync::Arc;
10
11use af_llm::{CompletionRequest, CompletionResponse, LlmClient, LlmError};
12use async_trait::async_trait;
13use tokio::sync::mpsc::UnboundedSender;
14
15/// Registered model and the request options it supports.
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct ModelDescriptor {
18    /// Stable identifier of this record.
19    pub id: String,
20    /// Name of the provider that produced or serves this record.
21    pub provider: String,
22    /// Reasoning efforts the provider accepts (`low`, `medium`, `high`).
23    #[serde(default)]
24    pub reasoning_efforts: BTreeSet<String>,
25    /// Input modalities the model accepts (for example `text`, `image`).
26    #[serde(default)]
27    pub input_modalities: BTreeSet<String>,
28}
29
30/// Model registration or resolution failure.
31#[derive(Debug, thiserror::Error, PartialEq, Eq)]
32pub enum ModelRegistryError {
33    /// Model descriptor requires id and provider.
34    #[error("model descriptor requires id and provider")]
35    InvalidDescriptor,
36    /// Model is already registered.
37    #[error("model is already registered: {0}")]
38    Duplicate(String),
39    /// Model is not registered.
40    #[error("model is not registered: {0}")]
41    Unknown(String),
42    /// Preferred model is not allowed.
43    #[error("preferred model is not allowed: {0}")]
44    NotAllowed(String),
45}
46
47/// Exact-id registry of models a Profile may select.
48#[derive(Default)]
49pub struct ModelRegistry {
50    models: BTreeMap<String, (ModelDescriptor, Arc<dyn ChatModel>)>,
51}
52
53impl ModelRegistry {
54    /// Register a model; duplicate ids are rejected.
55    pub fn register(
56        &mut self,
57        descriptor: ModelDescriptor,
58        model: Arc<dyn ChatModel>,
59    ) -> Result<(), ModelRegistryError> {
60        if descriptor.id.trim().is_empty() || descriptor.provider.trim().is_empty() {
61            return Err(ModelRegistryError::InvalidDescriptor);
62        }
63        if self.models.contains_key(&descriptor.id) {
64            return Err(ModelRegistryError::Duplicate(descriptor.id));
65        }
66        self.models
67            .insert(descriptor.id.clone(), (descriptor, model));
68        Ok(())
69    }
70
71    /// Registered descriptors, sorted by id.
72    pub fn descriptors(&self) -> impl Iterator<Item = &ModelDescriptor> {
73        self.models.values().map(|(descriptor, _)| descriptor)
74    }
75
76    /// Resolve an exact model id within `allowed`, validating the requested reasoning effort.
77    pub fn resolve(
78        &self,
79        preferred: &str,
80        allowed: &BTreeSet<String>,
81    ) -> Result<Arc<dyn ChatModel>, ModelRegistryError> {
82        if !allowed.contains(preferred) {
83            return Err(ModelRegistryError::NotAllowed(preferred.into()));
84        }
85        self.models
86            .get(preferred)
87            .map(|(descriptor, model)| {
88                Arc::new(RegisteredModel {
89                    model: Arc::clone(model),
90                    images: descriptor.input_modalities.contains("image"),
91                }) as Arc<dyn ChatModel>
92            })
93            .ok_or_else(|| ModelRegistryError::Unknown(preferred.into()))
94    }
95}
96
97struct RegisteredModel {
98    model: Arc<dyn ChatModel>,
99    images: bool,
100}
101
102#[async_trait]
103impl ChatModel for RegisteredModel {
104    async fn complete_streaming(
105        &self,
106        request: &CompletionRequest,
107        delta_tx: UnboundedSender<(String, bool)>,
108    ) -> Result<CompletionResponse, LlmError> {
109        if !self.images
110            && request
111                .messages
112                .iter()
113                .any(|message| !message.images.is_empty())
114        {
115            return Err(LlmError::InvalidInput(
116                "selected model does not support images".into(),
117            ));
118        }
119        self.model.complete_streaming(request, delta_tx).await
120    }
121}
122
123/// Anything that can turn a chat-completion request into a response.
124#[async_trait]
125pub trait ChatModel: Send + Sync {
126    /// Stream cumulative `(content, has_tool_calls)` updates and return the
127    /// canonical terminal response. This is the only model invocation path.
128    async fn complete_streaming(
129        &self,
130        request: &CompletionRequest,
131        delta_tx: UnboundedSender<(String, bool)>,
132    ) -> Result<CompletionResponse, LlmError>;
133}
134
135/// The production model is the real LLM client.
136#[async_trait]
137impl ChatModel for LlmClient {
138    async fn complete_streaming(
139        &self,
140        request: &CompletionRequest,
141        delta_tx: UnboundedSender<(String, bool)>,
142    ) -> Result<CompletionResponse, LlmError> {
143        self.complete_stream_single_attempt(request, |content, has_tools| {
144            let _ = delta_tx.send((content.to_string(), has_tools));
145        })
146        .await
147    }
148}
149
150/// A type-erased model, so callers (e.g. an HTTP service holding one `Agent` in
151/// shared state) can pick the backend at runtime — real client vs stub vs
152/// replay — without the loop being generic over every concrete type.
153#[async_trait]
154impl ChatModel for Arc<dyn ChatModel> {
155    async fn complete_streaming(
156        &self,
157        request: &CompletionRequest,
158        delta_tx: UnboundedSender<(String, bool)>,
159    ) -> Result<CompletionResponse, LlmError> {
160        (**self).complete_streaming(request, delta_tx).await
161    }
162}
163
164#[cfg(test)]
165mod registry_tests {
166    use super::*;
167    use af_llm::{ChatMessage, Choice, CompletionResponse, LlmError};
168
169    struct Fixed;
170
171    #[async_trait]
172    impl ChatModel for Fixed {
173        async fn complete_streaming(
174            &self,
175            _: &CompletionRequest,
176            _: UnboundedSender<(String, bool)>,
177        ) -> Result<CompletionResponse, LlmError> {
178            Ok(CompletionResponse {
179                id: "fixed".into(),
180                choices: vec![Choice {
181                    index: 0,
182                    message: ChatMessage::assistant("ok"),
183                    output_blocks: Vec::new(),
184                    finish_reason: None,
185                }],
186                usage: None,
187            })
188        }
189    }
190
191    fn descriptor(id: &str) -> ModelDescriptor {
192        ModelDescriptor {
193            id: id.into(),
194            provider: "test".into(),
195            reasoning_efforts: BTreeSet::from(["low".into()]),
196            input_modalities: BTreeSet::from(["text".into()]),
197        }
198    }
199
200    #[tokio::test]
201    async fn registered_modality_is_enforced_before_invocation() {
202        let mut registry = ModelRegistry::default();
203        registry
204            .register(descriptor("text"), Arc::new(Fixed))
205            .unwrap();
206        let mut vision = descriptor("vision");
207        vision.input_modalities.insert("image".into());
208        registry.register(vision, Arc::new(Fixed)).unwrap();
209        let mut message = ChatMessage::user("describe");
210        message.images.push(af_llm::InputImage {
211            asset_id: "asset".parse().unwrap(),
212            media_type: "image/png".into(),
213        });
214        for (id, success) in [("text", false), ("vision", true)] {
215            let model = registry.resolve(id, &BTreeSet::from([id.into()])).unwrap();
216            let (tx, _) = tokio::sync::mpsc::unbounded_channel();
217            let result = model
218                .complete_streaming(&CompletionRequest::new(id, vec![message.clone()]), tx)
219                .await;
220            assert_eq!(result.is_ok(), success);
221        }
222    }
223
224    #[test]
225    fn registry_is_exact_sorted_and_rejects_duplicates() {
226        let mut registry = ModelRegistry::default();
227        registry.register(descriptor("z"), Arc::new(Fixed)).unwrap();
228        registry.register(descriptor("a"), Arc::new(Fixed)).unwrap();
229        assert_eq!(
230            registry
231                .descriptors()
232                .map(|value| value.id.as_str())
233                .collect::<Vec<_>>(),
234            vec!["a", "z"]
235        );
236        assert_eq!(
237            registry
238                .register(descriptor("a"), Arc::new(Fixed))
239                .unwrap_err(),
240            ModelRegistryError::Duplicate("a".into())
241        );
242        assert!(registry.resolve("a", &BTreeSet::from(["a".into()])).is_ok());
243        assert!(matches!(
244            registry.resolve("z", &BTreeSet::from(["a".into()])),
245            Err(ModelRegistryError::NotAllowed(value)) if value == "z"
246        ));
247    }
248}