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}