leviath-core 0.5.5

Core types and traits for Leviath: context regions, memory layouts, blueprints, and lifecycle policies
Documentation
//! The `[stages.<name>.model]` table: which models a stage may run on, and
//! the parameters it hands them.
//!
//! Split from `stage.rs` by concern: the stage parser reads a stage's shape,
//! this reads the one sub-table that names a model.

use super::*;

/// Parse `[stages.<name>.model]`, or the shipped default when the stage does
/// not name one.
pub(super) fn parse_stage_model(stage_value: &toml::Value) -> ModelConfig {
    let model_table = stage_value.get("model").and_then(|v| v.as_table());
    if let Some(mt) = model_table {
        let mut models = Vec::new();

        // An entry may be a bare model name, or a table naming a provider only
        // when the route matters (a local or self-hosted model). An absent
        // provider is EMPTY, not "anthropic": an author cannot know what a
        // machine has configured, and defaulting made omission a silent choice.
        if let Some(models_arr) = mt.get("models").and_then(|v| v.as_array()) {
            for entry in models_arr {
                if let Some(name) = entry.as_str() {
                    models.push(ModelEntry::new(String::new(), name.to_string()));
                    continue;
                }
                // A table naming no model names nothing, so it is dropped.
                if let Some(t) = entry.as_table()
                    && let Some(model) = t.get("model").and_then(|v| v.as_str())
                {
                    let route = t.get("provider").and_then(|v| v.as_str());
                    let route = route.unwrap_or_default().to_string();
                    models.push(ModelEntry::new(route, model.to_string()));
                }
            }
        }

        // Backward compat: old single-model format (provider + model at
        // top level) or old fallbacks list - treat both as models entries.
        if models.is_empty() {
            if let Some(provider) = mt.get("provider").and_then(|v| v.as_str()) {
                let model_name = mt
                    .get("model")
                    .and_then(|v| v.as_str())
                    .unwrap_or("claude-sonnet-4-6");
                models.push(ModelEntry::new(
                    provider.to_string(),
                    model_name.to_string(),
                ));
            }

            // Old fallbacks become additional models entries
            if let Some(fallbacks_arr) = mt.get("fallbacks").and_then(|v| v.as_array()) {
                for fb in fallbacks_arr {
                    if let Some(fb_table) = fb.as_table() {
                        models.push(ModelEntry::new(
                            fb_table
                                .get("provider")
                                .and_then(|v| v.as_str())
                                .unwrap_or("anthropic")
                                .to_string(),
                            fb_table
                                .get("model")
                                .and_then(|v| v.as_str())
                                .unwrap_or("claude-sonnet-4-6")
                                .to_string(),
                        ));
                    }
                }
            }
        }

        // If still empty, use defaults
        if models.is_empty() {
            models.push(ModelEntry::new(
                "anthropic".to_string(),
                "claude-sonnet-4-6".to_string(),
            ));
        }

        let allow_user_default = mt
            .get("allow_user_default")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);

        // Parse parameters
        let mut parameters = std::collections::HashMap::new();
        if let Some(params) = mt.get("parameters").and_then(|v| v.as_table()) {
            for (k, v) in params {
                // Converting a parsed `toml::Value` to JSON is infallible:
                // serde_json maps non-finite floats to null rather than
                // erroring, and every other toml scalar/collection maps
                // cleanly.
                let json_val = serde_json::to_value(v)
                    .expect("infallible: toml::Value always converts to serde_json::Value");
                parameters.insert(k.clone(), json_val);
            }
        }

        let request_timeout_secs = mt.get("request_timeout_secs").and_then(|v| v.as_integer());
        let request_timeout_secs = request_timeout_secs
            .filter(|&secs| secs >= 0)
            .map(|secs| secs as u64);

        ModelConfig {
            models,
            allow_user_default,
            parameters,
            request_timeout_secs,
        }
    } else {
        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
    }
}