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) — native is probe-validated and trusted.
420 let llamacpp = validate_tool_format("llamacpp", "qwen3.6-35b-a3b-ud-q4-k-xl", "native");
421 assert_eq!(
422 llamacpp.effective, "native",
423 "llama.cpp serves qwen3.6 native"
424 );
425 assert!(llamacpp.correction.is_none());
426
427 // Ollama (/v1) — the embedded qwen tool-call parser 500s on text-mode
428 // output, so this route is served on the text/json channel: a native
429 // request must be auto-corrected to json (never silently dropped).
430 let ollama = validate_tool_format("ollama", "qwen3.6-35b-a3b", "native");
431 assert_eq!(
432 ollama.effective, "json",
433 "ollama qwen3.6 must steer native -> json (server-side parser 500 leak)"
434 );
435 assert!(
436 ollama.correction.is_some(),
437 "the native->json steer must be explained, not silent"
438 );
439
440 // A native_unreliable cloud route (deepinfra GLM-5) carries the same
441 // serving-stack verdict via tool_mode_parity + empirical notes, and is
442 // likewise steered off native.
443 let glm = validate_tool_format("deepinfra", "deepinfra/glm-5.2", "native");
444 assert_eq!(glm.effective, "json");
445 assert!(glm.correction.is_some());
446 }
447
448 #[test]
449 fn validate_tool_format_passes_through_when_no_channel_works() {
450 reset();
451 // A route with no working tool surface — text_only parity forbids the
452 // native channel, and text_tool_wire_format_supported = false forbids
453 // the text channel — so BOTH channels are forbidden. The gate has
454 // nothing better to steer to; it must NOT rewrite to an equally broken
455 // format under a misleading correction. Pass through unchanged.
456 let overrides: CapabilitiesFile = toml::from_str(
457 "[[provider.acme]]\n\
458 model_match = \"no-tools-*\"\n\
459 native_tools = false\n\
460 tool_mode_parity = \"text_only\"\n\
461 text_tool_wire_format_supported = false\n",
462 )
463 .expect("override parses");
464 let caps = lookup_with_user_overrides("acme", "no-tools-1", Some(&overrides));
465 for requested in ["native", "text", "json"] {
466 let decision = validate_tool_format_with_caps("acme", "no-tools-1", requested, &caps);
467 assert_eq!(
468 decision.effective, requested,
469 "{requested} passes through unchanged"
470 );
471 assert!(decision.correction.is_none());
472 }
473 }
474
475 /// FOOTGUN-REMOVAL — gpt-oss (Harmony) on the pay-per-token DeepInfra and
476 /// SambaNova routes drops tool calls into the reasoning channel on native, so
477 /// a `native` pin must auto-correct to the route's `text` channel with an
478 /// explanatory correction. The known-good native routes (cerebras gpt-oss,
479 /// sambanova minimax) must stay untouched.
480 #[test]
481 fn validate_tool_format_autocorrects_gpt_oss_native_pin_to_text() {
482 reset();
483 for (provider, model) in [
484 ("deepinfra", "deepinfra/openai/gpt-oss-120b"),
485 ("sambanova", "sambanova/gpt-oss-120b"),
486 ] {
487 let decision = validate_tool_format(provider, model, "native");
488 assert_eq!(
489 decision.effective, "text",
490 "{provider}/{model}: native must auto-correct to text"
491 );
492 let reason = decision
493 .correction
494 .unwrap_or_else(|| panic!("{provider}/{model}: a correction must be reported"));
495 assert!(
496 reason.contains("native_unreliable"),
497 "{provider}/{model}: names the parity"
498 );
499 assert!(
500 reason.contains("text"),
501 "{provider}/{model}: names the working alternative"
502 );
503 // text is already safe and passes through unchanged.
504 let text = validate_tool_format(provider, model, "text");
505 assert_eq!(text.effective, "text");
506 assert!(text.correction.is_none());
507 }
508 }
509
510 /// FOOTGUN-REMOVAL — the GLM-5.x native channel emits `<tool_call>` markup
511 /// instead of provider-native `tool_calls`, so the zai-direct GLM rows pin
512 /// text and a `native` pin must auto-correct, matching the Fireworks/
513 /// DeepInfra/Baseten precedents.
514 #[test]
515 fn validate_tool_format_autocorrects_zai_glm_native_pin_to_text() {
516 reset();
517 for model in ["glm-5.2", "glm-5.1", "glm-5"] {
518 let decision = validate_tool_format("zai", model, "native");
519 assert_eq!(
520 decision.effective, "text",
521 "zai/{model}: native must auto-correct to text"
522 );
523 let reason = decision
524 .correction
525 .unwrap_or_else(|| panic!("zai/{model}: a correction must be reported"));
526 assert!(
527 reason.contains("native_unreliable"),
528 "zai/{model}: names the parity"
529 );
530 }
531 }
532
533 /// The known-good native routes must NOT be touched by the gpt-oss/GLM
534 /// pins above — a native pin stays native with no spurious correction.
535 #[test]
536 fn validate_tool_format_leaves_known_good_native_routes_unchanged() {
537 reset();
538 for (provider, model) in [
539 // cerebras gpt-oss is native-clean (only throttled).
540 ("cerebras", "gpt-oss-120b"),
541 // sambanova deepseek-v3.2 is native and interchangeable; minimax is
542 // native_unreliable upstream and is not a known-good native
543 // exemplar.
544 ("sambanova", "DeepSeek-V3.2"),
545 ] {
546 let decision = validate_tool_format(provider, model, "native");
547 assert_eq!(
548 decision.effective, "native",
549 "{provider}/{model}: known-good native route must stay native"
550 );
551 assert!(
552 decision.correction.is_none(),
553 "{provider}/{model}: no spurious correction"
554 );
555 }
556 }
557
558 /// FOOTGUN-REMOVAL — the first-class no-viable-channel guard fires when BOTH
559 /// channels are forbidden (a route the registry trusts on neither native nor
560 /// text), naming the bad combo and a suggested alternative — never a silent
561 /// empty tool stream.
562 #[test]
563 fn no_viable_tool_channel_guard_fires_only_when_both_channels_forbidden() {
564 reset();
565 // Construct a gpt-oss route with NO working channel: native_unreliable
566 // forbids native, and text_tool_wire_format_supported = false forbids the
567 // text channel too.
568 let overrides: CapabilitiesFile = toml::from_str(
569 "[[provider.acme]]\n\
570 model_match = \"acme/gpt-oss-stub\"\n\
571 native_tools = false\n\
572 tool_mode_parity = \"native_unreliable\"\n\
573 text_tool_wire_format_supported = false\n",
574 )
575 .expect("override parses");
576 let caps = lookup_with_user_overrides("acme", "acme/gpt-oss-stub", Some(&overrides));
577 let message = no_viable_tool_channel_with_caps("acme", "acme/gpt-oss-stub", &caps)
578 .expect("the guard must fire when neither channel works");
579 assert!(
580 message.contains("no viable tool-calling channel"),
581 "names the failure: {message}"
582 );
583 assert!(
584 message.contains("acme/gpt-oss-stub"),
585 "names the bad combo: {message}"
586 );
587 // gpt-oss models get the Harmony-specific text-channel hint.
588 assert!(
589 message.contains("gpt-oss") && message.contains("text"),
590 "suggests an alternative: {message}"
591 );
592
593 // The DeepInfra/SambaNova gpt-oss rows keep a working text channel, so
594 // the guard must NOT fire on them (they auto-correct instead).
595 assert!(
596 no_viable_tool_channel("deepinfra", "deepinfra/openai/gpt-oss-120b").is_none(),
597 "auto-correctable route must not trip the fail-fast guard"
598 );
599 assert!(
600 no_viable_tool_channel("sambanova", "sambanova/gpt-oss-120b").is_none(),
601 "auto-correctable route must not trip the fail-fast guard"
602 );
603 // A healthy native-clean route never trips it.
604 assert!(
605 no_viable_tool_channel("cerebras", "gpt-oss-120b").is_none(),
606 "healthy native route must not trip the guard"
607 );
608 // The generic (non-gpt-oss) no-channel case still fires with a generic
609 // hint.
610 let generic: CapabilitiesFile = toml::from_str(
611 "[[provider.acme]]\n\
612 model_match = \"mystery-1\"\n\
613 native_tools = false\n\
614 tool_mode_parity = \"text_only\"\n\
615 text_tool_wire_format_supported = false\n",
616 )
617 .expect("override parses");
618 let caps = lookup_with_user_overrides("acme", "mystery-1", Some(&generic));
619 let message = no_viable_tool_channel_with_caps("acme", "mystery-1", &caps)
620 .expect("guard fires on the generic no-channel route too");
621 assert!(
622 message.contains("harn provider catalog matrix"),
623 "{message}"
624 );
625 }
626
627 // --- `extends = true` field-wise fall-through ---
628}