mobius-gateway 0.9.14

Headless authenticated gateway for möbius frontends
Documentation
//! Gateway composition registry for core-owned middleware manifests.

use std::collections::{BTreeMap, BTreeSet};

use mobius::middleware::manifest::MiddlewareManifest;
use mobius::protocol::{FrontendSettingValue, MiddlewareFeature, ModelChoice};

use crate::wire::MiddlewareConfig;
use crate::{Error, Result};

#[derive(Clone, Copy)]
pub(crate) enum BuiltinMiddleware {
    Sandbox,
    Attachments,
    Artifacts,
    Tools,
    Instructions,
    Cron,
    Extensions,
    Tasks,
    Subagents,
    Steering,
    ContextOffloading,
    Compaction,
    Scratchpad,
    Sessions,
}

pub(crate) struct MiddlewareRegistration {
    pub(crate) kind: BuiltinMiddleware,
    pub(crate) manifest: &'static MiddlewareManifest,
}

pub(crate) const MIDDLEWARE: [MiddlewareRegistration; 14] = [
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Sandbox,
        manifest: &mobius::backend::sandbox::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Attachments,
        manifest: &mobius::middleware::attachments::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Artifacts,
        manifest: &mobius::middleware::artifacts::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Tools,
        manifest: &mobius::middleware::tools::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Instructions,
        manifest: &mobius::middleware::instructions::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Cron,
        manifest: &mobius::middleware::cron::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Extensions,
        manifest: &mobius::middleware::extensions::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Tasks,
        manifest: &mobius::middleware::tasks::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Subagents,
        manifest: &mobius::middleware::subagents::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Steering,
        manifest: &mobius::middleware::steering::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::ContextOffloading,
        manifest: &mobius::middleware::context_offloading::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Compaction,
        manifest: &mobius::middleware::compaction::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Scratchpad,
        manifest: &mobius::middleware::scratchpad::MANIFEST,
    },
    MiddlewareRegistration {
        kind: BuiltinMiddleware::Sessions,
        manifest: &mobius::middleware::sessions::MANIFEST,
    },
];

pub(crate) fn features(models: &[ModelChoice]) -> Vec<MiddlewareFeature> {
    MIDDLEWARE
        .iter()
        .map(|entry| entry.manifest.feature(models))
        .collect()
}

pub(crate) fn default_config() -> MiddlewareConfig {
    let mut config = MiddlewareConfig {
        enabled: BTreeSet::new(),
        settings: BTreeMap::new(),
    };
    for entry in &MIDDLEWARE {
        let manifest = entry.manifest;
        if !manifest.required {
            config.set_enabled(manifest.id, manifest.default_enabled);
        }
        for setting in manifest.settings {
            config.set_setting(manifest.id, setting.id(), setting.default_value());
        }
    }
    config
}

pub(crate) fn validate(config: &MiddlewareConfig) -> Result<()> {
    for id in config.entries() {
        let manifest = definition(id)?.manifest;
        if manifest.required {
            return Err(Error::Config(format!(
                "required middleware `{id}` cannot be configured"
            )));
        }
    }
    for (middleware_id, settings) in &config.settings {
        let manifest = definition(middleware_id)?.manifest;
        if manifest.settings.is_empty() {
            return Err(Error::Config(format!(
                "middleware `{middleware_id}` has no settings"
            )));
        }
        for setting_id in settings.keys() {
            if !manifest
                .settings
                .iter()
                .any(|setting| setting.id() == setting_id)
            {
                return Err(Error::Config(format!(
                    "unknown setting `{middleware_id}.{setting_id}`"
                )));
            }
        }
    }
    for entry in &MIDDLEWARE {
        for setting in entry.manifest.settings {
            setting.validate(
                entry.manifest.id,
                config.setting(entry.manifest.id, setting.id()),
            )?;
        }
    }
    if integer_setting(config, "subagents", "max_agents")?
        < integer_setting(config, "subagents", "max_concurrency")?
    {
        return Err(Error::Config(
            "middleware setting `subagents.max_agents` must be at least `subagents.max_concurrency`"
                .into(),
        ));
    }
    Ok(())
}

pub(crate) fn validate_choices(config: &MiddlewareConfig, models: &[ModelChoice]) -> Result<()> {
    for entry in &MIDDLEWARE {
        for setting in entry.manifest.settings {
            setting.validate_choice(
                entry.manifest.id,
                config.setting(entry.manifest.id, setting.id()),
                models,
            )?;
        }
    }
    Ok(())
}

pub(crate) fn configured_model_routes(
    config: &MiddlewareConfig,
) -> Vec<(&'static str, &'static str, &str)> {
    MIDDLEWARE
        .iter()
        .flat_map(|entry| {
            entry.manifest.settings.iter().filter_map(|setting| {
                if !setting.uses_model_routes() {
                    return None;
                }
                let FrontendSettingValue::String(route) =
                    config.setting(entry.manifest.id, setting.id())?
                else {
                    return None;
                };
                Some((entry.manifest.id, setting.id(), route.as_str()))
            })
        })
        .collect()
}

pub(crate) fn integer_setting(
    config: &MiddlewareConfig,
    middleware: &str,
    setting: &str,
) -> Result<i64> {
    match config.setting(middleware, setting) {
        Some(FrontendSettingValue::Integer(value)) => Ok(*value),
        Some(FrontendSettingValue::String(_)) => Err(setting_type(middleware, setting, "integer")),
        None => Err(Error::Config(format!(
            "missing middleware setting `{middleware}.{setting}`"
        ))),
    }
}

