Skip to main content

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}