harn_vm/llm/capabilities/tool_format.rs
1//! Tool-format decision: validate and auto-correct a requested `tool_format`
2//! against the route's declared tool-call dialect validity.
3//!
4//! The capability registry declares, per route, which channel actually returns
5//! parseable tool calls. This module is the enforcement seam: it classifies a
6//! requested format into a [`ToolFormatWire`] channel, decides whether that
7//! channel is forbidden for the route, and either passes the request through or
8//! steers it to a working channel with an explanatory [`ToolFormatDecision`].
9
10use super::lookup::lookup;
11use super::model::Capabilities;
12
13/// The wire channel a `tool_format` string flows through. `native` is the
14/// provider's structured `tool_calls` JSON channel; `text` and `json` are
15/// text-channel grammars carried in assistant content. Mirrors
16/// `llm_config::ToolFormatChannel`, kept local so the capability registry
17/// (the single source of truth for tool-call dialect validity) has no
18/// dependency on the resolver.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ToolFormatWire {
21 /// Provider-native JSON tool calling (`tool_format = "native"`).
22 Native,
23 /// A text-channel grammar (`tool_format = "text"` or `"json"`).
24 Text,
25}
26
27impl ToolFormatWire {
28 /// Classify a `tool_format` string. Returns `None` for unknown values so
29 /// callers can reject typos loudly rather than guessing a channel.
30 pub fn classify(tool_format: &str) -> Option<Self> {
31 match tool_format {
32 "native" => Some(Self::Native),
33 "text" | "json" => Some(Self::Text),
34 _ => None,
35 }
36 }
37}
38
39/// Outcome of validating a requested `(provider, model, tool_format)` combo
40/// against the capability registry's tool-call dialect validity model.
41///
42/// This is the FOOTGUN-REMOVAL contract: a harness developer can ask for any
43/// tool_format, and the registry guarantees the resolved format is one that
44/// actually yields parseable tool calls for that route — auto-correcting a
45/// known-broken combo (e.g. a `native` pin on a `native_unreliable` route that
46/// silently drops to unparsed DSML text) and explaining why.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ToolFormatDecision {
49 /// The tool_format that should actually be used on the wire. Equal to the
50 /// requested format when the combo was already valid; otherwise the
51 /// registry's `preferred_tool_format` for the route.
52 pub effective: String,
53 /// Set when the requested format was overridden. Human-readable, names the
54 /// bad combo and the working alternative — surface this to the harness
55 /// developer so vanishing tool calls are never silent.
56 pub correction: Option<String>,
57}
58
59impl ToolFormatDecision {
60 fn accepted(format: String) -> Self {
61 Self {
62 effective: format,
63 correction: None,
64 }
65 }
66}
67
68/// True when a route's `tool_mode_parity` says the native (provider JSON)
69/// channel cannot be trusted to yield parseable tool calls. `unsupported`
70/// (no working channel) is intentionally excluded: there is no better format
71/// to steer to, so the gate leaves such a route alone rather than rewriting to
72/// another broken channel under a misleading "Using X instead" message.
73fn parity_forbids_native(parity: &str) -> bool {
74 matches!(parity, "native_unreliable" | "text_only")
75}
76
77/// True when a route's `tool_mode_parity` says a text-channel grammar cannot be
78/// trusted to yield parseable tool calls. See [`parity_forbids_native`] for why
79/// `unsupported` is excluded.
80fn parity_forbids_text(parity: &str) -> bool {
81 matches!(parity, "text_unreliable" | "native_only")
82}
83
84/// True when the requested wire channel is known not to return parseable tool
85/// calls for a route. The gate auto-corrects only on *positive* evidence of
86/// breakage, never on a "we don't know" default:
87///
88/// - `tool_mode_parity` is an explicit verdict (`parity_forbids_*`).
89/// - `text_tool_wire_format_supported = false` is an explicit declaration that
90/// the text channel does not survive this route (e.g. native-only local
91/// Ollama Qwen3 rows that omit a parity string). It defaults to `true`, so an
92/// unknown route is never wrongly judged text-broken.
93///
94/// `native_tools` is deliberately NOT consulted here: it defaults to `false`
95/// for unknown providers, so treating `!native_tools` as "native is broken"
96/// would wrongly rewrite a custom proxy that does support native tools. The
97/// hard `native` + `!native_tools` capability gate in `extract_llm_options`
98/// already rejects a genuine native-on-non-native mismatch loudly.
99fn channel_forbidden(wire: ToolFormatWire, caps: &Capabilities) -> bool {
100 let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
101 match wire {
102 ToolFormatWire::Native => parity_forbids_native(parity),
103 ToolFormatWire::Text => {
104 parity_forbids_text(parity) || !caps.text_tool_wire_format_supported
105 }
106 }
107}
108
109/// Validate (and, where the registry knows better, auto-correct) a requested
110/// `tool_format` for a `(provider, model)` route.
111///
112/// This is the single enforcement seam for tool-call dialect validity. The
113/// capability registry already declares, per route, which channel actually
114/// returns parseable tool calls (`tool_mode_parity`) and which format to use
115/// (`preferred_tool_format`). Before this function those fields were advisory
116/// metadata that any alias pin or explicit `--tool-format` flag could silently
117/// override — the footgun behind the DeepSeek V3.2 DSML "vanishing tool calls"
118/// dead-abstain. Now any combo whose requested channel is forbidden — by the
119/// route's `tool_mode_parity` verdict OR by an explicit
120/// `text_tool_wire_format_supported = false` declaration — is rewritten to a
121/// working channel (preferring the route's `preferred_tool_format`), with a
122/// `correction` message naming both. Unknown formats, routes with no adverse
123/// signal (`unknown`/`interchangeable`), and routes with no working channel at
124/// all pass through unchanged.
125pub fn validate_tool_format(provider: &str, model: &str, requested: &str) -> ToolFormatDecision {
126 let caps = lookup(provider, model);
127 validate_tool_format_with_caps(provider, model, requested, &caps)
128}
129
130/// `validate_tool_format` against an already-resolved [`Capabilities`], so hot
131/// callers that already hold one avoid a second matrix lookup.
132pub fn validate_tool_format_with_caps(
133 provider: &str,
134 model: &str,
135 requested: &str,
136 caps: &Capabilities,
137) -> ToolFormatDecision {
138 // Unknown / unclassifiable formats are not ours to second-guess — the
139 // exhaustive-match guard elsewhere already rejects typos loudly.
140 let Some(wire) = ToolFormatWire::classify(requested) else {
141 return ToolFormatDecision::accepted(requested.to_string());
142 };
143
144 if !channel_forbidden(wire, caps) {
145 return ToolFormatDecision::accepted(requested.to_string());
146 }
147
148 // The requested channel is known-broken for this route. Pick the opposite
149 // channel as the steer target, preferring the route's declared
150 // `preferred_tool_format` when it lands on a channel that is itself not
151 // forbidden. If BOTH channels are forbidden (a route with no working tool
152 // surface), there is nothing better to offer — pass the request through
153 // unchanged rather than rewrite to an equally-broken format under a
154 // misleading correction message.
155 let opposite = match wire {
156 ToolFormatWire::Native => ToolFormatWire::Text,
157 ToolFormatWire::Text => ToolFormatWire::Native,
158 };
159 if channel_forbidden(opposite, caps) {
160 return ToolFormatDecision::accepted(requested.to_string());
161 }
162 let preferred = caps
163 .preferred_tool_format
164 .clone()
165 .filter(|fmt| ToolFormatWire::classify(fmt) == Some(opposite))
166 .unwrap_or_else(|| match opposite {
167 ToolFormatWire::Native => "native".to_string(),
168 ToolFormatWire::Text => "json".to_string(),
169 });
170
171 let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
172 let mut correction = format!(
173 "tool_format `{requested}` is not safe for {provider}/{model} \
174 (tool_mode_parity = `{parity}`): this route does not return parseable \
175 tool calls on the {} channel, so calls would silently vanish. \
176 Using `{preferred}` instead.",
177 match wire {
178 ToolFormatWire::Native => "provider-native",
179 ToolFormatWire::Text => "text",
180 }
181 );
182 if let Some(note) = caps.tool_mode_parity_notes.as_deref() {
183 if !note.is_empty() {
184 correction.push_str(" (");
185 correction.push_str(note);
186 correction.push(')');
187 }
188 }
189
190 ToolFormatDecision {
191 effective: preferred,
192 correction: Some(correction),
193 }
194}
195
196/// FOOTGUN-REMOVAL — fail fast when a `(provider, model)` route has NO viable
197/// tool channel at all: the registry forbids both the provider-native channel
198/// AND every text-channel grammar. `validate_tool_format` deliberately passes
199/// such a route through unchanged (it has no *better* format to steer to and
200/// must not rewrite to an equally-broken one under a misleading "Using X
201/// instead" message); but a tool-bearing call dispatched on a route with no
202/// working channel can only produce a silent empty tool stream. This guard lets
203/// the call seam reject that combo BEFORE dispatch with an actionable message —
204/// naming the bad `(provider, model)` and a suggested alternative provider for
205/// the same model family — instead of billing a noncommittal completion.
206///
207/// Returns `Some(message)` only when both channels are forbidden (e.g. a route
208/// flagged `native_unreliable` whose text channel is also declared unsupported,
209/// or one explicitly pinned `tool_mode_parity = "unsupported"`). Returns `None`
210/// for every route that still has at least one working channel, so it never
211/// fires on the auto-correctable DeepInfra/SambaNova gpt-oss rows (those keep a
212/// working text channel) or on any healthy route. Modeled on the same
213/// `channel_forbidden` machinery `validate_tool_format` uses, so the two stay in
214/// lock-step: the gate auto-corrects when one channel works and fails fast when
215/// neither does.
216pub fn no_viable_tool_channel(provider: &str, model: &str) -> Option<String> {
217 let caps = lookup(provider, model);
218 no_viable_tool_channel_with_caps(provider, model, &caps)
219}
220
221/// `no_viable_tool_channel` against an already-resolved [`Capabilities`], so hot
222/// callers that already hold one avoid a second matrix lookup.
223pub fn no_viable_tool_channel_with_caps(
224 provider: &str,
225 model: &str,
226 caps: &Capabilities,
227) -> Option<String> {
228 let native_forbidden = channel_forbidden(ToolFormatWire::Native, caps);
229 let text_forbidden = channel_forbidden(ToolFormatWire::Text, caps);
230 if !(native_forbidden && text_forbidden) {
231 return None;
232 }
233 let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
234 let mut message = format!(
235 "no viable tool-calling channel for {provider}/{model} \
236 (tool_mode_parity = `{parity}`): the registry trusts neither the \
237 provider-native `tool_calls` channel nor a text-channel grammar to \
238 return parseable tool calls on this route, so a tool-bearing call here \
239 can only emit a silent empty tool stream. {}",
240 suggested_alternative_provider_hint(model)
241 );
242 if let Some(note) = caps.tool_mode_parity_notes.as_deref() {
243 if !note.is_empty() {
244 message.push_str(" (");
245 message.push_str(note);
246 message.push(')');
247 }
248 }
249 Some(message)
250}
251
252/// A short, actionable "try this provider instead" hint for a model whose
253/// current route has no viable tool channel. gpt-oss (Harmony) is the canonical
254/// case: its native channel is a footgun on several pay-per-token routes, so
255/// steer callers to the channels Harn has proven clean (Fireworks/DeepInfra/
256/// SambaNova on TEXT, or a native-clean route). Generic for everything else.
257fn suggested_alternative_provider_hint(model: &str) -> String {
258 if model.to_ascii_lowercase().contains("gpt-oss") {
259 "For gpt-oss (Harmony), use a TEXT-channel route (e.g. \
260 `fireworks`/`deepinfra`/`sambanova` gpt-oss, which Harn pins to \
261 `tool_format = \"text\"`) or a native-clean route; the provider-native \
262 Harmony channel drops tool calls into the reasoning channel."
263 .to_string()
264 } else {
265 "Pick a provider whose route for this model has a working native or \
266 text tool channel (see `harn provider catalog matrix`)."
267 .to_string()
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::super::lookup::{clear_user_overrides, lookup_with_user_overrides};
274 use super::super::model::CapabilitiesFile;
275 use super::super::BUILTIN_PROVIDERS_TOML;
276 use super::*;
277
278 fn reset() {
279 clear_user_overrides();
280 }
281
282 #[test]
283 fn every_catalogued_alias_tool_format_pin_is_safe_for_route() {
284 // Alias pins are consumed directly by downstream catalogs and CLI
285 // routing. They must not encode a known-broken channel that the
286 // central runtime guard would have to correct later.
287 reset();
288 let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
289 .expect("providers.toml must parse at build time");
290 let mut unsafe_pins = Vec::new();
291 for (alias, def) in &catalog.aliases {
292 let Some(tool_format) = def.tool_format.as_deref() else {
293 continue;
294 };
295 let decision = validate_tool_format(&def.provider, &def.id, tool_format);
296 if let Some(correction) = decision.correction.as_deref() {
297 unsafe_pins.push(format!(
298 "{alias} -> {}:{} pins {tool_format}, would be corrected to {} ({correction})",
299 def.provider, def.id, decision.effective
300 ));
301 }
302 }
303 assert!(
304 unsafe_pins.is_empty(),
305 "aliases pin unsafe tool_format values:\n- {}",
306 unsafe_pins.join("\n- ")
307 );
308 }
309
310 #[test]
311 fn validate_tool_format_autocorrects_native_pin_on_native_unreliable_route() {
312 reset();
313 // DeepSeek V3.2 on OpenRouter: tool_mode_parity = native_unreliable,
314 // preferred_tool_format = text. A `native` request is the footgun — it
315 // drops to unparsed DSML text and gets rejected. The gate must steer it
316 // to the route's preferred text-channel format and explain why.
317 let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "native");
318 assert_eq!(
319 decision.effective, "text",
320 "native must be auto-corrected to the route's preferred text format"
321 );
322 let reason = decision.correction.expect("a correction must be reported");
323 assert!(reason.contains("native"), "names the rejected format");
324 assert!(reason.contains("native_unreliable"), "names the parity");
325 assert!(reason.contains("text"), "names the working alternative");
326 }
327
328 #[test]
329 fn validate_tool_format_passes_through_safe_combos() {
330 reset();
331 // A native-capable route with no adverse parity keeps the requested
332 // native format untouched (no spurious correction).
333 let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3-base", "native");
334 assert_eq!(decision.effective, "native");
335 assert!(decision.correction.is_none());
336
337 // The same native_unreliable route is fine when text is requested.
338 let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "text");
339 assert_eq!(decision.effective, "text");
340 assert!(decision.correction.is_none());
341
342 // json is also a text-channel grammar and is accepted on a text route.
343 let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "json");
344 assert_eq!(decision.effective, "json");
345 assert!(decision.correction.is_none());
346 }
347
348 #[test]
349 fn validate_tool_format_leaves_unknown_routes_and_formats_alone() {
350 reset();
351 // Unknown provider/model has parity = unknown -> no opinion, pass through.
352 let decision = validate_tool_format("my-proxy", "mystery-1", "native");
353 assert_eq!(decision.effective, "native");
354 assert!(decision.correction.is_none());
355
356 // An unclassifiable tool_format string is not ours to rewrite.
357 let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "frobnicate");
358 assert_eq!(decision.effective, "frobnicate");
359 assert!(decision.correction.is_none());
360 }
361
362 #[test]
363 fn validate_tool_format_steers_off_text_on_native_only_route() {
364 reset();
365 // Synthesize a native_only route via a project override and confirm a
366 // text request is steered to native (the symmetric direction).
367 let overrides: CapabilitiesFile = toml::from_str(
368 "[[provider.acme]]\n\
369 model_match = \"native-only-*\"\n\
370 native_tools = true\n\
371 text_tool_wire_format_supported = false\n\
372 tool_mode_parity = \"native_only\"\n\
373 preferred_tool_format = \"native\"\n",
374 )
375 .expect("override parses");
376 let caps = lookup_with_user_overrides("acme", "native-only-1", Some(&overrides));
377 let decision = validate_tool_format_with_caps("acme", "native-only-1", "text", &caps);
378 assert_eq!(decision.effective, "native");
379 let reason = decision
380 .correction
381 .expect("text on native_only is corrected");
382 assert!(reason.contains("native_only"));
383 }
384
385 #[test]
386 fn validate_tool_format_honors_structural_text_unsupported_bit() {
387 reset();
388 // Real shipping route: ollama/qwen3* declares native_tools = true and
389 // text_tool_wire_format_supported = false with NO tool_mode_parity
390 // string. The gate's contract ("always yields parseable tool calls")
391 // must hold from the structural bit alone — a text/json request is
392 // steered to native, not passed through onto an unsupported channel.
393 let caps = lookup("ollama", "qwen3-coder:30b");
394 assert!(!caps.text_tool_wire_format_supported);
395 for requested in ["text", "json"] {
396 let decision =
397 validate_tool_format_with_caps("ollama", "qwen3-coder:30b", requested, &caps);
398 assert_eq!(
399 decision.effective, "native",
400 "{requested} must be steered to native on a text-unsupported route"
401 );
402 assert!(decision.correction.is_some());
403 }
404 // native is the route's working channel — untouched.
405 let native = validate_tool_format_with_caps("ollama", "qwen3-coder:30b", "native", &caps);
406 assert_eq!(native.effective, "native");
407 assert!(native.correction.is_none());
408 }
409
410 #[test]
411 fn tool_format_resolution_is_serving_stack_aware_for_same_weights() {
412 // The (model x serving-stack) insight: the SAME Qwen3.6 weights resolve
413 // to DIFFERENT working tool-call channels depending on who serves them.
414 // This divergence lives in the capability matrix as data (provider rows),
415 // NOT in alias pins — so an alias refactor must not be able to regress
416 // it. Locking the three live serving stacks here makes that explicit.
417 reset();
418
419 // llama.cpp (:8001) — the fresh #5162 family sweep used two replicates
420 // across six coding-agent fixtures for each Qwen3.6 quant and found
421 // native unreliable (2/12, 0/12, and 2/12 native passes for Q8, Q5,
422 // and Q4 respectively, versus 8/12 text passes for each). A native
423 // request therefore steers to the receipted JSON text contract.
424 let llamacpp = validate_tool_format("llamacpp", "qwen3.6-35b-a3b-ud-q4-k-xl", "native");
425 assert_eq!(
426 llamacpp.effective, "json",
427 "llama.cpp Qwen3.6 native route must steer to the measured text contract"
428 );
429 assert!(llamacpp.correction.is_some());
430
431 // Ollama (/v1) — the embedded qwen tool-call parser 500s on text-mode
432 // output, so this route is served on the text/json channel: a native
433 // request must be auto-corrected to json (never silently dropped).
434 let ollama = validate_tool_format("ollama", "qwen3.6-35b-a3b", "native");
435 assert_eq!(
436 ollama.effective, "json",
437 "ollama qwen3.6 must steer native -> json (server-side parser 500 leak)"
438 );
439 assert!(
440 ollama.correction.is_some(),
441 "the native->json steer must be explained, not silent"
442 );
443
444 // A native_unreliable cloud route (deepinfra GLM-5) carries the same
445 // serving-stack verdict via tool_mode_parity + empirical notes, and is
446 // likewise steered off native.
447 let glm = validate_tool_format("deepinfra", "deepinfra/glm-5.2", "native");
448 assert_eq!(glm.effective, "json");
449 assert!(glm.correction.is_some());
450 }
451
452 #[test]
453 fn validate_tool_format_passes_through_when_no_channel_works() {
454 reset();
455 // A route with no working tool surface — text_only parity forbids the
456 // native channel, and text_tool_wire_format_supported = false forbids
457 // the text channel — so BOTH channels are forbidden. The gate has
458 // nothing better to steer to; it must NOT rewrite to an equally broken
459 // format under a misleading correction. Pass through unchanged.
460 let overrides: CapabilitiesFile = toml::from_str(
461 "[[provider.acme]]\n\
462 model_match = \"no-tools-*\"\n\
463 native_tools = false\n\
464 tool_mode_parity = \"text_only\"\n\
465 text_tool_wire_format_supported = false\n",
466 )
467 .expect("override parses");
468 let caps = lookup_with_user_overrides("acme", "no-tools-1", Some(&overrides));
469 for requested in ["native", "text", "json"] {
470 let decision = validate_tool_format_with_caps("acme", "no-tools-1", requested, &caps);
471 assert_eq!(
472 decision.effective, requested,
473 "{requested} passes through unchanged"
474 );
475 assert!(decision.correction.is_none());
476 }
477 }
478
479 /// FOOTGUN-REMOVAL — gpt-oss (Harmony) on the pay-per-token DeepInfra and
480 /// SambaNova routes drops tool calls into the reasoning channel on native, so
481 /// a `native` pin must auto-correct to the route's `text` channel with an
482 /// explanatory correction. The known-good native routes (cerebras gpt-oss,
483 /// sambanova minimax) must stay untouched.
484 #[test]
485 fn validate_tool_format_autocorrects_gpt_oss_native_pin_to_text() {
486 reset();
487 for (provider, model) in [
488 ("deepinfra", "deepinfra/openai/gpt-oss-120b"),
489 ("sambanova", "sambanova/gpt-oss-120b"),
490 ] {
491 let decision = validate_tool_format(provider, model, "native");
492 assert_eq!(
493 decision.effective, "text",
494 "{provider}/{model}: native must auto-correct to text"
495 );
496 let reason = decision
497 .correction
498 .unwrap_or_else(|| panic!("{provider}/{model}: a correction must be reported"));
499 assert!(
500 reason.contains("native_unreliable"),
501 "{provider}/{model}: names the parity"
502 );
503 assert!(
504 reason.contains("text"),
505 "{provider}/{model}: names the working alternative"
506 );
507 // text is already safe and passes through unchanged.
508 let text = validate_tool_format(provider, model, "text");
509 assert_eq!(text.effective, "text");
510 assert!(text.correction.is_none());
511 }
512 }
513
514 /// FOOTGUN-REMOVAL — the GLM-5.x native channel emits `<tool_call>` markup
515 /// instead of provider-native `tool_calls`, so the zai-direct GLM rows pin
516 /// text and a `native` pin must auto-correct, matching the Fireworks/
517 /// DeepInfra/Baseten precedents.
518 #[test]
519 fn validate_tool_format_autocorrects_zai_glm_native_pin_to_text() {
520 reset();
521 for model in ["glm-5.2", "glm-5.1", "glm-5"] {
522 let decision = validate_tool_format("zai", model, "native");
523 assert_eq!(
524 decision.effective, "text",
525 "zai/{model}: native must auto-correct to text"
526 );
527 let reason = decision
528 .correction
529 .unwrap_or_else(|| panic!("zai/{model}: a correction must be reported"));
530 assert!(
531 reason.contains("native_unreliable"),
532 "zai/{model}: names the parity"
533 );
534 }
535 }
536
537 /// The known-good native routes must NOT be touched by the gpt-oss/GLM
538 /// pins above — a native pin stays native with no spurious correction.
539 #[test]
540 fn validate_tool_format_leaves_known_good_native_routes_unchanged() {
541 reset();
542 for (provider, model) in [
543 // cerebras gpt-oss is native-clean (only throttled).
544 ("cerebras", "gpt-oss-120b"),
545 // sambanova deepseek-v3.2 is native and interchangeable; minimax is
546 // native_unreliable upstream and is not a known-good native
547 // exemplar.
548 ("sambanova", "DeepSeek-V3.2"),
549 ] {
550 let decision = validate_tool_format(provider, model, "native");
551 assert_eq!(
552 decision.effective, "native",
553 "{provider}/{model}: known-good native route must stay native"
554 );
555 assert!(
556 decision.correction.is_none(),
557 "{provider}/{model}: no spurious correction"
558 );
559 }
560 }
561
562 /// FOOTGUN-REMOVAL — the first-class no-viable-channel guard fires when BOTH
563 /// channels are forbidden (a route the registry trusts on neither native nor
564 /// text), naming the bad combo and a suggested alternative — never a silent
565 /// empty tool stream.
566 #[test]
567 fn no_viable_tool_channel_guard_fires_only_when_both_channels_forbidden() {
568 reset();
569 // Construct a gpt-oss route with NO working channel: native_unreliable
570 // forbids native, and text_tool_wire_format_supported = false forbids the
571 // text channel too.
572 let overrides: CapabilitiesFile = toml::from_str(
573 "[[provider.acme]]\n\
574 model_match = \"acme/gpt-oss-stub\"\n\
575 native_tools = false\n\
576 tool_mode_parity = \"native_unreliable\"\n\
577 text_tool_wire_format_supported = false\n",
578 )
579 .expect("override parses");
580 let caps = lookup_with_user_overrides("acme", "acme/gpt-oss-stub", Some(&overrides));
581 let message = no_viable_tool_channel_with_caps("acme", "acme/gpt-oss-stub", &caps)
582 .expect("the guard must fire when neither channel works");
583 assert!(
584 message.contains("no viable tool-calling channel"),
585 "names the failure: {message}"
586 );
587 assert!(
588 message.contains("acme/gpt-oss-stub"),
589 "names the bad combo: {message}"
590 );
591 // gpt-oss models get the Harmony-specific text-channel hint.
592 assert!(
593 message.contains("gpt-oss") && message.contains("text"),
594 "suggests an alternative: {message}"
595 );
596
597 // The DeepInfra/SambaNova gpt-oss rows keep a working text channel, so
598 // the guard must NOT fire on them (they auto-correct instead).
599 assert!(
600 no_viable_tool_channel("deepinfra", "deepinfra/openai/gpt-oss-120b").is_none(),
601 "auto-correctable route must not trip the fail-fast guard"
602 );
603 assert!(
604 no_viable_tool_channel("sambanova", "sambanova/gpt-oss-120b").is_none(),
605 "auto-correctable route must not trip the fail-fast guard"
606 );
607 // A healthy native-clean route never trips it.
608 assert!(
609 no_viable_tool_channel("cerebras", "gpt-oss-120b").is_none(),
610 "healthy native route must not trip the guard"
611 );
612 // The generic (non-gpt-oss) no-channel case still fires with a generic
613 // hint.
614 let generic: CapabilitiesFile = toml::from_str(
615 "[[provider.acme]]\n\
616 model_match = \"mystery-1\"\n\
617 native_tools = false\n\
618 tool_mode_parity = \"text_only\"\n\
619 text_tool_wire_format_supported = false\n",
620 )
621 .expect("override parses");
622 let caps = lookup_with_user_overrides("acme", "mystery-1", Some(&generic));
623 let message = no_viable_tool_channel_with_caps("acme", "mystery-1", &caps)
624 .expect("guard fires on the generic no-channel route too");
625 assert!(
626 message.contains("harn provider catalog matrix"),
627 "{message}"
628 );
629 }
630
631 // --- `extends = true` field-wise fall-through ---
632}