pub(crate) fn usize_setting(
    config: &MiddlewareConfig,
    middleware: &str,
    setting: &str,
) -> Result<usize> {
    usize::try_from(integer_setting(config, middleware, setting)?).map_err(|_| {
        Error::Config(format!(
            "middleware setting `{middleware}.{setting}` must fit an unsigned integer"
        ))
    })
}

pub(crate) fn string_setting<'a>(
    config: &'a MiddlewareConfig,
    middleware: &str,
    setting: &str,
) -> Result<Option<&'a str>> {
    match config.setting(middleware, setting) {
        Some(FrontendSettingValue::String(value)) => Ok(Some(value)),
        Some(FrontendSettingValue::Integer(_)) => Err(setting_type(middleware, setting, "string")),
        None => Ok(None),
    }
}

fn definition(id: &str) -> Result<&'static MiddlewareRegistration> {
    MIDDLEWARE
        .iter()
        .find(|entry| entry.manifest.id == id)
        .ok_or_else(|| Error::Config(format!("unknown middleware `{id}`")))
}

fn setting_type(middleware: &str, setting: &str, expected: &str) -> Error {
    Error::Config(format!(
        "middleware setting `{middleware}.{setting}` must be {expected}"
    ))
}

#[cfg(test)]
mod tests {
    use mobius::middleware::context_offloading::DEFAULT_STALE_AFTER_TOKENS;
    use mobius::protocol::FrontendSettingKind;

    use super::*;

    #[test]
    fn defaults_and_required_features_come_from_core_manifests() {
        let config = default_config();
        let features = features(&[]);

        assert!(validate(&config).is_ok());
        assert!(!config.enabled("tasks"));
        assert!(config.enabled("artifacts"));
        assert!(config.enabled("cron"));
        assert!(config.enabled("extensions"));
        assert!(config.enabled("context_offloading"));
        assert_eq!(
            integer_setting(&config, "context_offloading", "stale_after_tokens")
                .expect("context setting"),
            DEFAULT_STALE_AFTER_TOKENS,
        );
        assert_eq!(
            features
                .iter()
                .filter(|feature| feature.required)
                .map(|feature| feature.id.as_str())
                .collect::<BTreeSet<_>>(),
            BTreeSet::from(["sandbox", "sessions", "steering", "tools"])
        );

        let mut without_cron = config.clone();
        without_cron.set_enabled("cron", false);
        assert!(validate(&without_cron).is_ok());

        let mut invalid = config;
        invalid.set_enabled("tools", true);
        assert!(validate(&invalid).is_err());
    }

    #[test]
    fn scratchpad_projects_after_compaction() {
        let position = |id| {
            MIDDLEWARE
                .iter()
                .position(|entry| entry.manifest.id == id)
                .expect("registered middleware")
        };

        assert!(position("compaction") < position("scratchpad"));
    }

    #[test]
    fn dynamic_choices_use_the_live_model_catalog() {
        let models = [ModelChoice {
            route: "provider::model::high".into(),
            group: "Provider · Model".into(),
            model: "model".into(),
            reasoning_effort: Some("high".into()),
            context_window: Some(200_000),
            supports_image_input: true,
        }];
        let subagents = features(&models)
            .into_iter()
            .find(|feature| feature.id == "subagents")
            .expect("subagent feature");
        let route = subagents
            .settings
            .iter()
            .find(|setting| setting.id == "model_route")
            .expect("model route setting");
        let FrontendSettingKind::Select {
            options,
            unset_label,
        } = &route.kind
        else {
            panic!("subagent route must be a select setting")
        };
        assert_eq!(unset_label.as_deref(), Some("Inherit parent"));
        assert_eq!(options[0].value, models[0].route);

        let mut config = default_config();
        config.set_setting(
            "subagents",
            "model_route",
            Some(FrontendSettingValue::String(models[0].route.clone())),
        );
        assert!(validate(&config).is_ok());
        assert!(validate_choices(&config, &models).is_ok());
        assert!(validate_choices(&config, &[]).is_err());
    }

    #[test]
    fn sandbox_manifest_drives_generic_approval_settings() {
        let config = default_config();
        let sandbox = features(&[])
            .into_iter()
            .find(|feature| feature.id == "sandbox")
            .expect("sandbox feature");

        assert!(sandbox.required);
        assert_eq!(
            config.setting("sandbox", "approval_policy"),
            Some(&FrontendSettingValue::String("auto_approve".into()))
        );
        assert_eq!(
            config.setting("sandbox", "reviewer_strictness"),
            Some(&FrontendSettingValue::String("strict".into()))
        );
        assert!(
            sandbox
                .settings
                .iter()
                .any(|setting| setting.id == "reviewer_model_route")
        );
    }

    #[test]
    fn config_rejects_unknown_mistyped_and_inconsistent_settings() {
        let mut config = default_config();
        config.set_setting("tools", "extra", Some(FrontendSettingValue::Integer(1)));
        assert!(validate(&config).is_err());

        config.set_setting("tools", "extra", None);
        config.set_setting(
            "context_offloading",
            "stale_after_tokens",
            Some(FrontendSettingValue::String("50000".into())),
        );
        assert!(validate(&config).is_err());

        let mut config = default_config();
        config.set_setting(
            "subagents",
            "max_agents",
            Some(FrontendSettingValue::Integer(2)),
        );
        assert!(validate(&config).is_err());
    }
}