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                    provider: descriptor.provider.clone(),
92                }) as Arc<dyn ChatModel>
93            })
94            .ok_or_else(|| ModelRegistryError::Unknown(preferred.into()))
95    }
96}
97
98struct RegisteredModel {
99    model: Arc<dyn ChatModel>,
100    images: bool,
101    provider: String,
102}
103
104#[async_trait]
105impl ChatModel for RegisteredModel {
106    fn provider_name(&self) -> Option<&str> {
107        Some(&self.provider)
108    }
109    async fn complete_streaming(
110        &self,
111        request: &CompletionRequest,
112        delta_tx: UnboundedSender<(String, bool)>,
113    ) -> Result<CompletionResponse, LlmError> {
114        if !self.images
115            && request
116                .messages
117                .iter()
118                .any(|message| !message.images.is_empty())
119        {
120            return Err(LlmError::InvalidInput(
121                "selected model does not support images".into(),
122            ));
123        }
124        self.model.complete_streaming(request, delta_tx).await
125    }
126}
127
128/// Anything that can turn a chat-completion request into a response.
129#[async_trait]
130pub trait ChatModel: Send + Sync {
131    /// Stable provider attribution; custom adapters may leave this unknown.
132    fn provider_name(&self) -> Option<&str> {
133        None
134    }
135    /// Stream cumulative `(content, has_tool_calls)` updates and return the
136    /// canonical terminal response. This is the only model invocation path.
137    async fn complete_streaming(
138        &self,
139        request: &CompletionRequest,
140        delta_tx: UnboundedSender<(String, bool)>,
141    ) -> Result<CompletionResponse, LlmError>;
142}
143
144/// The production model is the real LLM client.
145#[async_trait]
146impl ChatModel for LlmClient {
147    async fn complete_streaming(
148        &self,
149        request: &CompletionRequest,
150        delta_tx: UnboundedSender<(String, bool)>,
151    ) -> Result<CompletionResponse, LlmError> {
152        self.complete_stream_single_attempt(request, |content, has_tools| {
153            let _ = delta_tx.send((content.to_string(), has_tools));
154        })
155        .await
156    }
157}
158
159/// A type-erased model, so callers (e.g. an HTTP service holding one `Agent` in
160/// shared state) can pick the backend at runtime — real client vs stub vs
161/// replay — without the loop being generic over every concrete type.
162#[async_trait]
163impl ChatModel for Arc<dyn ChatModel> {
164    fn provider_name(&self) -> Option<&str> {
165        (**self).provider_name()
166    }
167    async fn complete_streaming(
168        &self,
169        request: &CompletionRequest,
170        delta_tx: UnboundedSender<(String, bool)>,
171    ) -> Result<CompletionResponse, LlmError> {
172        (**self).complete_streaming(request, delta_tx).await
173    }
174}
175
176#[cfg(test)]
177mod registry_tests {
178    use super::*;
179    use af_llm::{ChatMessage, Choice, CompletionResponse, LlmError};
180
181    struct Fixed;
182
183    #[async_trait]
184    impl ChatModel for Fixed {
185        async fn complete_streaming(
186            &self,
187            _: &CompletionRequest,
188            _: UnboundedSender<(String, bool)>,
189        ) -> Result<CompletionResponse, LlmError> {
190            Ok(CompletionResponse {
191                id: "fixed".into(),
192                choices: vec![Choice {
193                    index: 0,
194                    message: ChatMessage::assistant("ok"),
195                    output_blocks: Vec::new(),
196                    finish_reason: None,
197                }],
198                usage: None,
199            })
200        }
201    }
202
203    fn descriptor(id: &str) -> ModelDescriptor {
204        ModelDescriptor {
205            id: id.into(),
206            provider: "test".into(),
207            reasoning_efforts: BTreeSet::from(["low".into()]),
208            input_modalities: BTreeSet::from(["text".into()]),
209        }
210    }
211
212    #[tokio::test]
213    async fn registered_modality_is_enforced_before_invocation() {
214        let mut registry = ModelRegistry::default();
215        registry
216            .register(descriptor("text"), Arc::new(Fixed))
217            .unwrap();
218        let mut vision = descriptor("vision");
219        vision.input_modalities.insert("image".into());
220        registry.register(vision, Arc::new(Fixed)).unwrap();
221        let mut message = ChatMessage::user("describe");
222        message.images.push(af_llm::InputImage {
223            asset_id: "asset".parse().unwrap(),
224            media_type: "image/png".into(),
225        });
226        for (id, success) in [("text", false), ("vision", true)] {
227            let model = registry.resolve(id, &BTreeSet::from([id.into()])).unwrap();
228            let (tx, _) = tokio::sync::mpsc::unbounded_channel();
229            let result = model
230                .complete_streaming(&CompletionRequest::new(id, vec![message.clone()]), tx)
231                .await;
232            assert_eq!(result.is_ok(), success);
233        }
234    }
235
236    #[test]
237    fn registry_is_exact_sorted_and_rejects_duplicates() {
238        let mut registry = ModelRegistry::default();
239        registry.register(descriptor("z"), Arc::new(Fixed)).unwrap();
240        registry.register(descriptor("a"), Arc::new(Fixed)).unwrap();
241        assert_eq!(
242            registry
243                .descriptors()
244                .map(|value| value.id.as_str())
245                .collect::<Vec<_>>(),
246            vec!["a", "z"]
247        );
248        assert_eq!(
249            registry
250                .register(descriptor("a"), Arc::new(Fixed))
251                .unwrap_err(),
252            ModelRegistryError::Duplicate("a".into())
253        );
254        assert!(registry.resolve("a", &BTreeSet::from(["a".into()])).is_ok());
255        assert!(matches!(
256            registry.resolve("z", &BTreeSet::from(["a".into()])),
257            Err(ModelRegistryError::NotAllowed(value)) if value == "z"
258        ));
259    }
260}