Skip to main content

tea_model/
spec.rs

1use std::fmt;
2use std::str::FromStr;
3
4use tea_protocol::{ModelId, ModelRef, ProviderId, ReasoningEffort, TokenCount};
5use thiserror::Error;
6
7use crate::HostedToolKind;
8
9const MAX_DISPLAY_NAME_BYTES: usize = 256;
10
11/// Human-readable bounded model name.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct ModelDisplayName(String);
14
15impl ModelDisplayName {
16    /// Returns the model display name.
17    #[must_use]
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23impl FromStr for ModelDisplayName {
24    type Err = ModelTextParseError;
25
26    fn from_str(value: &str) -> Result<Self, Self::Err> {
27        if value.is_empty()
28            || value.len() > MAX_DISPLAY_NAME_BYTES
29            || value.chars().any(char::is_control)
30        {
31            return Err(ModelTextParseError::InvalidDisplayName);
32        }
33        Ok(Self(value.to_owned()))
34    }
35}
36
37impl fmt::Display for ModelDisplayName {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        formatter.write_str(&self.0)
40    }
41}
42
43/// Error returned when parsing bounded model text values.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
45pub enum ModelTextParseError {
46    /// Display name is empty, oversized, or contains control characters.
47    #[error("model display name is invalid")]
48    InvalidDisplayName,
49}
50
51const CAP_IMAGE_INPUT: u16 = 1 << 0;
52const CAP_REASONING: u16 = 1 << 1;
53const CAP_TOOLS: u16 = 1 << 2;
54const CAP_PARALLEL_TOOLS: u16 = 1 << 3;
55const CAP_USAGE: u16 = 1 << 4;
56const CAP_HOSTED_WEB_SEARCH: u16 = 1 << 5;
57
58/// Provider-neutral capabilities advertised by one model.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct ModelCapabilities(u16);
61
62impl ModelCapabilities {
63    /// Creates the minimum supported capability set: text input only.
64    #[must_use]
65    pub const fn text() -> Self {
66        Self(0)
67    }
68
69    /// Enables image input.
70    #[must_use]
71    pub const fn with_image_input(mut self) -> Self {
72        self.0 |= CAP_IMAGE_INPUT;
73        self
74    }
75
76    /// Enables reasoning output and options.
77    #[must_use]
78    pub const fn with_reasoning(mut self) -> Self {
79        self.0 |= CAP_REASONING;
80        self
81    }
82
83    /// Enables tool calls and optionally parallel tool calls.
84    #[must_use]
85    pub const fn with_tools(mut self, parallel: bool) -> Self {
86        self.0 |= CAP_TOOLS;
87        if parallel {
88            self.0 |= CAP_PARALLEL_TOOLS;
89        } else {
90            self.0 &= !CAP_PARALLEL_TOOLS;
91        }
92        self
93    }
94
95    /// Enables normalized usage reporting.
96    #[must_use]
97    pub const fn with_usage_reporting(mut self) -> Self {
98        self.0 |= CAP_USAGE;
99        self
100    }
101
102    /// Enables one provider-hosted tool kind for this model and endpoint.
103    #[must_use]
104    pub const fn with_hosted_tool(mut self, kind: HostedToolKind) -> Self {
105        match kind {
106            HostedToolKind::WebSearch => self.0 |= CAP_HOSTED_WEB_SEARCH,
107        }
108        self
109    }
110
111    /// Returns whether text input is accepted.
112    #[must_use]
113    pub const fn accepts_text(self) -> bool {
114        true
115    }
116
117    /// Returns whether image input is accepted.
118    #[must_use]
119    pub const fn accepts_images(self) -> bool {
120        self.0 & CAP_IMAGE_INPUT != 0
121    }
122
123    /// Returns whether reasoning is supported.
124    #[must_use]
125    pub const fn supports_reasoning(self) -> bool {
126        self.0 & CAP_REASONING != 0
127    }
128
129    /// Returns whether tool calls are supported.
130    #[must_use]
131    pub const fn supports_tools(self) -> bool {
132        self.0 & CAP_TOOLS != 0
133    }
134
135    /// Returns whether several tool calls may be requested in one response.
136    #[must_use]
137    pub const fn supports_parallel_tool_calls(self) -> bool {
138        self.0 & CAP_PARALLEL_TOOLS != 0
139    }
140
141    /// Returns whether usage is reported.
142    #[must_use]
143    pub const fn reports_usage(self) -> bool {
144        self.0 & CAP_USAGE != 0
145    }
146
147    /// Returns whether the model/endpoint supports one provider-hosted tool.
148    #[must_use]
149    pub const fn supports_hosted_tool(self, kind: HostedToolKind) -> bool {
150        match kind {
151            HostedToolKind::WebSearch => self.0 & CAP_HOSTED_WEB_SEARCH != 0,
152        }
153    }
154}
155
156impl Default for ModelCapabilities {
157    fn default() -> Self {
158        Self::text()
159    }
160}
161
162/// Validated provider-neutral reasoning levels supported by one model.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ReasoningProfile {
165    default_effort: ReasoningEffort,
166    supported_efforts: Vec<ReasoningEffort>,
167}
168
169impl ReasoningProfile {
170    /// Creates a profile with a supported default and unique effort levels.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error when no levels are supplied, a level is duplicated,
175    /// or the default level is not supported.
176    pub fn new(
177        default_effort: ReasoningEffort,
178        supported_efforts: impl IntoIterator<Item = ReasoningEffort>,
179    ) -> Result<Self, ModelSpecError> {
180        let mut supported_efforts = supported_efforts.into_iter().collect::<Vec<_>>();
181        if supported_efforts.is_empty() {
182            return Err(ModelSpecError::EmptyReasoningEfforts);
183        }
184        supported_efforts.sort_unstable();
185        if supported_efforts
186            .windows(2)
187            .any(|levels| levels[0] == levels[1])
188        {
189            return Err(ModelSpecError::DuplicateReasoningEffort);
190        }
191        if !supported_efforts.contains(&default_effort) {
192            return Err(ModelSpecError::ReasoningDefaultUnsupported);
193        }
194        Ok(Self {
195            default_effort,
196            supported_efforts,
197        })
198    }
199
200    pub(crate) fn compatible_default() -> Self {
201        Self {
202            default_effort: ReasoningEffort::Medium,
203            supported_efforts: ReasoningEffort::SHORTCUT_LEVELS.to_vec(),
204        }
205    }
206
207    /// Returns the model default when a session has no explicit selection.
208    #[must_use]
209    pub const fn default_effort(&self) -> ReasoningEffort {
210        self.default_effort
211    }
212
213    /// Returns supported levels in canonical ascending order.
214    #[must_use]
215    pub fn supported_efforts(&self) -> &[ReasoningEffort] {
216        &self.supported_efforts
217    }
218
219    /// Resolves one requested level using Pi's upward-first clamp rule.
220    #[must_use]
221    pub fn resolve(&self, requested: ReasoningEffort) -> ReasoningResolution {
222        let effective = if self.supported_efforts.contains(&requested) {
223            requested
224        } else {
225            self.supported_efforts
226                .iter()
227                .copied()
228                .find(|candidate| *candidate > requested)
229                .or_else(|| {
230                    self.supported_efforts
231                        .iter()
232                        .rev()
233                        .copied()
234                        .find(|candidate| *candidate < requested)
235                })
236                .unwrap_or(self.default_effort)
237        };
238        ReasoningResolution {
239            requested,
240            effective,
241        }
242    }
243}
244
245/// Result of resolving a requested reasoning level for one model.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct ReasoningResolution {
248    requested: ReasoningEffort,
249    effective: ReasoningEffort,
250}
251
252impl ReasoningResolution {
253    /// Returns the caller's requested level.
254    #[must_use]
255    pub const fn requested(self) -> ReasoningEffort {
256        self.requested
257    }
258
259    /// Returns the supported level selected for the model.
260    #[must_use]
261    pub const fn effective(self) -> ReasoningEffort {
262        self.effective
263    }
264
265    /// Returns whether resolution changed the caller's request.
266    #[must_use]
267    pub fn was_clamped(self) -> bool {
268        self.requested != self.effective
269    }
270}
271
272/// Validated provider-neutral model specification.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct ModelSpec {
275    model_ref: ModelRef,
276    display_name: ModelDisplayName,
277    context_window_tokens: TokenCount,
278    max_output_tokens: TokenCount,
279    capabilities: ModelCapabilities,
280    reasoning_profile: Option<ReasoningProfile>,
281}
282
283impl ModelSpec {
284    /// Creates a validated model specification.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error when context/output limits are zero or output exceeds
289    /// the full context window.
290    pub fn new(
291        model_id: ModelId,
292        provider_id: ProviderId,
293        display_name: ModelDisplayName,
294        context_window_tokens: TokenCount,
295        max_output_tokens: TokenCount,
296        capabilities: ModelCapabilities,
297    ) -> Result<Self, ModelSpecError> {
298        if context_window_tokens.get() == 0 {
299            return Err(ModelSpecError::EmptyContextWindow);
300        }
301        if max_output_tokens.get() == 0 {
302            return Err(ModelSpecError::EmptyOutputLimit);
303        }
304        if max_output_tokens > context_window_tokens {
305            return Err(ModelSpecError::OutputExceedsContext);
306        }
307        let reasoning_profile = capabilities
308            .supports_reasoning()
309            .then(ReasoningProfile::compatible_default);
310        Ok(Self {
311            model_ref: ModelRef::new(provider_id, model_id),
312            display_name,
313            context_window_tokens,
314            max_output_tokens,
315            capabilities,
316            reasoning_profile,
317        })
318    }
319
320    /// Replaces the model's supported/default reasoning metadata.
321    #[must_use]
322    pub fn with_reasoning_profile(mut self, profile: ReasoningProfile) -> Self {
323        self.capabilities = self.capabilities.with_reasoning();
324        self.reasoning_profile = Some(profile);
325        self
326    }
327
328    /// Returns the canonical model selector.
329    #[must_use]
330    pub const fn model_id(&self) -> &ModelId {
331        self.model_ref.model_id()
332    }
333
334    /// Returns the owning provider selector.
335    #[must_use]
336    pub const fn provider_id(&self) -> &ProviderId {
337        self.model_ref.provider_id()
338    }
339
340    /// Returns the complete provider-qualified model identity.
341    #[must_use]
342    pub const fn model_ref(&self) -> &ModelRef {
343        &self.model_ref
344    }
345
346    /// Returns the human-readable model name.
347    #[must_use]
348    pub const fn display_name(&self) -> &ModelDisplayName {
349        &self.display_name
350    }
351
352    /// Returns the full context-window limit.
353    #[must_use]
354    pub const fn context_window_tokens(&self) -> TokenCount {
355        self.context_window_tokens
356    }
357
358    /// Returns the maximum generated output tokens.
359    #[must_use]
360    pub const fn max_output_tokens(&self) -> TokenCount {
361        self.max_output_tokens
362    }
363
364    /// Returns provider-neutral model capabilities.
365    #[must_use]
366    pub const fn capabilities(&self) -> ModelCapabilities {
367        self.capabilities
368    }
369
370    /// Returns supported/default reasoning metadata for a reasoning model.
371    #[must_use]
372    pub const fn reasoning_profile(&self) -> Option<&ReasoningProfile> {
373        self.reasoning_profile.as_ref()
374    }
375
376    /// Resolves an optional session selection against this model.
377    ///
378    /// A missing selection inherits a reasoning model's default. A requested
379    /// level on a non-reasoning model resolves to explicit `off`.
380    #[must_use]
381    pub fn resolve_reasoning(
382        &self,
383        requested: Option<ReasoningEffort>,
384    ) -> Option<ReasoningResolution> {
385        match (&self.reasoning_profile, requested) {
386            (Some(profile), Some(requested)) => Some(profile.resolve(requested)),
387            (Some(profile), None) => Some(profile.resolve(profile.default_effort())),
388            (None, Some(requested)) => Some(ReasoningResolution {
389                requested,
390                effective: ReasoningEffort::Off,
391            }),
392            (None, None) => None,
393        }
394    }
395}
396
397/// Error returned when model limits are inconsistent.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
399pub enum ModelSpecError {
400    /// Context window must contain at least one token.
401    #[error("model context window must be non-zero")]
402    EmptyContextWindow,
403    /// Output limit must contain at least one token.
404    #[error("model output limit must be non-zero")]
405    EmptyOutputLimit,
406    /// Output token limit exceeds the full context window.
407    #[error("model output limit exceeds context window")]
408    OutputExceedsContext,
409    /// A reasoning profile must advertise at least one level.
410    #[error("model reasoning profile is empty")]
411    EmptyReasoningEfforts,
412    /// A reasoning profile cannot repeat a canonical level.
413    #[error("model reasoning profile contains a duplicate effort")]
414    DuplicateReasoningEffort,
415    /// The model default must be one of its supported levels.
416    #[error("model reasoning default is unsupported")]
417    ReasoningDefaultUnsupported,
418}