use super::extract::*;
use super::*;
use crate::llm_config::{AliasDef, AuthEnv, ProviderDef, ProvidersConfig, TierRule};
fn install_test_routes() {
let mut overlay = ProvidersConfig::default();
overlay.providers.insert(
"cheap".to_string(),
ProviderDef {
base_url: "https://cheap.example/v1".to_string(),
auth_style: "none".to_string(),
auth_env: AuthEnv::None,
chat_endpoint: "/chat/completions".to_string(),
cost_per_1k_in: Some(0.0),
cost_per_1k_out: Some(0.0),
latency_p50_ms: Some(2200),
..Default::default()
},
);
overlay.providers.insert(
"fast".to_string(),
ProviderDef {
base_url: "https://fast.example/v1".to_string(),
auth_style: "none".to_string(),
auth_env: AuthEnv::None,
chat_endpoint: "/chat/completions".to_string(),
cost_per_1k_in: Some(0.01),
cost_per_1k_out: Some(0.02),
latency_p50_ms: Some(250),
..Default::default()
},
);
overlay.aliases.insert(
"cheap-mid".to_string(),
AliasDef {
id: "cheap-mid-model".to_string(),
provider: "cheap".to_string(),
tool_format: None,
},
);
overlay.aliases.insert(
"fast-mid".to_string(),
AliasDef {
id: "fast-mid-model".to_string(),
provider: "fast".to_string(),
tool_format: None,
},
);
overlay.tier_rules.push(TierRule {
exact: Some("cheap-mid-model".to_string()),
pattern: None,
contains: None,
tier: "mid".to_string(),
});
overlay.tier_rules.push(TierRule {
exact: Some("fast-mid-model".to_string()),
pattern: None,
contains: None,
tier: "mid".to_string(),
});
crate::llm_config::set_user_overrides(Some(overlay));
super::super::reset_provider_key_cache();
}
fn extract_with_policy(policy: &str) -> crate::llm::api::LlmCallOptions {
let mut options = BTreeMap::new();
options.insert(
"route_policy".to_string(),
VmValue::String(std::sync::Arc::from(policy.to_string())),
);
options.insert(
"fallback_chain".to_string(),
VmValue::List(std::sync::Arc::new(vec![VmValue::String(
std::sync::Arc::from("fast".to_string()),
)])),
);
extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options")
}
#[test]
fn cheapest_over_quality_selects_lowest_cost_available_candidate() {
install_test_routes();
let opts = extract_with_policy("cheapest_over_quality(mid)");
assert_eq!(opts.provider, "cheap");
assert_eq!(opts.model, "cheap-mid-model");
assert_eq!(opts.fallback_chain, vec!["fast".to_string()]);
let decision = opts.routing_decision.expect("routing decision");
assert!(decision.alternatives.iter().any(|alt| alt.selected));
assert!(decision
.alternatives
.iter()
.any(|alt| alt.provider == "fast"));
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
fn extract_with_options(
opts: BTreeMap<String, VmValue>,
) -> Result<crate::llm::api::LlmCallOptions, VmError> {
extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(opts)),
])
}
fn model_role_options(role: &str) -> BTreeMap<String, VmValue> {
BTreeMap::from([(
"model_role".to_string(),
VmValue::String(std::sync::Arc::from(role.to_string())),
)])
}
fn clear_merge_role_env() {
std::env::remove_var("HARN_LLM_MERGE_PROVIDER");
std::env::remove_var("HARN_LLM_MERGE_MODEL");
std::env::remove_var("HARN_LLM_MERGE_ROUTE_POLICY");
std::env::remove_var("HARN_LLM_ROLE_MERGE_PROVIDER");
std::env::remove_var("HARN_LLM_ROLE_MERGE_MODEL");
std::env::remove_var("HARN_LLM_ROLE_MERGE_ROUTE_POLICY");
std::env::remove_var("HARN_LLM_FAST_APPLY_PROVIDER");
std::env::remove_var("HARN_LLM_FAST_APPLY_MODEL");
std::env::remove_var("HARN_LLM_FAST_APPLY_ROUTE_POLICY");
std::env::remove_var("HARN_LLM_ROLE_FAST_APPLY_PROVIDER");
std::env::remove_var("HARN_LLM_ROLE_FAST_APPLY_MODEL");
std::env::remove_var("HARN_LLM_ROLE_FAST_APPLY_ROUTE_POLICY");
}
#[test]
fn model_role_defaults_fill_missing_llm_options() {
let _guard = crate::llm::env_lock().lock().unwrap();
clear_merge_role_env();
let mut overlay = ProvidersConfig::default();
overlay.model_roles.insert(
"merge".to_string(),
BTreeMap::from([
(
"provider".to_string(),
toml::Value::String("mock".to_string()),
),
(
"model".to_string(),
toml::Value::String("mock-merge".to_string()),
),
("max_tokens".to_string(), toml::Value::Integer(4096)),
("temperature".to_string(), toml::Value::Float(0.0)),
]),
);
crate::llm_config::set_user_overrides(Some(overlay));
super::super::reset_provider_key_cache();
let opts = extract_with_options(model_role_options("merge")).expect("options");
assert_eq!(opts.provider, "mock");
assert_eq!(opts.model, "mock-merge");
assert_eq!(opts.max_tokens, 4096);
assert_eq!(opts.temperature, Some(0.0));
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
super::super::reset_provider_key_cache();
}
#[test]
fn explicit_options_win_over_model_role_defaults() {
let _guard = crate::llm::env_lock().lock().unwrap();
clear_merge_role_env();
let mut overlay = ProvidersConfig::default();
overlay.model_roles.insert(
"merge".to_string(),
BTreeMap::from([
(
"provider".to_string(),
toml::Value::String("mock".to_string()),
),
(
"model".to_string(),
toml::Value::String("mock-merge".to_string()),
),
("max_tokens".to_string(), toml::Value::Integer(4096)),
]),
);
crate::llm_config::set_user_overrides(Some(overlay));
super::super::reset_provider_key_cache();
let mut options = model_role_options("merge");
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from("mock-explicit".to_string())),
);
options.insert("max_tokens".to_string(), VmValue::Int(512));
let opts = extract_with_options(options).expect("options");
assert_eq!(opts.provider, "mock");
assert_eq!(opts.model, "mock-explicit");
assert_eq!(opts.max_tokens, 512);
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
super::super::reset_provider_key_cache();
}
#[test]
fn merge_model_role_has_env_overrides() {
let _guard = crate::llm::env_lock().lock().unwrap();
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
std::env::set_var("HARN_LLM_MERGE_PROVIDER", "mock");
std::env::set_var("HARN_LLM_MERGE_MODEL", "mock-env-merge");
super::super::reset_provider_key_cache();
let opts = extract_with_options(model_role_options("merge")).expect("options");
assert_eq!(opts.provider, "mock");
assert_eq!(opts.model, "mock-env-merge");
clear_merge_role_env();
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn model_role_aliases_do_not_override_exact_role_defaults() {
let _guard = crate::llm::env_lock().lock().unwrap();
clear_merge_role_env();
let mut overlay = ProvidersConfig::default();
overlay.model_roles.insert(
"fast_apply".to_string(),
BTreeMap::from([
(
"provider".to_string(),
toml::Value::String("mock".to_string()),
),
(
"model".to_string(),
toml::Value::String("mock-fast-apply".to_string()),
),
]),
);
overlay.model_roles.insert(
"merge".to_string(),
BTreeMap::from([
(
"provider".to_string(),
toml::Value::String("mock".to_string()),
),
(
"model".to_string(),
toml::Value::String("mock-merge".to_string()),
),
]),
);
crate::llm_config::set_user_overrides(Some(overlay));
super::super::reset_provider_key_cache();
let merge_opts = extract_with_options(model_role_options("merge")).expect("merge options");
let fast_apply_opts =
extract_with_options(model_role_options("fast_apply")).expect("fast_apply options");
assert_eq!(merge_opts.model, "mock-merge");
assert_eq!(fast_apply_opts.model, "mock-fast-apply");
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
super::super::reset_provider_key_cache();
}
#[test]
fn model_role_env_aliases_do_not_override_exact_role_env() {
let _guard = crate::llm::env_lock().lock().unwrap();
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
std::env::set_var("HARN_LLM_FAST_APPLY_PROVIDER", "mock");
std::env::set_var("HARN_LLM_FAST_APPLY_MODEL", "mock-env-fast-apply");
std::env::set_var("HARN_LLM_MERGE_PROVIDER", "mock");
std::env::set_var("HARN_LLM_MERGE_MODEL", "mock-env-merge");
super::super::reset_provider_key_cache();
let merge_opts = extract_with_options(model_role_options("merge")).expect("merge options");
let fast_apply_opts =
extract_with_options(model_role_options("fast_apply")).expect("fast_apply options");
assert_eq!(merge_opts.model, "mock-env-merge");
assert_eq!(fast_apply_opts.model, "mock-env-fast-apply");
clear_merge_role_env();
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn model_role_config_keys_normalize_like_call_options() {
let _guard = crate::llm::env_lock().lock().unwrap();
clear_merge_role_env();
let mut overlay = ProvidersConfig::default();
overlay.model_roles.insert(
"fast-apply".to_string(),
BTreeMap::from([
(
"provider".to_string(),
toml::Value::String("mock".to_string()),
),
(
"model".to_string(),
toml::Value::String("mock-fast-apply".to_string()),
),
]),
);
crate::llm_config::set_user_overrides(Some(overlay));
super::super::reset_provider_key_cache();
let opts = extract_with_options(model_role_options("fast_apply")).expect("options");
assert_eq!(opts.provider, "mock");
assert_eq!(opts.model, "mock-fast-apply");
crate::llm_config::clear_user_overrides();
clear_merge_role_env();
super::super::reset_provider_key_cache();
}
fn fast_options(model: &str) -> BTreeMap<String, VmValue> {
let mut options = BTreeMap::new();
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from(model.to_string())),
);
options.insert("fast".to_string(), VmValue::Bool(true));
options
}
#[test]
fn fast_opts_into_tier_for_supported_model_and_guards_others() {
let _guard = crate::llm::env_lock().lock().unwrap();
crate::llm_config::clear_user_overrides();
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
std::env::set_var("OPENAI_API_KEY", "test-key");
super::super::reset_provider_key_cache();
match extract_with_options(fast_options("claude-opus-4-8")) {
Ok(opus) => assert!(opus.fast, "fast must be set for a model with a usable tier"),
Err(e) => panic!("opus fast should succeed: {e:?}"),
}
match extract_with_options(fast_options("gpt-4o")) {
Err(VmError::Thrown(VmValue::String(message))) => {
assert!(message.contains("no accelerated-serving tier"), "{message}");
}
other => panic!("expected thrown error for gpt-4o, got {:?}", other.is_ok()),
}
match extract_with_options(fast_options("claude-opus-4-6")) {
Err(VmError::Thrown(VmValue::String(message))) => {
assert!(message.contains("deprecated"), "{message}");
}
other => panic!(
"expected thrown error for opus 4.6, got {:?}",
other.is_ok()
),
}
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("OPENAI_API_KEY");
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn fastest_over_quality_selects_lowest_latency_available_candidate() {
install_test_routes();
let opts = extract_with_policy("fastest_over_quality(mid)");
assert_eq!(opts.provider, "fast");
assert_eq!(opts.model, "fast-mid-model");
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn preference_list_cheapest_first_sets_route_fallbacks() {
install_test_routes();
let mut policy = BTreeMap::new();
policy.insert(
"mode".to_string(),
VmValue::String(std::sync::Arc::from("preference_list".to_string())),
);
policy.insert(
"strategy".to_string(),
VmValue::String(std::sync::Arc::from("cheapest_first".to_string())),
);
policy.insert(
"prefer".to_string(),
VmValue::List(std::sync::Arc::new(vec![
VmValue::String(std::sync::Arc::from("fast-mid")),
VmValue::String(std::sync::Arc::from("cheap-mid")),
])),
);
let mut options = BTreeMap::new();
options.insert(
"route_policy".to_string(),
VmValue::Dict(std::sync::Arc::new(policy)),
);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options");
assert_eq!(opts.provider, "cheap");
assert_eq!(opts.model, "cheap-mid-model");
assert_eq!(opts.route_fallbacks.len(), 1);
assert_eq!(opts.route_fallbacks[0].provider, "fast");
assert_eq!(opts.route_fallbacks[0].model, "fast-mid-model");
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn always_policy_accepts_provider_model_selector() {
install_test_routes();
let opts = extract_with_policy("always(fast:fast-mid-model)");
assert_eq!(opts.provider, "fast");
assert_eq!(opts.model, "fast-mid-model");
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
}
#[test]
fn thinking_dict_enabled_false_disables_thinking() {
let mut options = BTreeMap::new();
options.insert(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
);
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-5.4".to_string())),
);
options.insert(
"thinking".to_string(),
VmValue::Dict(std::sync::Arc::new(BTreeMap::from([(
"enabled".to_string(),
VmValue::Bool(false),
)]))),
);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options");
assert!(opts.thinking.is_disabled());
}
#[test]
fn thinking_dict_enabled_budget_parses_typed_config() {
let mut options = BTreeMap::new();
options.insert(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
);
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from("claude-opus-4-6".to_string())),
);
options.insert(
"thinking".to_string(),
VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"mode".to_string(),
VmValue::String(std::sync::Arc::from("enabled".to_string())),
),
("budget_tokens".to_string(), VmValue::Int(8000)),
]))),
);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Enabled {
budget_tokens: Some(8000)
}
);
assert_eq!(
opts.anthropic_beta_features,
vec![crate::llm::providers::anthropic::ANTHROPIC_INTERLEAVED_THINKING_BETA]
);
}
#[test]
fn anthropic_beta_features_parse_and_dedupe_with_interleaved_flag() {
let mut options = BTreeMap::new();
options.insert(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
);
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from("claude-opus-4-6".to_string())),
);
options.insert(
"anthropic_beta_features".to_string(),
VmValue::List(std::sync::Arc::new(vec![
VmValue::String(std::sync::Arc::from(
"fine-grained-tool-streaming-2025-05-14",
)),
VmValue::String(std::sync::Arc::from(
crate::llm::providers::anthropic::ANTHROPIC_INTERLEAVED_THINKING_BETA,
)),
])),
);
options.insert("interleaved_thinking".to_string(), VmValue::Bool(true));
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options");
assert_eq!(
opts.anthropic_beta_features,
vec![
"fine-grained-tool-streaming-2025-05-14".to_string(),
crate::llm::providers::anthropic::ANTHROPIC_INTERLEAVED_THINKING_BETA.to_string(),
]
);
}
#[test]
fn anthropic_beta_features_reject_invalid_header_names() {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("claude-opus-4-6".to_string())),
),
(
"anthropic_beta_features".to_string(),
VmValue::String(std::sync::Arc::from("bad\r\nheader".to_string())),
),
]);
let err = match extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
]) {
Ok(_) => panic!("invalid beta feature should fail before transport"),
Err(err) => err,
};
assert!(err.to_string().contains("invalid beta feature name `bad"));
}
#[test]
fn thinking_effort_parses_typed_level() {
let mut options = BTreeMap::new();
options.insert(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
);
options.insert(
"model".to_string(),
VmValue::String(std::sync::Arc::from("o3".to_string())),
);
options.insert(
"thinking".to_string(),
VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"mode".to_string(),
VmValue::String(std::sync::Arc::from("effort".to_string())),
),
(
"level".to_string(),
VmValue::String(std::sync::Arc::from("high".to_string())),
),
]))),
);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("options");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::High
}
);
}
fn unsupported_local_options(extra: Vec<(&str, VmValue)>) -> VmError {
let mut options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("local".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("unsupported-model".to_string())),
),
]);
for (key, value) in extra {
options.insert(key.to_string(), value);
}
match extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
]) {
Ok(_) => panic!("unsupported option should fail"),
Err(err) => err,
}
}
fn assert_unsupported_local_option(option: &str, extra: Vec<(&str, VmValue)>) {
crate::llm::capabilities::clear_user_overrides();
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
let err = unsupported_local_options(extra);
assert!(
err.to_string().contains(&format!(
"option `{option}` is not supported by `unsupported-model` (provider `local`). See `harn providers matrix` for compatibility."
)),
"unexpected error for {option}: {err}"
);
}
fn one_tool_list() -> VmValue {
VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
std::sync::Arc::new(BTreeMap::from([
(
"name".to_string(),
VmValue::String(std::sync::Arc::from("lookup")),
),
(
"description".to_string(),
VmValue::String(std::sync::Arc::from("Look something up")),
),
(
"parameters".to_string(),
VmValue::Dict(std::sync::Arc::new(BTreeMap::new())),
),
])),
)]))
}
struct ScopedEnvVar {
key: &'static str,
previous: Option<String>,
}
impl ScopedEnvVar {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
super::super::reset_provider_key_cache();
Self { key, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
super::super::reset_provider_key_cache();
}
}
#[test]
fn unsupported_capability_options_error_with_provider_matrix_hint() {
assert_unsupported_local_option("thinking", vec![("thinking", VmValue::Bool(true))]);
assert_unsupported_local_option(
"output_format",
vec![(
"output_format",
VmValue::String(std::sync::Arc::from("json_object".to_string())),
)],
);
assert_unsupported_local_option(
"tools",
vec![
(
"tool_format",
VmValue::String(std::sync::Arc::from("native".to_string())),
),
("tools", one_tool_list()),
],
);
assert_unsupported_local_option("cache", vec![("cache", VmValue::Bool(true))]);
assert_unsupported_local_option("vision", vec![("vision", VmValue::Bool(true))]);
assert_unsupported_local_option("audio", vec![("audio", VmValue::Bool(true))]);
assert_unsupported_local_option("pdf", vec![("pdf", VmValue::Bool(true))]);
assert_unsupported_local_option("video", vec![("video", VmValue::Bool(true))]);
assert_unsupported_local_option(
"reasoning_effort",
vec![(
"reasoning_effort",
VmValue::String(std::sync::Arc::from("high".to_string())),
)],
);
assert_unsupported_local_option(
"interleaved_thinking",
vec![("interleaved_thinking", VmValue::Bool(true))],
);
}
#[test]
fn tool_choice_accepted_on_text_tool_routes() {
crate::llm::capabilities::clear_user_overrides();
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("ollama".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("devstral-small-2:24b".to_string())),
),
(
"tool_choice".to_string(),
VmValue::String(std::sync::Arc::from("none".to_string())),
),
]);
extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("tool_choice accepted on text-format routes");
}
#[test]
fn text_tool_format_does_not_emit_native_provider_tools() {
crate::llm::capabilities::clear_user_overrides();
crate::llm_config::clear_user_overrides();
super::super::reset_provider_key_cache();
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("ollama".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("devstral-small-2:24b".to_string())),
),
(
"tool_format".to_string(),
VmValue::String(std::sync::Arc::from("text".to_string())),
),
("tools".to_string(), one_tool_list()),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("text-format tools accepted");
assert!(opts.tools.is_some());
assert!(opts.native_tools.is_none());
}
#[test]
fn standalone_reasoning_effort_maps_to_thinking_effort_when_supported() {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("o3".to_string())),
),
(
"reasoning_effort".to_string(),
VmValue::String(std::sync::Arc::from("high".to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("supported reasoning_effort");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::High
}
);
}
#[test]
fn standalone_reasoning_effort_accepts_minimal_when_supported() {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("o3".to_string())),
),
(
"reasoning_effort".to_string(),
VmValue::String(std::sync::Arc::from("minimal".to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("minimal reasoning_effort");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::Minimal
}
);
}
#[test]
fn reasoning_policy_maps_to_provider_aware_thinking_when_explicit() {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-5.5".to_string())),
),
(
"reasoning_policy".to_string(),
VmValue::String(std::sync::Arc::from("off".to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("reasoning_policy");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::None
}
);
}
#[test]
fn session_pinned_reasoning_policy_is_llm_call_default() {
crate::agent_sessions::reset_session_store();
let session_id =
crate::agent_sessions::open_or_create(Some("reasoning-policy-options-session".to_string()));
crate::agent_sessions::set_pinned_reasoning_policy(&session_id, Some("high".to_string()))
.expect("set policy");
let _session_guard = crate::agent_sessions::enter_current_session(session_id);
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("o3".to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("session reasoning_policy");
drop(_session_guard);
crate::agent_sessions::reset_session_store();
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::High
}
);
}
#[test]
fn explicit_thinking_wins_over_session_pinned_reasoning_policy() {
crate::agent_sessions::reset_session_store();
let session_id = crate::agent_sessions::open_or_create(Some(
"reasoning-policy-explicit-session".to_string(),
));
crate::agent_sessions::set_pinned_reasoning_policy(&session_id, Some("high".to_string()))
.expect("set policy");
let _session_guard = crate::agent_sessions::enter_current_session(session_id);
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("o3".to_string())),
),
("thinking".to_string(), VmValue::Bool(false)),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("explicit thinking");
drop(_session_guard);
crate::agent_sessions::reset_session_store();
assert!(opts.thinking.is_disabled());
}
#[test]
fn standalone_reasoning_effort_accepts_none_and_xhigh_when_supported() {
for (raw, expected) in [
("none", crate::llm::api::ReasoningEffort::None),
("xhigh", crate::llm::api::ReasoningEffort::XHigh),
] {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-5.5".to_string())),
),
(
"reasoning_effort".to_string(),
VmValue::String(std::sync::Arc::from(raw.to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("reasoning_effort");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort { level: expected }
);
}
}
#[test]
fn standalone_reasoning_effort_rejects_cerebras_gpt_oss_unsupported_levels() {
let _cerebras_key = ScopedEnvVar::set("CEREBRAS_API_KEY", "test-key");
for (key, value) in [
(
"reasoning_effort",
VmValue::String(std::sync::Arc::from("none".to_string())),
),
(
"thinking",
VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"mode".to_string(),
VmValue::String(std::sync::Arc::from("effort".to_string())),
),
(
"level".to_string(),
VmValue::String(std::sync::Arc::from("minimal".to_string())),
),
]))),
),
] {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("cerebras".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-oss-120b".to_string())),
),
(key.to_string(), value),
]);
let err = match extract_with_options(options) {
Ok(_) => panic!("unsupported reasoning effort should fail before transport"),
Err(err) => err,
};
let message = err.to_string();
assert!(
message.contains("supported reasoning_effort values: low, medium, high"),
"unexpected error: {message}"
);
}
}
#[test]
fn standalone_reasoning_effort_none_disables_thinking_without_effort_capability() {
let options = BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("local".to_string())),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("no-effort-model".to_string())),
),
(
"reasoning_effort".to_string(),
VmValue::String(std::sync::Arc::from("none".to_string())),
),
]);
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("hello".to_string())),
VmValue::Nil,
VmValue::Dict(std::sync::Arc::new(options)),
])
.expect("reasoning_effort none should be universally accepted");
assert_eq!(
opts.thinking,
crate::llm::api::ThinkingConfig::Effort {
level: crate::llm::api::ReasoningEffort::None
}
);
}
#[test]
fn standalone_reasoning_effort_uses_dedicated_capability_gate() {
crate::llm::capabilities::set_user_overrides_toml(
r#"
[[provider.local]]
model_match = "thinking-effort-only"
thinking_modes = ["effort"]
"#,
)
.expect("capability override");
super::super::reset_provider_key_cache();
let err = unsupported_local_options(vec![
(
"model",
VmValue::String(std::sync::Arc::from("thinking-effort-only".to_string())),
),
(
"reasoning_effort",
VmValue::String(std::sync::Arc::from("high".to_string())),
),
]);
assert!(
err.to_string()
.contains("option `reasoning_effort` is not supported"),
"unexpected error: {err}"
);
crate::llm::capabilities::clear_user_overrides();
}
#[test]
fn image_content_sets_vision_and_requires_capability() {
let image_block = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"type".to_string(),
VmValue::String(std::sync::Arc::from("image")),
),
(
"base64".to_string(),
VmValue::String(std::sync::Arc::from("iVBORw0KGgo=")),
),
(
"media_type".to_string(),
VmValue::String(std::sync::Arc::from("image/png")),
),
])));
let message = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"role".to_string(),
VmValue::String(std::sync::Arc::from("user")),
),
(
"content".to_string(),
VmValue::List(std::sync::Arc::new(vec![image_block])),
),
])));
let options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-4o")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message.clone()])),
),
])));
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
options,
])
.unwrap();
assert!(opts.vision);
let bad_options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-3.5-turbo")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message])),
),
])));
let err = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
bad_options,
])
.err()
.expect("non-vision model should reject image content");
assert!(err.to_string().contains("option `vision` is not supported"));
let url_image = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"type".to_string(),
VmValue::String(std::sync::Arc::from("image")),
),
(
"url".to_string(),
VmValue::String(std::sync::Arc::from("https://example.com/image.png")),
),
(
"media_type".to_string(),
VmValue::String(std::sync::Arc::from("image/png")),
),
])));
let url_message = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"role".to_string(),
VmValue::String(std::sync::Arc::from("user")),
),
(
"content".to_string(),
VmValue::List(std::sync::Arc::new(vec![url_image])),
),
])));
let ollama_options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("ollama")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("llava:latest")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![url_message])),
),
])));
let err = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
ollama_options,
])
.err()
.expect("ollama should reject url image content");
assert!(err.to_string().contains("requires image base64"));
}
#[test]
fn pdf_and_audio_content_require_capabilities() {
let pdf_block = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"type".to_string(),
VmValue::String(std::sync::Arc::from("pdf")),
),
(
"file_id".to_string(),
VmValue::String(std::sync::Arc::from("file_123")),
),
])));
let audio_block = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"type".to_string(),
VmValue::String(std::sync::Arc::from("audio")),
),
(
"base64".to_string(),
VmValue::String(std::sync::Arc::from("UklGRg==")),
),
(
"media_type".to_string(),
VmValue::String(std::sync::Arc::from("audio/wav")),
),
])));
let message = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"role".to_string(),
VmValue::String(std::sync::Arc::from("user")),
),
(
"content".to_string(),
VmValue::List(std::sync::Arc::new(vec![pdf_block, audio_block])),
),
])));
let options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("claude-sonnet-4-7")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message.clone()])),
),
])));
let opts = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
options,
])
.unwrap();
assert!(opts
.anthropic_beta_features
.contains(&crate::stdlib::files::ANTHROPIC_FILES_API_BETA.to_string()));
let bad_options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-3.5-turbo")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message])),
),
])));
let err = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
bad_options,
])
.err()
.expect("non-multimodal model should reject pdf/audio content");
assert!(err.to_string().contains("option `audio` is not supported"));
}
#[test]
fn video_content_requires_capability() {
crate::llm::capabilities::set_user_overrides_toml(
r#"
[[provider.local]]
model_match = "video-model"
video_supported = true
"#,
)
.expect("capability override");
let video_block = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"type".to_string(),
VmValue::String(std::sync::Arc::from("video")),
),
(
"base64".to_string(),
VmValue::String(std::sync::Arc::from("AAAA")),
),
(
"media_type".to_string(),
VmValue::String(std::sync::Arc::from("video/mp4")),
),
])));
let message = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"role".to_string(),
VmValue::String(std::sync::Arc::from("user")),
),
(
"content".to_string(),
VmValue::List(std::sync::Arc::new(vec![video_block])),
),
])));
let options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("local")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("video-model")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message.clone()])),
),
])));
extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
options,
])
.expect("video-capable route should accept video content");
crate::llm::capabilities::clear_user_overrides();
let bad_options = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
(
"provider".to_string(),
VmValue::String(std::sync::Arc::from("mock")),
),
(
"model".to_string(),
VmValue::String(std::sync::Arc::from("gpt-4o")),
),
(
"messages".to_string(),
VmValue::List(std::sync::Arc::new(vec![message])),
),
])));
let err = extract_llm_options(&[
VmValue::String(std::sync::Arc::from("")),
VmValue::Nil,
bad_options,
])
.err()
.expect("non-video model should reject video content");
assert!(err.to_string().contains("option `video` is not supported"));
}