use super::*;
use super::{
defaults::*, json::*, output::*, reminders::*, routing::*, system_prompt::*, thinking::*,
tool_search::*,
};
pub(crate) fn extract_llm_options(
args: &[VmValue],
) -> Result<crate::llm::api::LlmCallOptions, VmError> {
use crate::llm::api::{LlmApiMode, LlmCallOptions, ToolSearchMode, ToolSearchVariant};
use crate::llm::provider::{provider_supports_defer_loading, provider_tool_search_variants};
use crate::llm::tools::{extract_deferred_tool_names, vm_tools_to_native};
let prompt = args.first().map(|a| a.display()).unwrap_or_default();
let system = args.get(1).and_then(|a| {
if matches!(a, VmValue::Nil) {
None
} else {
Some(a.display())
}
});
let explicit_options = args.get(2).and_then(|a| a.as_dict()).cloned();
let options = crate::llm::cost_route::merge_context_options(explicit_options);
let mut options = options;
apply_model_role_defaults(&mut options);
apply_active_step_defaults(&mut options);
let routing_policy = crate::llm::routing::extract_routing_policy(options.as_ref())?;
let route_policy = parse_route_policy_option(options.as_ref())?;
let mut provider = vm_resolve_provider(&options);
let mut model = vm_resolve_model(&options, &provider);
let routing_decision = resolve_route_policy(&route_policy, &provider, &model)?;
if let Some(decision) = routing_decision.as_ref() {
provider = decision.selected_provider.clone();
model = decision.selected_model.clone();
}
if let Some(policy) = routing_policy.as_ref() {
if let Some(first) = policy.chain.first() {
provider = first.provider.clone();
model = first.model.clone();
}
}
let route_fallbacks = match &route_policy {
crate::llm::api::LlmRoutePolicy::PreferenceList { .. } => routing_decision
.as_ref()
.map(|decision| {
decision
.alternatives
.iter()
.filter(|alt| !alt.selected)
.map(|alt| crate::llm::api::LlmRouteFallback {
provider: alt.provider.clone(),
model: alt.model.clone(),
})
.collect()
})
.unwrap_or_default(),
_ => Vec::new(),
};
let fallback_chain = parse_fallback_chain_option(options.as_ref());
let api_key = resolve_api_key(&provider)?;
let caps = crate::llm::capabilities::lookup(&provider, &model);
let api_mode = parse_api_mode_option(options.as_ref())?;
if enforce_responses_provider_gate(api_mode, &provider) {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
"api_mode: \"responses\" is only supported by provider \"openai\"; got provider \"{provider}\""
)))));
}
let session_id = opt_str(&options, "session_id")
.filter(|value| !value.is_empty())
.or_else(crate::agent_sessions::current_session_id);
let pending_reminders = pending_reminders_from_session(session_id.as_deref());
let rendered_reminders = render_pending_reminders(&caps, &pending_reminders);
let reminder_lifecycle = rendered_reminder_lifecycle(
session_id.as_deref(),
opt_int(&options, "_iteration").unwrap_or(0),
&pending_reminders,
&rendered_reminders,
);
let system =
compose_system_prompt_with_reminders(system, options.as_ref(), &rendered_reminders)?;
let enforce_capability_gates = !crate::llm::mock::cli_llm_mock_replay_active()
&& !crate::llm::mock::builtin_llm_mock_active();
let model_defaults = crate::llm_config::model_params(&model);
let default_float =
|key: &str| -> Option<f64> { model_defaults.get(key).and_then(|v| v.as_float()) };
let default_int =
|key: &str| -> Option<i64> { model_defaults.get(key).and_then(|v| v.as_integer()) };
let max_tokens = opt_int(&options, "max_tokens").unwrap_or(16384);
let temperature = opt_float(&options, "temperature").or_else(|| default_float("temperature"));
let top_p = opt_float(&options, "top_p").or_else(|| default_float("top_p"));
let top_k = opt_int(&options, "top_k").or_else(|| default_int("top_k"));
let logprobs = opt_bool(&options, "logprobs");
let top_logprobs = opt_int(&options, "top_logprobs");
let stop = opt_str_list(&options, "stop");
let seed = opt_int(&options, "seed");
let frequency_penalty =
opt_float(&options, "frequency_penalty").or_else(|| default_float("frequency_penalty"));
let presence_penalty =
opt_float(&options, "presence_penalty").or_else(|| default_float("presence_penalty"));
let timeout = resolve_timeout_secs(
opt_int(&options, "timeout"),
opt_int(&options, "timeout_ms"),
);
let idle_timeout = opt_int(&options, "idle_timeout").map(|t| t as u64);
let cache = opt_bool(&options, "cache");
let stream = options
.as_ref()
.and_then(|o| o.get("stream"))
.map(|v| v.is_truthy())
.unwrap_or_else(|| {
std::env::var("HARN_LLM_STREAM")
.map(|v| v != "0" && v.to_lowercase() != "false")
.unwrap_or(true)
});
let output_validation = opt_str(&options, "output_validation");
let reasoning_policy_application = crate::llm::reasoning_policy::resolve_for_llm_call(
options.as_ref(),
&provider,
&model,
&caps,
)?;
let thinking_from_reasoning_policy = reasoning_policy_application.is_some();
let policy_thinking = reasoning_policy_application
.as_ref()
.map(|application| application.thinking.clone());
let reasoning_effort = parse_reasoning_effort_option(options.as_ref())?;
let thinking_from_reasoning_effort = reasoning_effort.is_some()
&& !options
.as_ref()
.and_then(|o| o.get("thinking"))
.is_some_and(|value| value.is_truthy());
let thinking = if let Some(level) = reasoning_effort {
if options
.as_ref()
.and_then(|o| o.get("thinking"))
.is_some_and(|value| value.is_truthy())
{
return Err(thinking_error(
"reasoning_effort cannot be combined with a non-disabled thinking option",
));
}
crate::llm::api::ThinkingConfig::Effort { level }
} else if let Some(thinking) = policy_thinking {
thinking
} else {
parse_thinking_option(options.as_ref())?
};
let reasoning_effort_requires_provider_support = matches!(
thinking,
crate::llm::api::ThinkingConfig::Effort { level }
if level != crate::llm::api::ReasoningEffort::None
);
if enforce_capability_gates
&& thinking_from_reasoning_effort
&& reasoning_effort_requires_provider_support
&& !caps.reasoning_effort_supported
{
return Err(unsupported_option_error(
"reasoning_effort",
&provider,
&model,
));
}
if enforce_capability_gates {
validate_thinking_supported(
&thinking,
&provider,
&model,
&caps.thinking_modes,
if thinking_from_reasoning_effort {
"reasoning_effort"
} else if thinking_from_reasoning_policy {
"reasoning_policy"
} else {
"thinking"
},
)?;
validate_reasoning_effort_level_supported(
&thinking,
&provider,
&model,
&caps,
if thinking_from_reasoning_effort {
"reasoning_effort"
} else if thinking_from_reasoning_policy {
"reasoning_policy"
} else {
"thinking"
},
)?;
}
let mut anthropic_beta_features = parse_anthropic_beta_features_option(
options.as_ref(),
&thinking,
&provider,
&model,
enforce_capability_gates,
)?;
let response_format = opt_str(&options, "response_format");
let json_schema = parse_schema_value(
options
.as_ref()
.and_then(|o| o.get("json_schema").or_else(|| o.get("schema"))),
"json_schema",
)?;
let output_schema = parse_schema_value(
options.as_ref().and_then(|o| {
o.get("output_schema")
.or_else(|| o.get("json_schema"))
.or_else(|| o.get("schema"))
}),
"output_schema",
)?;
let output_format = parse_output_format_option(
options.as_ref(),
response_format.as_deref(),
json_schema.as_ref(),
)?;
if enforce_capability_gates {
validate_output_format_supported(&output_format, &provider, &model, &caps)?;
}
let output_schema = output_schema.or_else(|| output_format.schema().cloned());
let schema_stream_abort = match options.as_ref().and_then(|o| o.get("schema_stream_abort")) {
Some(VmValue::Bool(value)) => *value,
Some(VmValue::Nil) | None => output_schema.is_some(),
Some(other) => {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
format!(
"llm_call: `schema_stream_abort` must be a bool, got {}",
other.type_name()
),
))));
}
};
if options.as_ref().and_then(|o| o.get("transcript")).is_some() {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"llm_call / agent_loop: the `transcript` option was removed. \
Open or open-and-resume a session with agent_session_open(id) \
and pass `session_id: id` instead.",
))));
}
let messages_val = options.as_ref().and_then(|o| o.get("messages")).cloned();
let messages = if let Some(VmValue::List(msg_list)) = &messages_val {
vm_messages_to_json(msg_list)?
} else {
vec![serde_json::json!({"role": "user", "content": prompt})]
};
let messages = apply_rendered_reminder_messages(messages, &rendered_reminders);
let vision =
opt_bool(&options, "vision") || crate::llm::content::messages_contain_images(&messages)?;
let audio = option_is_enabled(options.as_ref(), "audio")
|| crate::llm::content::messages_contain_audio(&messages)?;
let pdf = option_is_enabled(options.as_ref(), "pdf")
|| crate::llm::content::messages_contain_pdf(&messages)?;
let video = option_is_enabled(options.as_ref(), "video")
|| crate::llm::content::messages_contain_videos(&messages)?;
let uses_file_ids = crate::llm::content::messages_contain_file_ids(&messages)?;
if enforce_capability_gates && vision && !caps.vision_supported {
return Err(unsupported_option_error("vision", &provider, &model));
}
if enforce_capability_gates && audio && !caps.audio {
return Err(unsupported_option_error("audio", &provider, &model));
}
if enforce_capability_gates && pdf && !caps.pdf {
return Err(unsupported_option_error("pdf", &provider, &model));
}
if enforce_capability_gates && video && !caps.video {
return Err(unsupported_option_error("video", &provider, &model));
}
if enforce_capability_gates && uses_file_ids && !caps.files_api_supported {
return Err(unsupported_option_error("files_api", &provider, &model));
}
if uses_file_ids && caps.message_wire_format == "anthropic" {
crate::llm::api::push_unique_anthropic_beta_feature(
&mut anthropic_beta_features,
crate::stdlib::files::ANTHROPIC_FILES_API_BETA,
);
}
if enforce_capability_gates && cache && !caps.prompt_caching {
return Err(unsupported_option_error("cache", &provider, &model));
}
if vision
&& !crate::llm::provider::provider_supports_image_urls(&provider, &model)
&& crate::llm::content::messages_contain_url_images(&messages)?
{
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"llm_call: this provider/model route requires image base64; url image content is not supported",
))));
}
let tools_val = options
.as_ref()
.and_then(|o| o.get("tools"))
.filter(|value| !matches!(value, VmValue::Nil))
.cloned();
let tool_format = opt_str(&options, "tool_format")
.unwrap_or_else(|| crate::llm_config::default_tool_format(&model, &provider));
if enforce_capability_gates
&& tools_val.is_some()
&& tool_format == "native"
&& !caps.native_tools
{
return Err(unsupported_option_error("tools", &provider, &model));
}
let mut native_tools = if tool_format == "native" {
if let Some(tools) = &tools_val {
Some(vm_tools_to_native(tools, &provider, &model)?)
} else {
None
}
} else {
None
};
let provider_tools = parse_provider_tools_option(options.as_ref())?;
if enforce_capability_gates && !provider_tools.is_empty() && api_mode != LlmApiMode::Responses {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"provider_tools requires api_mode: \"responses\"",
))));
}
let mut tool_search = parse_tool_search_option(options.as_ref())?;
if let Some(cfg) = tool_search.as_mut() {
let native_variants = provider_tool_search_variants(&provider, &model);
let model_based_native =
provider_supports_defer_loading(&provider, &model) && !native_variants.is_empty();
let forced = provider_overrides_force_native(options.as_ref(), &provider);
let provider_has_native = model_based_native || forced;
if cfg.variant == ToolSearchVariant::Hybrid && cfg.mode == ToolSearchMode::Native {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"tool_search: variant \"hybrid\" is client-only; set mode: \"client\" or use \"bm25\"/\"regex\" for native provider tool search",
))));
}
let effective_variants: Vec<String> = if forced && native_variants.is_empty() {
vec!["hosted".to_string(), "client".to_string()]
} else {
native_variants
};
let variant_supported = |v: &str| effective_variants.iter().any(|x| x == v);
let resolution = match cfg.mode {
ToolSearchMode::Native => {
if !provider_has_native {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
format!(
"tool_search: provider \"{provider}\" does not expose native \
tool-search for model \"{model}\". Set \
`tool_search: {{ mode: \"client\" }}` to use the client-executed \
fallback, or omit tool_search to ship tools eagerly."
),
))));
}
ToolSearchResolution::Native
}
ToolSearchMode::Client => ToolSearchResolution::Client,
ToolSearchMode::Auto => {
if cfg.variant == ToolSearchVariant::Hybrid {
ToolSearchResolution::Client
} else if provider_has_native {
ToolSearchResolution::Native
} else {
ToolSearchResolution::Client
}
}
};
if let Some(tools) = native_tools.as_ref() {
let deferred = extract_deferred_tool_names(tools);
let total_user_tools = tools.len();
if total_user_tools > 0 && deferred.len() == total_user_tools {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"tool_search: all tools have defer_loading set. At least \
one tool must be non-deferred so the model has somewhere \
to start. (Matches Anthropic's 400 on the same condition.)",
))));
}
}
match resolution {
ToolSearchResolution::Native => {
let shape = classify_native_shape(&provider, &model);
match shape {
crate::llm::provider::NativeToolSearchShape::Anthropic => {
if !variant_supported(cfg.variant.as_short()) {
crate::events::log_warn(
"llm.tool_search",
&format!(
"provider \"{provider}\" model \"{model}\" does not support \
tool_search variant \"{}\"; falling back to \"{}\"",
cfg.variant.as_short(),
effective_variants[0],
),
);
}
let effective_variant = if variant_supported(cfg.variant.as_short()) {
cfg.variant
} else {
match effective_variants[0].as_str() {
"regex" => ToolSearchVariant::Regex,
_ => ToolSearchVariant::Bm25,
}
};
crate::llm::tools::apply_tool_search_native_injection_typed(
&mut native_tools,
shape,
effective_variant.as_short(),
"hosted",
);
}
crate::llm::provider::NativeToolSearchShape::OpenAi => {
crate::llm::tools::apply_tool_search_native_injection_typed(
&mut native_tools,
shape,
cfg.variant.as_short(),
"hosted",
);
}
}
}
ToolSearchResolution::Client => {}
}
}
let tool_choice = options
.as_ref()
.and_then(|o| o.get("tool_choice"))
.filter(|value| !matches!(value, VmValue::Nil))
.map(vm_value_to_json);
if enforce_capability_gates
&& tool_choice.is_some()
&& !caps.native_tools
&& !caps.text_tool_wire_format_supported
{
return Err(unsupported_option_error("tool_choice", &provider, &model));
}
let provider_overrides = options
.as_ref()
.and_then(|o| o.get(&provider))
.and_then(|v| v.as_dict())
.map(vm_value_dict_to_json);
let previous_response_id =
opt_str(&options, "previous_response_id").filter(|value| !value.trim().is_empty());
let store = opt_responses_store_field(options.as_ref())?;
let background = opt_bool_field(options.as_ref(), "background")?;
let truncation = opt_str(&options, "truncation").filter(|value| !value.trim().is_empty());
let compact = opt_bool_field(options.as_ref(), "compact")?;
let include = opt_str_list(&options, "include");
let max_tool_calls = opt_int(&options, "max_tool_calls");
if enforce_capability_gates
&& api_mode != LlmApiMode::Responses
&& (previous_response_id.is_some()
|| store.is_some()
|| background.is_some()
|| truncation.is_some()
|| compact.is_some()
|| include.is_some()
|| max_tool_calls.is_some())
{
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
"Responses-only options require api_mode: \"responses\"",
))));
}
let prefill = options
.as_ref()
.and_then(|o| o.get("prefill"))
.and_then(|v| {
if matches!(v, VmValue::Nil) {
None
} else {
let s = v.display();
if s.is_empty() {
None
} else {
Some(s)
}
}
});
let structural_experiment =
crate::llm::structural_experiments::parse_structural_experiment_option(options.as_ref())?;
let budget = crate::llm::cost::parse_budget_envelope(options.as_ref())?;
let reminders = options
.as_ref()
.and_then(|o| o.get("reminders"))
.map(vm_value_to_json);
let fast = opt_bool(&options, "fast") || opt_str(&options, "speed").as_deref() == Some("fast");
if fast && enforce_capability_gates {
match crate::llm::fast_mode::gate(&model) {
crate::llm::fast_mode::FastModeGate::Usable => {}
crate::llm::fast_mode::FastModeGate::Unsupported => {
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
format!(
"fast: model \"{model}\" (provider \"{provider}\") has no accelerated-serving \
tier in the catalog; remove `fast` or pick a model that advertises `fast_mode`"
),
))));
}
crate::llm::fast_mode::FastModeGate::Deprecated { note } => {
let detail = note.map(|n| format!(" ({n})")).unwrap_or_default();
return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
format!(
"fast: the accelerated-serving tier for model \"{model}\" is deprecated{detail}"
),
))));
}
}
}
let opts = LlmCallOptions {
provider,
model,
api_key,
api_mode,
route_policy,
fallback_chain,
route_fallbacks,
routing_decision,
routing_policy,
session_id,
reminders,
reminder_lifecycle,
messages,
system,
transcript_summary: None,
max_tokens,
temperature,
top_p,
top_k,
logprobs,
top_logprobs,
stop,
seed,
frequency_penalty,
presence_penalty,
fast,
output_format,
response_format,
json_schema,
output_schema,
output_validation,
schema_stream_abort,
thinking,
anthropic_beta_features,
vision,
tools: tools_val,
native_tools,
provider_tools,
tool_choice,
tool_search,
cache,
timeout,
idle_timeout,
stream,
provider_overrides,
previous_response_id,
store,
background,
truncation,
compact,
include,
max_tool_calls,
budget,
prefill,
structural_experiment,
applied_structural_experiment: None,
};
validate_options(&opts);
Ok(opts)
}
pub(crate) fn opt_str_list(
options: &Option<BTreeMap<String, VmValue>>,
key: &str,
) -> Option<Vec<String>> {
let val = options.as_ref()?.get(key)?;
match val {
VmValue::List(list) => {
let strs: Vec<String> = list.iter().map(|v| v.display()).collect();
if strs.is_empty() {
None
} else {
Some(strs)
}
}
_ => None,
}
}
pub(super) fn validate_options(opts: &crate::llm::api::LlmCallOptions) {
let caps = crate::llm::capabilities::lookup(&opts.provider, &opts.model);
let warn = |param: &str| {
crate::events::log_warn(
"llm",
&format!(
"\"{param}\" is not supported by provider \"{}\" model \"{}\", ignoring",
opts.provider, opts.model
),
);
};
if opts.seed.is_some() && !caps.seed_supported {
warn("seed");
}
if opts.top_k.is_some() && !caps.top_k_supported {
warn("top_k");
}
if opts.frequency_penalty.is_some() && !caps.frequency_penalty_supported {
warn("frequency_penalty");
}
if opts.presence_penalty.is_some() && !caps.presence_penalty_supported {
warn("presence_penalty");
}
if opts.cache && !caps.prompt_caching {
warn("cache");
}
}