agentyk_core/profile.rs
1//! What a model can actually do — and catching a request it can't, early.
2//!
3//! [`ModelSpec`] is deliberately just "which model,
4//! through which driver, with which credentials". It will happily carry a
5//! reasoning effort the model does not offer, or a thinking budget from a
6//! model that has none; the first sign of trouble is then a provider error in
7//! the middle of a turn, which is the most expensive place to learn it.
8//!
9//! A [`ModelCatalog`] is what a host consults instead. It is a **seam, not a
10//! table**: agentyk ships no list of models on purpose. A baked-in catalog is
11//! wrong within weeks of any provider's next release, and being wrong is worse
12//! than being absent — it would reject a model that exists. Hosts already have
13//! this data (a config file, an API call, a hard-coded map they own) and
14//! implement the trait over it.
15//!
16//! What the library provides is the *check*: attach a catalog to an agent and
17//! `build()` validates the model against it, so a bad combination fails at
18//! composition time with a message naming what is supported.
19
20use std::collections::HashMap;
21
22use crate::driver::{DriverId, ModelSpec};
23
24/// What one model supports. Every field is optional knowledge: `None` means
25/// "not stated", never "not supported", so a partial catalog entry can still
26/// be useful.
27#[derive(Debug, Clone, PartialEq, Default)]
28#[non_exhaustive]
29pub struct ModelProfile {
30 /// Total context window in tokens, when known. A host renders budget and
31 /// compaction decisions from this; core never enforces it.
32 pub context_window: Option<u64>,
33 /// Ceiling on tokens the model will generate in one response.
34 pub max_output_tokens: Option<u64>,
35 /// Reasoning-effort values this model accepts, e.g. `["low", "high"]`.
36 /// Empty means the model takes no effort setting at all — which is what
37 /// makes passing one an error rather than a no-op.
38 pub reasoning_efforts: Vec<String>,
39 /// Whether the model supports an extended-thinking token budget.
40 pub thinking: bool,
41 /// Whether the model accepts image content.
42 pub vision: bool,
43}
44
45impl ModelProfile {
46 /// A profile that states nothing. Build one up with the setters.
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// State the context window, in tokens.
52 pub fn context_window(mut self, tokens: u64) -> Self {
53 self.context_window = Some(tokens);
54 self
55 }
56
57 /// State the per-response output ceiling, in tokens.
58 pub fn max_output_tokens(mut self, tokens: u64) -> Self {
59 self.max_output_tokens = Some(tokens);
60 self
61 }
62
63 /// State which reasoning-effort values the model accepts.
64 pub fn reasoning_efforts(
65 mut self,
66 efforts: impl IntoIterator<Item = impl Into<String>>,
67 ) -> Self {
68 self.reasoning_efforts = efforts.into_iter().map(Into::into).collect();
69 self
70 }
71
72 /// State that the model supports an extended-thinking budget.
73 pub fn thinking(mut self, supported: bool) -> Self {
74 self.thinking = supported;
75 self
76 }
77
78 /// State that the model accepts images.
79 pub fn vision(mut self, supported: bool) -> Self {
80 self.vision = supported;
81 self
82 }
83
84 /// Why this model cannot serve `model`, if it cannot.
85 ///
86 /// Checks only what the profile actually states — an empty profile
87 /// accepts everything, so attaching a catalog can never reject a model it
88 /// simply does not describe.
89 pub fn validate(&self, model: &ModelSpec) -> Result<(), String> {
90 let Some(reasoning) = &model.reasoning else {
91 return Ok(());
92 };
93
94 if let Some(effort) = &reasoning.effort {
95 if self.reasoning_efforts.is_empty() {
96 return Err(format!(
97 "model `{}` takes no reasoning effort, but `{effort}` was requested",
98 model.model
99 ));
100 }
101 if !self.reasoning_efforts.iter().any(|known| known == effort) {
102 return Err(format!(
103 "model `{}` does not support reasoning effort `{effort}` (supported: {})",
104 model.model,
105 self.reasoning_efforts.join(", ")
106 ));
107 }
108 }
109
110 if reasoning.budget_tokens.is_some() && !self.thinking {
111 return Err(format!(
112 "model `{}` does not support extended thinking, but a thinking budget was set",
113 model.model
114 ));
115 }
116
117 Ok(())
118 }
119}
120
121/// Where a host's knowledge about models lives.
122///
123/// Implement it over whatever you already have — a config file, a provider's
124/// models endpoint, a map in your own source. Returning `None` means "I don't
125/// know this model", which is never an error: an unknown model is sent to the
126/// provider unchanged.
127pub trait ModelCatalog: Send + Sync {
128 /// What is known about this model on this driver.
129 fn profile(&self, driver: &DriverId, model: &str) -> Option<ModelProfile>;
130}
131
132/// A catalog built from entries you supply — the straightforward
133/// implementation, for hosts whose model knowledge is a literal list.
134#[derive(Debug, Clone, Default)]
135pub struct InMemoryModelCatalog {
136 profiles: HashMap<(String, String), ModelProfile>,
137}
138
139impl InMemoryModelCatalog {
140 /// An empty catalog.
141 pub fn new() -> Self {
142 Self::default()
143 }
144
145 /// Describe one model. Replaces any previous entry for the same pair.
146 ///
147 /// ```
148 /// use agentyk_core::{DriverId, InMemoryModelCatalog, ModelProfile, ModelSpec};
149 ///
150 /// let catalog = InMemoryModelCatalog::new().with(
151 /// DriverId::openai(),
152 /// "gpt-5.5",
153 /// ModelProfile::new()
154 /// .context_window(400_000)
155 /// .reasoning_efforts(["low", "medium", "high"]),
156 /// );
157 ///
158 /// use agentyk_core::ModelCatalog;
159 /// let profile = catalog.profile(&DriverId::openai(), "gpt-5.5").unwrap();
160 /// assert!(profile.validate(&ModelSpec::openai("gpt-5.5").reasoning_effort("high")).is_ok());
161 /// assert!(profile.validate(&ModelSpec::openai("gpt-5.5").reasoning_effort("extreme")).is_err());
162 /// ```
163 pub fn with(
164 mut self,
165 driver: DriverId,
166 model: impl Into<String>,
167 profile: ModelProfile,
168 ) -> Self {
169 self.profiles
170 .insert((driver.as_str().to_string(), model.into()), profile);
171 self
172 }
173}
174
175impl ModelCatalog for InMemoryModelCatalog {
176 fn profile(&self, driver: &DriverId, model: &str) -> Option<ModelProfile> {
177 self.profiles
178 .get(&(driver.as_str().to_string(), model.to_string()))
179 .cloned()
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn gpt() -> ModelProfile {
188 ModelProfile::new()
189 .context_window(400_000)
190 .reasoning_efforts(["low", "high"])
191 }
192
193 #[test]
194 fn an_empty_profile_only_rejects_what_it_contradicts() {
195 let empty = ModelProfile::new();
196 // Nothing requested, nothing to disagree with.
197 assert!(empty.validate(&ModelSpec::openai("mystery")).is_ok());
198 // But "no efforts listed" is a statement, not a shrug: it means the
199 // model takes none.
200 assert!(
201 empty
202 .validate(&ModelSpec::openai("mystery").reasoning_effort("high"))
203 .is_err()
204 );
205 }
206
207 #[test]
208 fn a_supported_effort_passes_and_an_unsupported_one_names_the_alternatives() {
209 assert!(
210 gpt()
211 .validate(&ModelSpec::openai("gpt").reasoning_effort("high"))
212 .is_ok()
213 );
214 let error = gpt()
215 .validate(&ModelSpec::openai("gpt").reasoning_effort("ultra"))
216 .expect_err("unsupported effort");
217 assert!(error.contains("ultra"), "{error}");
218 assert!(error.contains("low, high"), "{error}");
219 }
220
221 #[test]
222 fn a_thinking_budget_needs_a_thinking_model() {
223 let error = gpt()
224 .validate(&ModelSpec::openai("gpt").thinking_budget(4_000))
225 .expect_err("no thinking support");
226 assert!(error.contains("extended thinking"), "{error}");
227
228 let claude = ModelProfile::new().thinking(true);
229 assert!(
230 claude
231 .validate(&ModelSpec::anthropic("claude").thinking_budget(4_000))
232 .is_ok()
233 );
234 }
235
236 #[test]
237 fn a_catalog_is_keyed_by_driver_and_model() {
238 let catalog = InMemoryModelCatalog::new().with(DriverId::openai(), "gpt", gpt());
239 assert!(catalog.profile(&DriverId::openai(), "gpt").is_some());
240 assert!(
241 catalog.profile(&DriverId::anthropic(), "gpt").is_none(),
242 "same name on another protocol is another model"
243 );
244 assert!(catalog.profile(&DriverId::openai(), "unknown").is_none());
245 }
246}