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 }) 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#[async_trait]
125pub trait ChatModel: Send + Sync {
126 async fn complete_streaming(
129 &self,
130 request: &CompletionRequest,
131 delta_tx: UnboundedSender<(String, bool)>,
132 ) -> Result<CompletionResponse, LlmError>;
133}
134
135#[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#[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}