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 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#[async_trait]
130pub trait ChatModel: Send + Sync {
131 fn provider_name(&self) -> Option<&str> {
133 None
134 }
135 async fn complete_streaming(
138 &self,
139 request: &CompletionRequest,
140 delta_tx: UnboundedSender<(String, bool)>,
141 ) -> Result<CompletionResponse, LlmError>;
142}
143
144#[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#[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}