1use crate::routing::probe_match::select_probe_slug;
2use crate::routing::slug;
3
4use super::availability::{ResolvedRunnablePath, RunnableConfidence, RunnablePathSource};
5use super::probes::{OpenCodeProbeResult, PiProbeResult};
6
7pub struct HarnessModelInput<'a> {
8 pub harness: &'a str,
9 pub model_id: &'a str,
10 pub provider_constraint: Option<&'a str>,
11 pub provider_for_order: Option<&'a str>,
12 pub settings_provider_order: Option<&'a [String]>,
13 pub opencode_probe: Option<&'a OpenCodeProbeResult>,
14 pub pi_probe: Option<&'a PiProbeResult>,
15}
16
17pub fn resolve_harness_model(input: HarnessModelInput<'_>) -> ResolvedRunnablePath {
18 let model_id = input.model_id.trim();
19 if model_id.is_empty() {
20 return ResolvedRunnablePath {
21 harness_model_id: String::new(),
22 source: RunnablePathSource::Passthrough,
23 confidence: RunnableConfidence::Unknown,
24 };
25 }
26
27 let harness = input.harness;
28 if harness.eq_ignore_ascii_case("pi") {
29 return resolve_pi_harness_model(input);
30 }
31 if harness.eq_ignore_ascii_case("opencode") {
32 return resolve_opencode_harness_model(input);
33 }
34
35 if native_provider_matches_harness(input, harness) {
36 return ResolvedRunnablePath {
37 harness_model_id: model_id.to_string(),
38 source: RunnablePathSource::ProviderMatch,
39 confidence: RunnableConfidence::Likely,
40 };
41 }
42
43 ResolvedRunnablePath {
44 harness_model_id: model_id.to_string(),
45 source: RunnablePathSource::Passthrough,
46 confidence: RunnableConfidence::Unknown,
47 }
48}
49
50fn native_provider_matches_harness(input: HarnessModelInput<'_>, harness: &str) -> bool {
51 let provider_matches = |provider: &str| {
52 !provider.trim().is_empty() && slug::provider_matches_native_harness(provider, harness)
53 };
54 input.provider_constraint.is_some_and(provider_matches)
55 || input.provider_for_order.is_some_and(provider_matches)
56}
57
58fn resolve_pi_harness_model(input: HarnessModelInput<'_>) -> ResolvedRunnablePath {
59 let model_id = input.model_id.trim();
60 let Some(pi_probe) = input.pi_probe else {
61 return constraint_qualified_passthrough(model_id, input.provider_constraint);
62 };
63 if !pi_probe.compatible {
64 return constraint_qualified_passthrough(model_id, input.provider_constraint);
65 }
66
67 probe_slug_or_passthrough(
68 model_id,
69 input.provider_constraint,
70 input.provider_for_order,
71 input.settings_provider_order,
72 pi_probe.model_slugs.iter().map(String::as_str),
73 )
74}
75
76fn resolve_opencode_harness_model(input: HarnessModelInput<'_>) -> ResolvedRunnablePath {
77 let model_id = input.model_id.trim();
78 let Some(opencode_probe) = input.opencode_probe else {
79 return constraint_qualified_passthrough(model_id, input.provider_constraint);
80 };
81 if !opencode_probe.model_probe_success {
82 return constraint_qualified_passthrough(model_id, input.provider_constraint);
83 }
84
85 probe_slug_or_passthrough(
86 model_id,
87 input.provider_constraint,
88 input.provider_for_order,
89 input.settings_provider_order,
90 opencode_probe.model_slugs.iter().map(String::as_str),
91 )
92}
93
94fn probe_slug_or_passthrough<'a>(
95 model_id: &str,
96 provider_constraint: Option<&str>,
97 provider_for_order: Option<&str>,
98 settings_provider_order: Option<&[String]>,
99 slugs: impl IntoIterator<Item = &'a str>,
100) -> ResolvedRunnablePath {
101 let selection = select_probe_slug(
102 model_id,
103 probe_constraint_for_selection(provider_constraint, provider_for_order),
104 provider_for_order,
105 settings_provider_order,
106 slugs,
107 );
108 if let Some(slug) = selection.chosen_slug {
109 return ResolvedRunnablePath {
110 harness_model_id: slug,
111 source: RunnablePathSource::CachedProbe,
112 confidence: RunnableConfidence::Confirmed,
113 };
114 }
115
116 constraint_qualified_passthrough(model_id, provider_constraint)
117}
118
119fn probe_constraint_for_selection<'a>(
122 provider_constraint: Option<&'a str>,
123 provider_for_order: Option<&'a str>,
124) -> Option<&'a str> {
125 let constraint = provider_constraint.filter(|provider| !provider.trim().is_empty())?;
126 let order = provider_for_order.filter(|provider| !provider.trim().is_empty());
127 if order.is_some_and(|order| slug::providers_exact_match(constraint, order)) {
128 return None;
129 }
130 Some(constraint)
131}
132
133fn constraint_qualified_passthrough(
134 model_id: &str,
135 provider_constraint: Option<&str>,
136) -> ResolvedRunnablePath {
137 if model_id.contains('/') {
138 return passthrough_bare(model_id);
139 }
140 if let Some(constraint) = provider_constraint.filter(|provider| !provider.trim().is_empty()) {
141 return ResolvedRunnablePath {
142 harness_model_id: format!("{}/{}", constraint.trim(), model_id),
143 source: RunnablePathSource::Passthrough,
144 confidence: RunnableConfidence::Confirmed,
145 };
146 }
147 passthrough_bare(model_id)
148}
149
150fn passthrough_bare(model_id: &str) -> ResolvedRunnablePath {
151 ResolvedRunnablePath {
152 harness_model_id: model_id.to_string(),
153 source: RunnablePathSource::Passthrough,
154 confidence: RunnableConfidence::Unknown,
155 }
156}
157
158pub fn pi_harness_model_requires_probe_slug(
159 harness: &str,
160 selection_kind: &str,
161 model_id: &str,
162 resolved: &ResolvedRunnablePath,
163) -> bool {
164 harness.eq_ignore_ascii_case("pi")
165 && selection_kind.eq_ignore_ascii_case("fixed")
166 && !model_id.trim().is_empty()
167 && resolved.source == RunnablePathSource::Passthrough
168 && !resolved.harness_model_id.contains('/')
169}
170
171#[cfg(test)]
172mod tests {
173 use std::collections::HashSet;
174
175 use super::*;
176 use crate::models::probes::PiProbeResult;
177
178 #[test]
179 fn qualified_provider_constraint_passthrough_without_probe() {
180 let resolved = resolve_harness_model(HarnessModelInput {
181 harness: "pi",
182 model_id: "gpt-5.4-mini",
183 provider_constraint: Some("openai-codex"),
184 provider_for_order: Some("openai-codex"),
185 settings_provider_order: None,
186 opencode_probe: None,
187 pi_probe: None,
188 });
189
190 assert_eq!(resolved.harness_model_id, "openai-codex/gpt-5.4-mini");
191 assert_eq!(resolved.source, RunnablePathSource::Passthrough);
192 assert_eq!(resolved.confidence, RunnableConfidence::Confirmed);
193 }
194
195 #[test]
196 fn pi_bare_model_uses_probe_slug() {
197 let mut model_slugs = HashSet::new();
198 model_slugs.insert("openai-codex/gpt-5.4-mini".to_string());
199 model_slugs.insert("openai/gpt-5.4-mini".to_string());
200 let pi_probe = PiProbeResult {
201 compatible: true,
202 model_slugs,
203 ..PiProbeResult::default()
204 };
205
206 let resolved = resolve_harness_model(HarnessModelInput {
207 harness: "pi",
208 model_id: "gpt-5.4-mini",
209 provider_constraint: None,
210 provider_for_order: Some("openai"),
211 settings_provider_order: None,
212 opencode_probe: None,
213 pi_probe: Some(&pi_probe),
214 });
215
216 assert_eq!(resolved.harness_model_id, "openai-codex/gpt-5.4-mini");
217 assert_eq!(resolved.source, RunnablePathSource::CachedProbe);
218 assert_eq!(resolved.confidence, RunnableConfidence::Confirmed);
219 }
220
221 #[test]
222 fn pi_constraint_prefers_matching_provider_slug() {
223 let mut model_slugs = HashSet::new();
224 model_slugs.insert("openai-codex/gpt-5.4-mini".to_string());
225 model_slugs.insert("openai/gpt-5.4-mini".to_string());
226 let pi_probe = PiProbeResult {
227 compatible: true,
228 model_slugs,
229 ..PiProbeResult::default()
230 };
231
232 let resolved = resolve_harness_model(HarnessModelInput {
233 harness: "pi",
234 model_id: "gpt-5.4-mini",
235 provider_constraint: Some("openai-codex"),
236 provider_for_order: Some("openai-codex"),
237 settings_provider_order: None,
238 opencode_probe: None,
239 pi_probe: Some(&pi_probe),
240 });
241
242 assert_eq!(resolved.harness_model_id, "openai-codex/gpt-5.4-mini");
243 }
244
245 #[test]
246 fn opencode_uses_probe_slug_without_provider_constraint() {
247 let opencode_probe = OpenCodeProbeResult {
248 model_slugs: vec![
249 "openai/gpt-5.4-mini".to_string(),
250 "openai/gpt-5.5".to_string(),
251 ],
252 model_probe_success: true,
253 error: None,
254 };
255
256 let resolved = resolve_harness_model(HarnessModelInput {
257 harness: "opencode",
258 model_id: "gpt-5.4-mini",
259 provider_constraint: None,
260 provider_for_order: Some("openai"),
261 settings_provider_order: None,
262 opencode_probe: Some(&opencode_probe),
263 pi_probe: None,
264 });
265
266 assert_eq!(resolved.harness_model_id, "openai/gpt-5.4-mini");
267 assert_eq!(resolved.source, RunnablePathSource::CachedProbe);
268 }
269
270 #[test]
271 fn codex_native_provider_match_returns_bare_model() {
272 let resolved = resolve_harness_model(HarnessModelInput {
273 harness: "codex",
274 model_id: "gpt-5.4-mini",
275 provider_constraint: None,
276 provider_for_order: Some("openai"),
277 settings_provider_order: None,
278 opencode_probe: None,
279 pi_probe: None,
280 });
281
282 assert_eq!(resolved.harness_model_id, "gpt-5.4-mini");
283 assert_eq!(resolved.source, RunnablePathSource::ProviderMatch);
284 assert_eq!(resolved.confidence, RunnableConfidence::Likely);
285 }
286
287 #[test]
288 fn codex_provider_constraint_native_match_returns_bare_model() {
289 let resolved = resolve_harness_model(HarnessModelInput {
290 harness: "codex",
291 model_id: "gpt-5.4-mini",
292 provider_constraint: Some("openai"),
293 provider_for_order: Some("openai"),
294 settings_provider_order: None,
295 opencode_probe: None,
296 pi_probe: None,
297 });
298
299 assert_eq!(resolved.harness_model_id, "gpt-5.4-mini");
300 assert_eq!(resolved.source, RunnablePathSource::ProviderMatch);
301 assert_eq!(resolved.confidence, RunnableConfidence::Likely);
302 }
303
304 #[test]
305 fn pi_provider_constraint_uses_probe_slug_not_blind_prefix() {
306 let mut model_slugs = HashSet::new();
307 model_slugs.insert("openai-codex/gpt-5.4-mini".to_string());
308 model_slugs.insert("openai/gpt-5.4-mini".to_string());
309 let pi_probe = PiProbeResult {
310 compatible: true,
311 model_slugs,
312 ..PiProbeResult::default()
313 };
314
315 let resolved = resolve_harness_model(HarnessModelInput {
316 harness: "pi",
317 model_id: "gpt-5.4-mini",
318 provider_constraint: Some("openai"),
319 provider_for_order: Some("openai"),
320 settings_provider_order: None,
321 opencode_probe: None,
322 pi_probe: Some(&pi_probe),
323 });
324
325 assert_eq!(resolved.harness_model_id, "openai-codex/gpt-5.4-mini");
326 assert_ne!(resolved.harness_model_id, "openai/gpt-5.4-mini");
327 assert_eq!(resolved.source, RunnablePathSource::CachedProbe);
328 assert_eq!(resolved.confidence, RunnableConfidence::Confirmed);
329 }
330
331 #[test]
332 fn pi_fixed_without_probe_slug_is_detected() {
333 let resolved = passthrough_bare("gpt-5.4-mini");
334 assert!(pi_harness_model_requires_probe_slug(
335 "pi",
336 "fixed",
337 "gpt-5.4-mini",
338 &resolved
339 ));
340 }
341}