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(|(_, model)| Arc::clone(model))
88            .ok_or_else(|| ModelRegistryError::Unknown(preferred.into()))
89    }
90}
91
92/// Anything that can turn a chat-completion request into a response.
93#[async_trait]
94pub trait ChatModel: Send + Sync {
95    /// Stream cumulative `(content, has_tool_calls)` updates and return the
96    /// canonical terminal response. This is the only model invocation path.
97    async fn complete_streaming(
98        &self,
99        request: &CompletionRequest,
100        delta_tx: UnboundedSender<(String, bool)>,
101    ) -> Result<CompletionResponse, LlmError>;
102}
103
104/// The production model is the real LLM client.
105#[async_trait]
106impl ChatModel for LlmClient {
107    async fn complete_streaming(
108        &self,
109        request: &CompletionRequest,
110        delta_tx: UnboundedSender<(String, bool)>,
111    ) -> Result<CompletionResponse, LlmError> {
112        self.complete_stream_single_attempt(request, |content, has_tools| {
113            let _ = delta_tx.send((content.to_string(), has_tools));
114        })
115        .await
116    }
117}
118
119/// A type-erased model, so callers (e.g. an HTTP service holding one `Agent` in
120/// shared state) can pick the backend at runtime — real client vs stub vs
121/// replay — without the loop being generic over every concrete type.
122#[async_trait]
123impl ChatModel for Arc<dyn ChatModel> {
124    async fn complete_streaming(
125        &self,
126        request: &CompletionRequest,
127        delta_tx: UnboundedSender<(String, bool)>,
128    ) -> Result<CompletionResponse, LlmError> {
129        (**self).complete_streaming(request, delta_tx).await
130    }
131}
132
133#[cfg(test)]
134mod registry_tests {
135    use super::*;
136    use af_llm::{ChatMessage, Choice, CompletionResponse, LlmError};
137
138    struct Fixed;
139
140    #[async_trait]
141    impl ChatModel for Fixed {
142        async fn complete_streaming(
143            &self,
144            _: &CompletionRequest,
145            _: UnboundedSender<(String, bool)>,
146        ) -> Result<CompletionResponse, LlmError> {
147            Ok(CompletionResponse {
148                id: "fixed".into(),
149                choices: vec![Choice {
150                    index: 0,
151                    message: ChatMessage::assistant("ok"),
152                    output_blocks: Vec::new(),
153                    finish_reason: None,
154                }],
155                usage: None,
156            })
157        }
158    }
159
160    fn descriptor(id: &str) -> ModelDescriptor {
161        ModelDescriptor {
162            id: id.into(),
163            provider: "test".into(),
164            reasoning_efforts: BTreeSet::from(["low".into()]),
165            input_modalities: BTreeSet::from(["text".into()]),
166        }
167    }
168
169    #[test]
170    fn registry_is_exact_sorted_and_rejects_duplicates() {
171        let mut registry = ModelRegistry::default();
172        registry.register(descriptor("z"), Arc::new(Fixed)).unwrap();
173        registry.register(descriptor("a"), Arc::new(Fixed)).unwrap();
174        assert_eq!(
175            registry
176                .descriptors()
177                .map(|value| value.id.as_str())
178                .collect::<Vec<_>>(),
179            vec!["a", "z"]
180        );
181        assert_eq!(
182            registry
183                .register(descriptor("a"), Arc::new(Fixed))
184                .unwrap_err(),
185            ModelRegistryError::Duplicate("a".into())
186        );
187        assert!(registry.resolve("a", &BTreeSet::from(["a".into()])).is_ok());
188        assert!(matches!(
189            registry.resolve("z", &BTreeSet::from(["a".into()])),
190            Err(ModelRegistryError::NotAllowed(value)) if value == "z"
191        ));
192    }
193}