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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct ModelDisplayName(String);
14
15impl ModelDisplayName {
16 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
45pub enum ModelTextParseError {
46 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct ModelCapabilities(u16);
61
62impl ModelCapabilities {
63 #[must_use]
65 pub const fn text() -> Self {
66 Self(0)
67 }
68
69 #[must_use]
71 pub const fn with_image_input(mut self) -> Self {
72 self.0 |= CAP_IMAGE_INPUT;
73 self
74 }
75
76 #[must_use]
78 pub const fn with_reasoning(mut self) -> Self {
79 self.0 |= CAP_REASONING;
80 self
81 }
82
83 #[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 #[must_use]
97 pub const fn with_usage_reporting(mut self) -> Self {
98 self.0 |= CAP_USAGE;
99 self
100 }
101
102 #[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 #[must_use]
113 pub const fn accepts_text(self) -> bool {
114 true
115 }
116
117 #[must_use]
119 pub const fn accepts_images(self) -> bool {
120 self.0 & CAP_IMAGE_INPUT != 0
121 }
122
123 #[must_use]
125 pub const fn supports_reasoning(self) -> bool {
126 self.0 & CAP_REASONING != 0
127 }
128
129 #[must_use]
131 pub const fn supports_tools(self) -> bool {
132 self.0 & CAP_TOOLS != 0
133 }
134
135 #[must_use]
137 pub const fn supports_parallel_tool_calls(self) -> bool {
138 self.0 & CAP_PARALLEL_TOOLS != 0
139 }
140
141 #[must_use]
143 pub const fn reports_usage(self) -> bool {
144 self.0 & CAP_USAGE != 0
145 }
146
147 #[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#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ReasoningProfile {
165 default_effort: ReasoningEffort,
166 supported_efforts: Vec<ReasoningEffort>,
167}
168
169impl ReasoningProfile {
170 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 #[must_use]
209 pub const fn default_effort(&self) -> ReasoningEffort {
210 self.default_effort
211 }
212
213 #[must_use]
215 pub fn supported_efforts(&self) -> &[ReasoningEffort] {
216 &self.supported_efforts
217 }
218
219 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct ReasoningResolution {
248 requested: ReasoningEffort,
249 effective: ReasoningEffort,
250}
251
252impl ReasoningResolution {
253 #[must_use]
255 pub const fn requested(self) -> ReasoningEffort {
256 self.requested
257 }
258
259 #[must_use]
261 pub const fn effective(self) -> ReasoningEffort {
262 self.effective
263 }
264
265 #[must_use]
267 pub fn was_clamped(self) -> bool {
268 self.requested != self.effective
269 }
270}
271
272#[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 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 #[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 #[must_use]
330 pub const fn model_id(&self) -> &ModelId {
331 self.model_ref.model_id()
332 }
333
334 #[must_use]
336 pub const fn provider_id(&self) -> &ProviderId {
337 self.model_ref.provider_id()
338 }
339
340 #[must_use]
342 pub const fn model_ref(&self) -> &ModelRef {
343 &self.model_ref
344 }
345
346 #[must_use]
348 pub const fn display_name(&self) -> &ModelDisplayName {
349 &self.display_name
350 }
351
352 #[must_use]
354 pub const fn context_window_tokens(&self) -> TokenCount {
355 self.context_window_tokens
356 }
357
358 #[must_use]
360 pub const fn max_output_tokens(&self) -> TokenCount {
361 self.max_output_tokens
362 }
363
364 #[must_use]
366 pub const fn capabilities(&self) -> ModelCapabilities {
367 self.capabilities
368 }
369
370 #[must_use]
372 pub const fn reasoning_profile(&self) -> Option<&ReasoningProfile> {
373 self.reasoning_profile.as_ref()
374 }
375
376 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
399pub enum ModelSpecError {
400 #[error("model context window must be non-zero")]
402 EmptyContextWindow,
403 #[error("model output limit must be non-zero")]
405 EmptyOutputLimit,
406 #[error("model output limit exceeds context window")]
408 OutputExceedsContext,
409 #[error("model reasoning profile is empty")]
411 EmptyReasoningEfforts,
412 #[error("model reasoning profile contains a duplicate effort")]
414 DuplicateReasoningEffort,
415 #[error("model reasoning default is unsupported")]
417 ReasoningDefaultUnsupported,
418}