1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct ModelDescriptor {
18 pub id: String,
20 pub provider: String,
22 #[serde(default)]
24 pub reasoning_efforts: BTreeSet<String>,
25 #[serde(default)]
27 pub input_modalities: BTreeSet<String>,
28}
29
30#[derive(Debug, thiserror::Error, PartialEq, Eq)]
32pub enum ModelRegistryError {
33 #[error("model descriptor requires id and provider")]
35 InvalidDescriptor,
36 #[error("model is already registered: {0}")]
38 Duplicate(String),
39 #[error("model is not registered: {0}")]
41 Unknown(String),
42 #[error("preferred model is not allowed: {0}")]
44 NotAllowed(String),
45}
46
47#[derive(Default)]
49pub struct ModelRegistry {
50 models: BTreeMap<String, (ModelDescriptor, Arc<dyn ChatModel>)>,
51}
52
53impl ModelRegistry {
54 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 pub fn descriptors(&self) -> impl Iterator<Item = &ModelDescriptor> {
73 self.models.values().map(|(descriptor, _)| descriptor)
74 }
75
76 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 fn capabilities(&self) -> ProviderCapabilities {
110 self.model.capabilities()
111 }
112 async fn complete_streaming(
113 &self,
114 request: &CompletionRequest,
115 delta_tx: UnboundedSender<(String, bool)>,
116 ) -> Result<CompletionResponse, LlmError> {
117 if !self.images
118 && request
119 .messages
120 .iter()
121 .any(|message| !message.images.is_empty())
122 {
123 return Err(LlmError::InvalidInput(
124 "selected model does not support images".into(),
125 ));
126 }
127 self.model.complete_streaming(request, delta_tx).await
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
135pub struct ProviderCapabilities {
136 pub deferred_tools: bool,
138 pub direct_tool_activation: bool,
140 pub tool_result_condensation: bool,
142 pub structured_checkpoint: bool,
144 pub cross_run_tool_placeholders: bool,
146}
147
148impl ProviderCapabilities {
149 pub const fn all() -> Self {
151 Self {
152 deferred_tools: true,
153 direct_tool_activation: true,
154 tool_result_condensation: true,
155 structured_checkpoint: true,
156 cross_run_tool_placeholders: true,
157 }
158 }
159
160 pub const fn none() -> Self {
162 Self {
163 deferred_tools: false,
164 direct_tool_activation: false,
165 tool_result_condensation: false,
166 structured_checkpoint: false,
167 cross_run_tool_placeholders: false,
168 }
169 }
170}
171
172impl Default for ProviderCapabilities {
173 fn default() -> Self {
174 Self::all()
175 }
176}
177
178#[async_trait]
180pub trait ChatModel: Send + Sync {
181 fn provider_name(&self) -> Option<&str> {
183 None
184 }
185 fn capabilities(&self) -> ProviderCapabilities {
187 ProviderCapabilities::default()
188 }
189 async fn complete_streaming(
192 &self,
193 request: &CompletionRequest,
194 delta_tx: UnboundedSender<(String, bool)>,
195 ) -> Result<CompletionResponse, LlmError>;
196}
197
198#[async_trait]
200impl ChatModel for LlmClient {
201 async fn complete_streaming(
202 &self,
203 request: &CompletionRequest,
204 delta_tx: UnboundedSender<(String, bool)>,
205 ) -> Result<CompletionResponse, LlmError> {
206 self.complete_stream_single_attempt(request, |content, has_tools| {
207 let _ = delta_tx.send((content.to_string(), has_tools));
208 })
209 .await
210 }
211}
212
213#[async_trait]
217impl ChatModel for Arc<dyn ChatModel> {
218 fn provider_name(&self) -> Option<&str> {
219 (**self).provider_name()
220 }
221 fn capabilities(&self) -> ProviderCapabilities {
222 (**self).capabilities()
223 }
224 async fn complete_streaming(
225 &self,
226 request: &CompletionRequest,
227 delta_tx: UnboundedSender<(String, bool)>,
228 ) -> Result<CompletionResponse, LlmError> {
229 (**self).complete_streaming(request, delta_tx).await
230 }
231}
232
233#[cfg(test)]
234mod registry_tests {
235 use super::*;
236 use af_llm::{ChatMessage, Choice, CompletionResponse, LlmError};
237
238 struct Fixed;
239
240 #[async_trait]
241 impl ChatModel for Fixed {
242 async fn complete_streaming(
243 &self,
244 _: &CompletionRequest,
245 _: UnboundedSender<(String, bool)>,
246 ) -> Result<CompletionResponse, LlmError> {
247 Ok(CompletionResponse {
248 id: "fixed".into(),
249 choices: vec![Choice {
250 index: 0,
251 message: ChatMessage::assistant("ok"),
252 output_blocks: Vec::new(),
253 finish_reason: None,
254 }],
255 usage: None,
256 })
257 }
258 }
259
260 fn descriptor(id: &str) -> ModelDescriptor {
261 ModelDescriptor {
262 id: id.into(),
263 provider: "test".into(),
264 reasoning_efforts: BTreeSet::from(["low".into()]),
265 input_modalities: BTreeSet::from(["text".into()]),
266 }
267 }
268
269 #[tokio::test]
270 async fn registered_modality_is_enforced_before_invocation() {
271 let mut registry = ModelRegistry::default();
272 registry
273 .register(descriptor("text"), Arc::new(Fixed))
274 .unwrap();
275 let mut vision = descriptor("vision");
276 vision.input_modalities.insert("image".into());
277 registry.register(vision, Arc::new(Fixed)).unwrap();
278 let mut message = ChatMessage::user("describe");
279 message.images.push(af_llm::InputImage {
280 asset_id: "asset".parse().unwrap(),
281 media_type: "image/png".into(),
282 });
283 for (id, success) in [("text", false), ("vision", true)] {
284 let model = registry.resolve(id, &BTreeSet::from([id.into()])).unwrap();
285 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
286 let result = model
287 .complete_streaming(&CompletionRequest::new(id, vec![message.clone()]), tx)
288 .await;
289 assert_eq!(result.is_ok(), success);
290 }
291 }
292
293 #[test]
294 fn registry_is_exact_sorted_and_rejects_duplicates() {
295 let mut registry = ModelRegistry::default();
296 registry.register(descriptor("z"), Arc::new(Fixed)).unwrap();
297 registry.register(descriptor("a"), Arc::new(Fixed)).unwrap();
298 assert_eq!(
299 registry
300 .descriptors()
301 .map(|value| value.id.as_str())
302 .collect::<Vec<_>>(),
303 vec!["a", "z"]
304 );
305 assert_eq!(
306 registry
307 .register(descriptor("a"), Arc::new(Fixed))
308 .unwrap_err(),
309 ModelRegistryError::Duplicate("a".into())
310 );
311 assert!(registry.resolve("a", &BTreeSet::from(["a".into()])).is_ok());
312 assert!(matches!(
313 registry.resolve("z", &BTreeSet::from(["a".into()])),
314 Err(ModelRegistryError::NotAllowed(value)) if value == "z"
315 ));
316 }
317}