Multiple Providers and Dynamic Models

The Quickstart used a single client with a hardcoded model. This chapter builds on that to show how to support multiple providers, discover models at runtime, and use plugins for automation.

The patterns shown here match what the moly-mini example does.

RouterClient

aitk's RouterClient aggregates multiple BotClient implementations into one, routing requests based on a prefix in the BotId. See the aitk Router Client documentation for full details.

Instead of creating a single OpenAiClient, build a RouterClient with multiple sub-clients:

fn build_client() -> RouterClient {
    let client = RouterClient::new();

    // Ollama runs locally, no key needed.
    let ollama = OpenAiClient::new("http://localhost:11434/v1".into());
    client.insert_client("ollama", Box::new(ollama));

    // Add OpenAI if a key is available.
    if let Some(key) = option_env!("OPEN_AI_KEY") {
        let mut openai = OpenAiClient::new("https://api.openai.com/v1".into());
        let _ = openai.set_key(key);
        client.insert_client("open_ai", Box::new(openai));
    }

    // Add more providers following the same pattern.

    client
}

Loading models dynamically

Instead of hardcoding a BotId, ask the controller to fetch available models from all configured providers:

let controller = ChatController::builder()
    .with_client(build_client())
    .with_basic_spawner()
    .build_arc();

controller.lock().unwrap().dispatch_task(ChatTask::Load);

ChatTask::Load triggers an async call to bots() on the client. When it completes, the controller's state().bots is populated with all available models (prefixed by their router key, e.g. ollama/llama3, open_ai/gpt-4.1).

The Chat widget includes a built-in ModelSelector that lets the user pick from the loaded models. Once the user selects a model, the widget sets the BotId on the controller automatically.

Plugins

aitk's ChatControllerPlugin trait lets you hook into state changes and task execution. Plugins form a composable pipeline -- they observe mutations, react to state transitions, and can intercept tasks before they execute. See the aitk Chat App documentation for more information.

In our example, we'll use a plugin to auto-select a model once they finish loading, so the user can start chatting immediately without manual selection.

The plugin uses Makepad's DeferWithRedraw trait, which requires an explicit import:

use makepad_widgets::defer_with_redraw::DeferWithRedraw;
struct AutoSelectPlugin {
    ui: UiRunner<MyChat>,
    initialized: bool,
}

impl ChatControllerPlugin for AutoSelectPlugin {
    fn on_state_ready(&mut self, state: &ChatState, _mutations: &[ChatStateMutation]) {
        if self.initialized || state.bots.is_empty() {
            return;
        }

        let bots = state.bots.clone();
        self.ui.defer_with_redraw(move |widget, _cx, _scope| {
            widget.select_first_bot(&bots);
        });
        self.initialized = true;
    }
}

The plugin watches for state changes. Once state.bots is populated (meaning the Load task completed), it defers a UI action via Makepad's UiRunner to select the first bot. UiRunner is Makepad's mechanism for safely deferring work back to the widget that owns the runner.

The selection itself dispatches a mutation on the controller:

impl MyChat {
    fn select_first_bot(&mut self, bots: &[Bot]) {
        let Some(controller) = &self.controller else {
            return;
        };
        if let Some(bot) = bots.first() {
            controller
                .lock()
                .unwrap()
                .dispatch_mutation(ChatStateMutation::SetBotId(Some(bot.id.clone())));
        }
    }
}

Putting it together

Register the plugin when building the controller, and wire everything in on_after_new. Since the plugin uses UiRunner, you also need to add self.ui_runner().handle(...) to your widget's handle_event:

impl Widget for MyChat {
    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
        self.ui_runner().handle(cx, event, scope, self);
        self.view.handle_event(cx, event, scope);
    }

    // draw_walk unchanged...
}

impl ScriptHook for MyChat {
    fn on_after_new(&mut self, vm: &mut ScriptVm) {
        let controller = ChatController::builder()
            .with_basic_spawner()
            .with_client(build_client())
            .with_plugin_prepend(AutoSelectPlugin {
                ui: self.ui_runner(),
                initialized: false,
            })
            .build_arc();

        controller.lock().unwrap().dispatch_task(ChatTask::Load);

        self.controller = Some(controller.clone());
        vm.with_cx_mut(|cx| {
            self.chat(cx, ids!(chat))
                .write()
                .set_chat_controller(cx, Some(controller));
        });
    }
}