use super::lookup::lookup;
use super::model::Capabilities;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolFormatWire {
Native,
Text,
}
impl ToolFormatWire {
pub fn classify(tool_format: &str) -> Option<Self> {
match tool_format {
"native" => Some(Self::Native),
"text" | "json" => Some(Self::Text),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolFormatDecision {
pub effective: String,
pub correction: Option<String>,
}
impl ToolFormatDecision {
fn accepted(format: String) -> Self {
Self {
effective: format,
correction: None,
}
}
}
fn parity_forbids_native(parity: &str) -> bool {
matches!(parity, "native_unreliable" | "text_only")
}
fn parity_forbids_text(parity: &str) -> bool {
matches!(parity, "text_unreliable" | "native_only")
}
fn channel_forbidden(wire: ToolFormatWire, caps: &Capabilities) -> bool {
let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
match wire {
ToolFormatWire::Native => parity_forbids_native(parity),
ToolFormatWire::Text => {
parity_forbids_text(parity) || !caps.text_tool_wire_format_supported
}
}
}
pub fn validate_tool_format(provider: &str, model: &str, requested: &str) -> ToolFormatDecision {
let caps = lookup(provider, model);
validate_tool_format_with_caps(provider, model, requested, &caps)
}
pub fn validate_tool_format_with_caps(
provider: &str,
model: &str,
requested: &str,
caps: &Capabilities,
) -> ToolFormatDecision {
let Some(wire) = ToolFormatWire::classify(requested) else {
return ToolFormatDecision::accepted(requested.to_string());
};
if !channel_forbidden(wire, caps) {
return ToolFormatDecision::accepted(requested.to_string());
}
let opposite = match wire {
ToolFormatWire::Native => ToolFormatWire::Text,
ToolFormatWire::Text => ToolFormatWire::Native,
};
if channel_forbidden(opposite, caps) {
return ToolFormatDecision::accepted(requested.to_string());
}
let preferred = caps
.preferred_tool_format
.clone()
.filter(|fmt| ToolFormatWire::classify(fmt) == Some(opposite))
.unwrap_or_else(|| match opposite {
ToolFormatWire::Native => "native".to_string(),
ToolFormatWire::Text => "json".to_string(),
});
let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
let mut correction = format!(
"tool_format `{requested}` is not safe for {provider}/{model} \
(tool_mode_parity = `{parity}`): this route does not return parseable \
tool calls on the {} channel, so calls would silently vanish. \
Using `{preferred}` instead.",
match wire {
ToolFormatWire::Native => "provider-native",
ToolFormatWire::Text => "text",
}
);
if let Some(note) = caps.tool_mode_parity_notes.as_deref() {
if !note.is_empty() {
correction.push_str(" (");
correction.push_str(note);
correction.push(')');
}
}
ToolFormatDecision {
effective: preferred,
correction: Some(correction),
}
}
pub fn no_viable_tool_channel(provider: &str, model: &str) -> Option<String> {
let caps = lookup(provider, model);
no_viable_tool_channel_with_caps(provider, model, &caps)
}
pub fn no_viable_tool_channel_with_caps(
provider: &str,
model: &str,
caps: &Capabilities,
) -> Option<String> {
let native_forbidden = channel_forbidden(ToolFormatWire::Native, caps);
let text_forbidden = channel_forbidden(ToolFormatWire::Text, caps);
if !(native_forbidden && text_forbidden) {
return None;
}
let parity = caps.tool_mode_parity.as_deref().unwrap_or("unknown");
let mut message = format!(
"no viable tool-calling channel for {provider}/{model} \
(tool_mode_parity = `{parity}`): the registry trusts neither the \
provider-native `tool_calls` channel nor a text-channel grammar to \
return parseable tool calls on this route, so a tool-bearing call here \
can only emit a silent empty tool stream. {}",
suggested_alternative_provider_hint(model)
);
if let Some(note) = caps.tool_mode_parity_notes.as_deref() {
if !note.is_empty() {
message.push_str(" (");
message.push_str(note);
message.push(')');
}
}
Some(message)
}
fn suggested_alternative_provider_hint(model: &str) -> String {
if model.to_ascii_lowercase().contains("gpt-oss") {
"For gpt-oss (Harmony), use a TEXT-channel route (e.g. \
`fireworks`/`deepinfra`/`sambanova` gpt-oss, which Harn pins to \
`tool_format = \"text\"`) or a native-clean route; the provider-native \
Harmony channel drops tool calls into the reasoning channel."
.to_string()
} else {
"Pick a provider whose route for this model has a working native or \
text tool channel (see `harn provider catalog matrix`)."
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::super::lookup::{clear_user_overrides, lookup_with_user_overrides};
use super::super::model::CapabilitiesFile;
use super::super::BUILTIN_PROVIDERS_TOML;
use super::*;
fn reset() {
clear_user_overrides();
}
#[test]
fn every_catalogued_alias_tool_format_pin_is_safe_for_route() {
reset();
let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
.expect("providers.toml must parse at build time");
let mut unsafe_pins = Vec::new();
for (alias, def) in &catalog.aliases {
let Some(tool_format) = def.tool_format.as_deref() else {
continue;
};
let decision = validate_tool_format(&def.provider, &def.id, tool_format);
if let Some(correction) = decision.correction.as_deref() {
unsafe_pins.push(format!(
"{alias} -> {}:{} pins {tool_format}, would be corrected to {} ({correction})",
def.provider, def.id, decision.effective
));
}
}
assert!(
unsafe_pins.is_empty(),
"aliases pin unsafe tool_format values:\n- {}",
unsafe_pins.join("\n- ")
);
}
#[test]
fn validate_tool_format_autocorrects_native_pin_on_native_unreliable_route() {
reset();
let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "native");
assert_eq!(
decision.effective, "text",
"native must be auto-corrected to the route's preferred text format"
);
let reason = decision.correction.expect("a correction must be reported");
assert!(reason.contains("native"), "names the rejected format");
assert!(reason.contains("native_unreliable"), "names the parity");
assert!(reason.contains("text"), "names the working alternative");
}
#[test]
fn validate_tool_format_passes_through_safe_combos() {
reset();
let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3-base", "native");
assert_eq!(decision.effective, "native");
assert!(decision.correction.is_none());
let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "text");
assert_eq!(decision.effective, "text");
assert!(decision.correction.is_none());
let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "json");
assert_eq!(decision.effective, "json");
assert!(decision.correction.is_none());
}
#[test]
fn validate_tool_format_leaves_unknown_routes_and_formats_alone() {
reset();
let decision = validate_tool_format("my-proxy", "mystery-1", "native");
assert_eq!(decision.effective, "native");
assert!(decision.correction.is_none());
let decision = validate_tool_format("openrouter", "deepseek/deepseek-v3.2", "frobnicate");
assert_eq!(decision.effective, "frobnicate");
assert!(decision.correction.is_none());
}
#[test]
fn validate_tool_format_steers_off_text_on_native_only_route() {
reset();
let overrides: CapabilitiesFile = toml::from_str(
"[[provider.acme]]\n\
model_match = \"native-only-*\"\n\
native_tools = true\n\
text_tool_wire_format_supported = false\n\
tool_mode_parity = \"native_only\"\n\
preferred_tool_format = \"native\"\n",
)
.expect("override parses");
let caps = lookup_with_user_overrides("acme", "native-only-1", Some(&overrides));
let decision = validate_tool_format_with_caps("acme", "native-only-1", "text", &caps);
assert_eq!(decision.effective, "native");
let reason = decision
.correction
.expect("text on native_only is corrected");
assert!(reason.contains("native_only"));
}
#[test]
fn validate_tool_format_honors_structural_text_unsupported_bit() {
reset();
let caps = lookup("ollama", "qwen3-coder:30b");
assert!(!caps.text_tool_wire_format_supported);
for requested in ["text", "json"] {
let decision =
validate_tool_format_with_caps("ollama", "qwen3-coder:30b", requested, &caps);
assert_eq!(
decision.effective, "native",
"{requested} must be steered to native on a text-unsupported route"
);
assert!(decision.correction.is_some());
}
let native = validate_tool_format_with_caps("ollama", "qwen3-coder:30b", "native", &caps);
assert_eq!(native.effective, "native");
assert!(native.correction.is_none());
}
#[test]
fn tool_format_resolution_is_serving_stack_aware_for_same_weights() {
reset();
let llamacpp = validate_tool_format("llamacpp", "qwen3.6-35b-a3b-ud-q4-k-xl", "native");
assert_eq!(
llamacpp.effective, "native",
"llama.cpp serves qwen3.6 native"
);
assert!(llamacpp.correction.is_none());
let ollama = validate_tool_format("ollama", "qwen3.6-35b-a3b", "native");
assert_eq!(
ollama.effective, "json",
"ollama qwen3.6 must steer native -> json (server-side parser 500 leak)"
);
assert!(
ollama.correction.is_some(),
"the native->json steer must be explained, not silent"
);
let glm = validate_tool_format("deepinfra", "deepinfra/glm-5.2", "native");
assert_eq!(glm.effective, "json");
assert!(glm.correction.is_some());
}
#[test]
fn validate_tool_format_passes_through_when_no_channel_works() {
reset();
let overrides: CapabilitiesFile = toml::from_str(
"[[provider.acme]]\n\
model_match = \"no-tools-*\"\n\
native_tools = false\n\
tool_mode_parity = \"text_only\"\n\
text_tool_wire_format_supported = false\n",
)
.expect("override parses");
let caps = lookup_with_user_overrides("acme", "no-tools-1", Some(&overrides));
for requested in ["native", "text", "json"] {
let decision = validate_tool_format_with_caps("acme", "no-tools-1", requested, &caps);
assert_eq!(
decision.effective, requested,
"{requested} passes through unchanged"
);
assert!(decision.correction.is_none());
}
}
#[test]
fn validate_tool_format_autocorrects_gpt_oss_native_pin_to_text() {
reset();
for (provider, model) in [
("deepinfra", "deepinfra/openai/gpt-oss-120b"),
("sambanova", "sambanova/gpt-oss-120b"),
] {
let decision = validate_tool_format(provider, model, "native");
assert_eq!(
decision.effective, "text",
"{provider}/{model}: native must auto-correct to text"
);
let reason = decision
.correction
.unwrap_or_else(|| panic!("{provider}/{model}: a correction must be reported"));
assert!(
reason.contains("native_unreliable"),
"{provider}/{model}: names the parity"
);
assert!(
reason.contains("text"),
"{provider}/{model}: names the working alternative"
);
let text = validate_tool_format(provider, model, "text");
assert_eq!(text.effective, "text");
assert!(text.correction.is_none());
}
}
#[test]
fn validate_tool_format_autocorrects_zai_glm_native_pin_to_text() {
reset();
for model in ["glm-5.2", "glm-5.1", "glm-5"] {
let decision = validate_tool_format("zai", model, "native");
assert_eq!(
decision.effective, "text",
"zai/{model}: native must auto-correct to text"
);
let reason = decision
.correction
.unwrap_or_else(|| panic!("zai/{model}: a correction must be reported"));
assert!(
reason.contains("native_unreliable"),
"zai/{model}: names the parity"
);
}
}
#[test]
fn validate_tool_format_leaves_known_good_native_routes_unchanged() {
reset();
for (provider, model) in [
("cerebras", "gpt-oss-120b"),
("sambanova", "DeepSeek-V3.2"),
] {
let decision = validate_tool_format(provider, model, "native");
assert_eq!(
decision.effective, "native",
"{provider}/{model}: known-good native route must stay native"
);
assert!(
decision.correction.is_none(),
"{provider}/{model}: no spurious correction"
);
}
}
#[test]
fn no_viable_tool_channel_guard_fires_only_when_both_channels_forbidden() {
reset();
let overrides: CapabilitiesFile = toml::from_str(
"[[provider.acme]]\n\
model_match = \"acme/gpt-oss-stub\"\n\
native_tools = false\n\
tool_mode_parity = \"native_unreliable\"\n\
text_tool_wire_format_supported = false\n",
)
.expect("override parses");
let caps = lookup_with_user_overrides("acme", "acme/gpt-oss-stub", Some(&overrides));
let message = no_viable_tool_channel_with_caps("acme", "acme/gpt-oss-stub", &caps)
.expect("the guard must fire when neither channel works");
assert!(
message.contains("no viable tool-calling channel"),
"names the failure: {message}"
);
assert!(
message.contains("acme/gpt-oss-stub"),
"names the bad combo: {message}"
);
assert!(
message.contains("gpt-oss") && message.contains("text"),
"suggests an alternative: {message}"
);
assert!(
no_viable_tool_channel("deepinfra", "deepinfra/openai/gpt-oss-120b").is_none(),
"auto-correctable route must not trip the fail-fast guard"
);
assert!(
no_viable_tool_channel("sambanova", "sambanova/gpt-oss-120b").is_none(),
"auto-correctable route must not trip the fail-fast guard"
);
assert!(
no_viable_tool_channel("cerebras", "gpt-oss-120b").is_none(),
"healthy native route must not trip the guard"
);
let generic: CapabilitiesFile = toml::from_str(
"[[provider.acme]]\n\
model_match = \"mystery-1\"\n\
native_tools = false\n\
tool_mode_parity = \"text_only\"\n\
text_tool_wire_format_supported = false\n",
)
.expect("override parses");
let caps = lookup_with_user_overrides("acme", "mystery-1", Some(&generic));
let message = no_viable_tool_channel_with_caps("acme", "mystery-1", &caps)
.expect("guard fires on the generic no-channel route too");
assert!(
message.contains("harn provider catalog matrix"),
"{message}"
);
}
}