Skip to main content

roder_core/
model_profiles.rs

1use std::collections::HashMap;
2
3use roder_api::catalog::{
4    EDIT_TOOL_EDIT, EDIT_TOOL_PATCH, REASONING_HIGH, REASONING_LOW, REASONING_MAX,
5    REASONING_MEDIUM, REASONING_MINIMAL, REASONING_NONE, REASONING_ULTRA, REASONING_XHIGH,
6    built_in_model_profile, built_in_model_profiles, lookup_model, model_supports_reasoning_effort,
7};
8use roder_api::inference::{
9    ModelHarnessProfile, ModelInstructionOverlay, ModelProfileReasoning, ModelSchemaPolicy,
10    ProviderFamily,
11};
12
13#[derive(Debug, Clone, Default)]
14pub struct ModelProfileOverrides {
15    pub profiles: HashMap<String, ModelHarnessProfileOverride>,
16}
17
18#[derive(Debug, Clone, Default)]
19pub struct ModelHarnessProfileOverride {
20    pub provider_family: Option<String>,
21    pub edit_tool: Option<String>,
22    pub schema_policy: Option<String>,
23    pub instruction_overlay: Option<String>,
24    pub reasoning: ModelProfileReasoningOverride,
25    pub parallel_tool_calls: Option<bool>,
26    pub auto_compact_token_limit: Option<u32>,
27}
28
29#[derive(Debug, Clone, Default)]
30pub struct ModelProfileReasoningOverride {
31    pub orientation: Option<String>,
32    pub execution: Option<String>,
33    pub verification: Option<String>,
34    pub recovery: Option<String>,
35}
36
37pub fn resolve_model_profiles(
38    overrides: &ModelProfileOverrides,
39) -> anyhow::Result<HashMap<String, ModelHarnessProfile>> {
40    let mut profiles = HashMap::new();
41    for profile in built_in_model_profiles() {
42        profiles.entry(profile.model.clone()).or_insert(profile);
43    }
44
45    for (model, override_profile) in &overrides.profiles {
46        let mut profile = profiles
47            .remove(model)
48            .or_else(|| built_in_model_profile(model))
49            .ok_or_else(|| anyhow::anyhow!("unknown model profile {model:?}"))?;
50        apply_override(&mut profile, override_profile)?;
51        validate_model_profile(&profile)?;
52        profiles.insert(model.clone(), profile);
53    }
54
55    for profile in profiles.values() {
56        validate_model_profile(profile)?;
57    }
58
59    Ok(profiles)
60}
61
62fn apply_override(
63    profile: &mut ModelHarnessProfile,
64    override_profile: &ModelHarnessProfileOverride,
65) -> anyhow::Result<()> {
66    if let Some(provider_family) = &override_profile.provider_family {
67        profile.provider_family = parse_provider_family(provider_family)?;
68    }
69    if let Some(edit_tool) = &override_profile.edit_tool {
70        profile.edit_tool = Some(edit_tool.clone());
71    }
72    if let Some(schema_policy) = &override_profile.schema_policy {
73        profile.schema_policy = parse_schema_policy(schema_policy)?;
74    }
75    if let Some(instruction_overlay) = &override_profile.instruction_overlay {
76        profile.instruction_overlay = parse_instruction_overlay(instruction_overlay)?;
77    }
78    merge_reasoning(&mut profile.reasoning, &override_profile.reasoning);
79    if let Some(parallel_tool_calls) = override_profile.parallel_tool_calls {
80        profile.parallel_tool_calls = Some(parallel_tool_calls);
81    }
82    if let Some(limit) = override_profile.auto_compact_token_limit {
83        profile.auto_compact_token_limit = Some(limit);
84    }
85    Ok(())
86}
87
88fn merge_reasoning(
89    profile: &mut ModelProfileReasoning,
90    override_reasoning: &ModelProfileReasoningOverride,
91) {
92    if let Some(reasoning) = &override_reasoning.orientation {
93        profile.orientation = Some(reasoning.clone());
94    }
95    if let Some(reasoning) = &override_reasoning.execution {
96        profile.execution = Some(reasoning.clone());
97    }
98    if let Some(reasoning) = &override_reasoning.verification {
99        profile.verification = Some(reasoning.clone());
100    }
101    if let Some(reasoning) = &override_reasoning.recovery {
102        profile.recovery = Some(reasoning.clone());
103    }
104}
105
106pub fn validate_model_profile(profile: &ModelHarnessProfile) -> anyhow::Result<()> {
107    if let Some(edit_tool) = profile.edit_tool.as_deref() {
108        validate_edit_tool_name(edit_tool)?;
109        if !lookup_model(&profile.model)
110            .map(|model| model.supports_tools)
111            .unwrap_or(false)
112        {
113            anyhow::bail!(
114                "model profile {:?} sets edit_tool but model does not support tools",
115                profile.model
116            );
117        }
118    }
119
120    for (phase, effort) in [
121        ("orientation", profile.reasoning.orientation.as_deref()),
122        ("execution", profile.reasoning.execution.as_deref()),
123        ("verification", profile.reasoning.verification.as_deref()),
124        ("recovery", profile.reasoning.recovery.as_deref()),
125    ] {
126        let Some(effort) = effort else {
127            continue;
128        };
129        validate_reasoning_name(effort)?;
130        if effort != REASONING_NONE && !model_supports_reasoning_effort(&profile.model, effort) {
131            anyhow::bail!(
132                "model profile {:?} uses unsupported {phase} reasoning effort {:?}",
133                profile.model,
134                effort
135            );
136        }
137    }
138
139    if profile.parallel_tool_calls == Some(true)
140        && !lookup_model(&profile.model)
141            .map(|model| model.supports_tools)
142            .unwrap_or(false)
143    {
144        anyhow::bail!(
145            "model profile {:?} enables parallel_tool_calls but model does not support tools",
146            profile.model
147        );
148    }
149
150    Ok(())
151}
152
153fn validate_edit_tool_name(value: &str) -> anyhow::Result<()> {
154    match value {
155        EDIT_TOOL_PATCH | EDIT_TOOL_EDIT => Ok(()),
156        other => anyhow::bail!(
157            "unsupported model profile edit_tool {other:?}; expected {EDIT_TOOL_PATCH:?} or {EDIT_TOOL_EDIT:?}"
158        ),
159    }
160}
161
162fn validate_reasoning_name(value: &str) -> anyhow::Result<()> {
163    match value {
164        REASONING_NONE | REASONING_MINIMAL | REASONING_LOW | REASONING_MEDIUM | REASONING_HIGH
165        | REASONING_XHIGH | REASONING_MAX | REASONING_ULTRA => Ok(()),
166        other => anyhow::bail!("unsupported model profile reasoning effort {other:?}"),
167    }
168}
169
170fn parse_provider_family(value: &str) -> anyhow::Result<ProviderFamily> {
171    match value {
172        "mock" => Ok(ProviderFamily::Mock),
173        "openai" => Ok(ProviderFamily::OpenAi),
174        "anthropic" => Ok(ProviderFamily::Anthropic),
175        "gemini" => Ok(ProviderFamily::Gemini),
176        "xai" => Ok(ProviderFamily::Xai),
177        "opencode" => Ok(ProviderFamily::Opencode),
178        "poolside" => Ok(ProviderFamily::Poolside),
179        other => anyhow::bail!("unsupported model profile provider_family {other:?}"),
180    }
181}
182
183fn parse_schema_policy(value: &str) -> anyhow::Result<ModelSchemaPolicy> {
184    match value {
185        "standard_required_first" => Ok(ModelSchemaPolicy::StandardRequiredFirst),
186        "required_first_flat" => Ok(ModelSchemaPolicy::RequiredFirstFlat),
187        other => anyhow::bail!("unsupported model profile schema_policy {other:?}"),
188    }
189}
190
191fn parse_instruction_overlay(value: &str) -> anyhow::Result<ModelInstructionOverlay> {
192    match value {
193        "standard" => Ok(ModelInstructionOverlay::Standard),
194        "literal_tool_outputs" => Ok(ModelInstructionOverlay::LiteralToolOutputs),
195        "intuitive_context" => Ok(ModelInstructionOverlay::IntuitiveContext),
196        other => anyhow::bail!("unsupported model profile instruction_overlay {other:?}"),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn model_profile_defaults_include_catalog_values() {
206        let profiles = resolve_model_profiles(&ModelProfileOverrides::default()).unwrap();
207        let profile = profiles.get("gpt-5.5").unwrap();
208
209        assert_eq!(profile.provider_family, ProviderFamily::OpenAi);
210        assert_eq!(profile.edit_tool.as_deref(), Some(EDIT_TOOL_PATCH));
211        assert_eq!(profile.reasoning.execution.as_deref(), Some(REASONING_LOW));
212    }
213
214    #[test]
215    fn model_profile_overrides_merge_into_builtin_profile() {
216        let mut overrides = ModelProfileOverrides::default();
217        overrides.profiles.insert(
218            "gpt-5.5".to_string(),
219            ModelHarnessProfileOverride {
220                schema_policy: Some("standard_required_first".to_string()),
221                reasoning: ModelProfileReasoningOverride {
222                    execution: Some("medium".to_string()),
223                    ..Default::default()
224                },
225                parallel_tool_calls: Some(false),
226                auto_compact_token_limit: Some(180_000),
227                ..Default::default()
228            },
229        );
230
231        let profiles = resolve_model_profiles(&overrides).unwrap();
232        let profile = profiles.get("gpt-5.5").unwrap();
233
234        assert_eq!(
235            profile.schema_policy,
236            ModelSchemaPolicy::StandardRequiredFirst
237        );
238        assert_eq!(
239            profile.reasoning.execution.as_deref(),
240            Some(REASONING_MEDIUM)
241        );
242        assert_eq!(profile.parallel_tool_calls, Some(false));
243        assert_eq!(profile.auto_compact_token_limit, Some(180_000));
244    }
245
246    #[test]
247    fn model_profile_validation_rejects_unknown_edit_tool() {
248        let mut overrides = ModelProfileOverrides::default();
249        overrides.profiles.insert(
250            "gpt-5.5".to_string(),
251            ModelHarnessProfileOverride {
252                edit_tool: Some("legacy_replace".to_string()),
253                ..Default::default()
254            },
255        );
256
257        let err = resolve_model_profiles(&overrides).unwrap_err();
258        assert!(
259            err.to_string()
260                .contains("unsupported model profile edit_tool")
261        );
262    }
263
264    #[test]
265    fn model_profile_validation_rejects_unsupported_reasoning() {
266        let mut overrides = ModelProfileOverrides::default();
267        overrides.profiles.insert(
268            "mock".to_string(),
269            ModelHarnessProfileOverride {
270                reasoning: ModelProfileReasoningOverride {
271                    execution: Some("high".to_string()),
272                    ..Default::default()
273                },
274                ..Default::default()
275            },
276        );
277
278        let err = resolve_model_profiles(&overrides).unwrap_err();
279        assert!(err.to_string().contains("unsupported execution reasoning"));
280    }
281
282    #[test]
283    fn model_profile_validation_accepts_xhigh_for_supported_models() {
284        let mut overrides = ModelProfileOverrides::default();
285        overrides.profiles.insert(
286            "gpt-5.5".to_string(),
287            ModelHarnessProfileOverride {
288                reasoning: ModelProfileReasoningOverride {
289                    orientation: Some(REASONING_XHIGH.to_string()),
290                    ..Default::default()
291                },
292                ..Default::default()
293            },
294        );
295
296        let profiles = resolve_model_profiles(&overrides).unwrap();
297        assert_eq!(
298            profiles
299                .get("gpt-5.5")
300                .unwrap()
301                .reasoning
302                .orientation
303                .as_deref(),
304            Some(REASONING_XHIGH)
305        );
306    }
307
308    #[test]
309    fn model_profile_validation_accepts_ultra_for_supported_models() {
310        let mut overrides = ModelProfileOverrides::default();
311        overrides.profiles.insert(
312            "gpt-5.6-sol".to_string(),
313            ModelHarnessProfileOverride {
314                reasoning: ModelProfileReasoningOverride {
315                    orientation: Some(REASONING_ULTRA.to_string()),
316                    ..Default::default()
317                },
318                ..Default::default()
319            },
320        );
321
322        let profiles = resolve_model_profiles(&overrides).unwrap();
323        assert_eq!(
324            profiles
325                .get("gpt-5.6-sol")
326                .unwrap()
327                .reasoning
328                .orientation
329                .as_deref(),
330            Some(REASONING_ULTRA)
331        );
332    }
333}