1use serde::{Deserialize, Serialize};
7
8use crate::provider::Provider;
9use crate::reasoning::ReasoningEffortLevel;
10
11const DEFAULT_CONTEXT_WINDOW: i64 = 128_000;
13
14const LARGE_CONTEXT_WINDOW: i64 = 1_048_576;
16
17const MEDIUM_CONTEXT_WINDOW: i64 = 200_000;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
22pub enum ShellToolType {
23 #[default]
25 Default,
26 ShellCommand,
28 Local,
30 ExecCommand,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum TruncationPolicy {
37 Bytes(usize),
39 Tokens(usize),
41 None,
43}
44
45impl Default for TruncationPolicy {
46 fn default() -> Self {
47 TruncationPolicy::Bytes(10_000)
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct ModelFamily {
54 slug: String,
56
57 pub family: String,
59
60 pub provider: Provider,
62
63 context_window: Option<i64>,
65
66 auto_compact_token_limit: Option<i64>,
68
69 pub supports_reasoning_summaries: bool,
71
72 default_reasoning_effort: Option<ReasoningEffortLevel>,
74
75 supports_parallel_tool_calls: bool,
77
78 needs_special_apply_patch_instructions: bool,
80
81 shell_type: ShellToolType,
83
84 truncation_policy: TruncationPolicy,
86
87 experimental_supported_tools: Vec<String>,
89
90 effective_context_window_percent: i64,
92
93 support_verbosity: bool,
95
96 supports_tool_use: bool,
98
99 supports_streaming: bool,
101
102 supports_thinking: bool,
104}
105
106impl Default for ModelFamily {
107 fn default() -> Self {
108 Self {
109 slug: String::new(),
110 family: String::new(),
111 provider: Provider::default(),
112 context_window: Some(DEFAULT_CONTEXT_WINDOW),
113 auto_compact_token_limit: None,
114 supports_reasoning_summaries: false,
115 default_reasoning_effort: None,
116 supports_parallel_tool_calls: false,
117 needs_special_apply_patch_instructions: false,
118 shell_type: ShellToolType::Default,
119 truncation_policy: TruncationPolicy::default(),
120 experimental_supported_tools: Vec::new(),
121 effective_context_window_percent: 95,
122 support_verbosity: false,
123 supports_tool_use: true,
124 supports_streaming: true,
125 supports_thinking: false,
126 }
127 }
128}
129
130impl ModelFamily {
131 fn new(slug: impl Into<String>, family: impl Into<String>, provider: Provider) -> Self {
133 Self {
134 slug: slug.into(),
135 family: family.into(),
136 provider,
137 ..Default::default()
138 }
139 }
140
141 fn auto_compact_token_limit(&self) -> Option<i64> {
143 self.auto_compact_token_limit
144 .or(self.context_window.map(Self::default_auto_compact_limit))
145 }
146
147 const fn default_auto_compact_limit(context_window: i64) -> i64 {
149 (context_window * 9) / 10
150 }
151
152 pub fn get_model_slug(&self) -> &str {
154 &self.slug
155 }
156
157 fn supports_feature(&self, feature: &str) -> bool {
159 match feature {
160 "reasoning" | "thinking" => self.supports_thinking,
161 "tool_use" | "tools" => self.supports_tool_use,
162 "streaming" => self.supports_streaming,
163 "parallel_tools" => self.supports_parallel_tool_calls,
164 _ => self.experimental_supported_tools.contains(&feature.to_string()),
165 }
166 }
167}
168
169#[macro_export]
171macro_rules! model_family {
172 (
173 $slug:expr, $family:expr, $provider:expr $(, $key:ident : $value:expr )* $(,)?
174 ) => {{
175 let mut mf = $crate::model_family::ModelFamily::new($slug, $family, $provider);
176 $(
177 mf.$key = $value;
178 )*
179 mf
180 }};
181}
182
183pub fn find_family_for_model(slug: &str) -> ModelFamily {
185 if let Some((provider, raw_slug)) = opencode_provider_and_raw_slug(slug) {
186 let mut family = find_family_for_model(raw_slug);
187 family.slug = slug.to_string();
188 family.provider = provider;
189 return family;
190 }
191
192 if slug.starts_with("gemini-3") {
194 return model_family!(
195 slug, "gemini-3", Provider::Gemini,
196 context_window: Some(LARGE_CONTEXT_WINDOW),
197 supports_thinking: true,
198 supports_parallel_tool_calls: true,
199 supports_reasoning_summaries: true,
200 );
201 }
202 if slug.starts_with("gemini") {
203 return model_family!(
204 slug, "gemini", Provider::Gemini,
205 context_window: Some(LARGE_CONTEXT_WINDOW),
206 );
207 }
208
209 if slug.starts_with("gpt-5") {
211 return model_family!(
212 slug, "gpt-5", Provider::OpenAI,
213 context_window: Some(DEFAULT_CONTEXT_WINDOW),
214 supports_thinking: true,
215 supports_parallel_tool_calls: true,
216 );
217 }
218 if slug.starts_with("codex") {
219 return model_family!(
220 slug, "codex", Provider::OpenAI,
221 context_window: Some(MEDIUM_CONTEXT_WINDOW),
222 supports_thinking: true,
223 shell_type: ShellToolType::ExecCommand,
224 );
225 }
226 if slug.starts_with("gpt-oss") || slug.contains("gpt-oss") {
227 return model_family!(
228 slug, "gpt-oss", Provider::OpenAI,
229 context_window: Some(96_000),
230 );
231 }
232 if slug.starts_with("o3") || slug.starts_with("o4") {
233 return model_family!(
234 slug, "o-series", Provider::OpenAI,
235 context_window: Some(MEDIUM_CONTEXT_WINDOW),
236 supports_thinking: true,
237 supports_reasoning_summaries: true,
238 needs_special_apply_patch_instructions: true,
239 );
240 }
241
242 if slug.starts_with("claude-opus") || slug.contains("opus") {
244 return model_family!(
245 slug, "claude-opus", Provider::Anthropic,
246 context_window: Some(MEDIUM_CONTEXT_WINDOW),
247 supports_thinking: true,
248 supports_parallel_tool_calls: true,
249 );
250 }
251 if slug.starts_with("claude-sonnet") || slug.contains("sonnet") {
252 return model_family!(
253 slug, "claude-sonnet", Provider::Anthropic,
254 context_window: Some(MEDIUM_CONTEXT_WINDOW),
255 supports_thinking: true,
256 );
257 }
258 if slug.starts_with("claude-haiku") || slug.contains("haiku") {
259 return model_family!(
260 slug, "claude-haiku", Provider::Anthropic,
261 context_window: Some(MEDIUM_CONTEXT_WINDOW),
262 );
263 }
264 if slug.starts_with("claude") {
265 return model_family!(
266 slug, "claude", Provider::Anthropic,
267 context_window: Some(MEDIUM_CONTEXT_WINDOW),
268 );
269 }
270
271 if slug.contains("deepseek") && slug.contains("reason") {
273 return model_family!(
274 slug, "deepseek-reasoner", Provider::DeepSeek,
275 context_window: Some(DEFAULT_CONTEXT_WINDOW),
276 supports_thinking: true,
277 );
278 }
279 if slug.contains("deepseek") {
280 return model_family!(
281 slug, "deepseek", Provider::DeepSeek,
282 context_window: Some(DEFAULT_CONTEXT_WINDOW),
283 );
284 }
285
286 if slug.starts_with("muse-spark-") {
288 return model_family!(
289 slug, "muse-spark", Provider::Meta,
290 context_window: Some(LARGE_CONTEXT_WINDOW),
291 supports_thinking: true,
292 supports_parallel_tool_calls: true,
293 supports_reasoning_summaries: false,
294 );
295 }
296
297 if slug.contains("glm-5") {
299 return model_family!(
300 slug, "glm-5", Provider::ZAI,
301 context_window: Some(DEFAULT_CONTEXT_WINDOW),
302 supports_thinking: true,
303 );
304 }
305 if slug.contains("glm") {
306 return model_family!(
307 slug, "glm", Provider::ZAI,
308 context_window: Some(DEFAULT_CONTEXT_WINDOW),
309 );
310 }
311
312 if slug.contains("minimax") {
314 return model_family!(
315 slug, "minimax", Provider::Minimax,
316 context_window: Some(DEFAULT_CONTEXT_WINDOW),
317 supports_thinking: true,
318 );
319 }
320
321 if slug.contains("kimi") || slug.contains("moonshot") {
323 return model_family!(
324 slug, "kimi", Provider::Moonshot,
325 context_window: Some(DEFAULT_CONTEXT_WINDOW),
326 supports_thinking: slug.contains("thinking"),
327 );
328 }
329
330 if slug.contains("qwen") {
332 return model_family!(
333 slug, "qwen", Provider::OpenRouter,
334 context_window: Some(DEFAULT_CONTEXT_WINDOW),
335 supports_thinking: slug.contains("thinking"),
336 );
337 }
338
339 if slug.starts_with("ollama/") || slug.contains(":") {
341 return model_family!(
342 slug, "ollama-local", Provider::Ollama,
343 context_window: Some(DEFAULT_CONTEXT_WINDOW),
344 );
345 }
346
347 if slug.contains("/") {
349 return model_family!(
350 slug, "openrouter", Provider::OpenRouter,
351 context_window: Some(DEFAULT_CONTEXT_WINDOW),
352 );
353 }
354
355 model_family!(
357 slug, "unknown", Provider::default(),
358 context_window: Some(DEFAULT_CONTEXT_WINDOW),
359 )
360}
361
362fn opencode_provider_and_raw_slug(slug: &str) -> Option<(Provider, &str)> {
363 if let Some(raw_slug) = slug.strip_prefix("opencode-go/") {
364 Some((Provider::OpenCodeGo, raw_slug))
365 } else if let Some(raw_slug) = slug.strip_prefix("opencode/").or_else(|| slug.strip_prefix("opencode-zen/")) {
366 Some((Provider::OpenCodeZen, raw_slug))
367 } else {
368 None
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_gemini_family_detection() {
378 let family = find_family_for_model("gemini-3-flash-preview");
379 assert_eq!(family.family, "gemini-3");
380 assert_eq!(family.provider, Provider::Gemini);
381 assert!(family.context_window.unwrap() >= LARGE_CONTEXT_WINDOW);
382 }
383
384 #[test]
385 fn test_gpt5_family_detection() {
386 let family = find_family_for_model("gpt-5.3-codex");
387 assert_eq!(family.family, "gpt-5");
388 assert_eq!(family.provider, Provider::OpenAI);
389 assert!(family.supports_thinking);
390 }
391
392 #[test]
393 fn test_claude_family_detection() {
394 let family = find_family_for_model("claude-opus-4.5");
395 assert_eq!(family.family, "claude-opus");
396 assert_eq!(family.provider, Provider::Anthropic);
397 }
398
399 #[test]
400 fn test_opencode_zen_family_detection_preserves_provider() {
401 let family = find_family_for_model("opencode/gpt-5.4");
402 assert_eq!(family.family, "gpt-5");
403 assert_eq!(family.provider, Provider::OpenCodeZen);
404 assert!(family.supports_thinking);
405 }
406
407 #[test]
408 fn test_opencode_go_family_detection_preserves_provider() {
409 let family = find_family_for_model("opencode-go/kimi-k2.5");
410 assert_eq!(family.family, "kimi");
411 assert_eq!(family.provider, Provider::OpenCodeGo);
412 }
413
414 #[test]
415 fn test_auto_compact_limit() {
416 let family = ModelFamily {
417 context_window: Some(100_000),
418 ..Default::default()
419 };
420 assert_eq!(family.auto_compact_token_limit(), Some(90_000));
421 }
422
423 #[test]
424 fn test_supports_feature() {
425 let family = ModelFamily {
426 supports_thinking: true,
427 supports_tool_use: true,
428 ..Default::default()
429 };
430 assert!(family.supports_feature("thinking"));
431 assert!(family.supports_feature("tool_use"));
432 assert!(!family.supports_feature("unknown"));
433 }
434}