(in-package :passepartout) (defvar *model-cascade-code* nil "Cascade for :code tasks: ((:ollama . \"model\") ...)") (defvar *model-cascade-plan* nil "Cascade for :plan tasks.") (defvar *model-cascade-chat* nil "Cascade for :chat tasks.") (defvar *model-cascade-background* nil "Cascade for background tasks (heartbeat, delegation).") (defvar *local-backends* '(:ollama :llama-cpp) "Backend keywords considered local (privacy-safe).") (defun model-classify-complexity (text) "Classify TEXT into :code, :plan, or :chat." (let ((lower (string-downcase text))) (cond ((or (search "defun" lower) (search "defmacro" lower) (search "write" lower) (search "refactor" lower) (search "fix " lower) (search "implement" lower) (search "code" lower) (search "#+begin_src" lower)) :code) ((or (search "plan" lower) (search "roadmap" lower) (search "strategy" lower) (search "design" lower) (search "architecture" lower)) :plan) (t :chat)))) (defun model-cascade-find (cascade backend) "Find first (PROVIDER . MODEL) in CASCADE matching BACKEND." (assoc backend cascade :test (lambda (a b) (string-equal (string a) (string b))))) (defun model-select (backend context) "Select model for BACKEND given CONTEXT signal. Returns model name or :skip." (let* ((payload (getf context :payload)) (text (or (getf payload :text) "")) (sensor (getf payload :sensor)) (has-personal (and (boundp '*dispatcher-privacy-tags*) (some (lambda (tag) (search tag text)) (symbol-value '*dispatcher-privacy-tags*)))) (is-local (member backend *local-backends*))) ;; Privacy: skip cloud backends for personal content (when (and has-personal (not is-local)) (log-message "MODEL-ROUTER: Skipping ~a (personal content)" backend) (return-from model-select :skip)) ;; Quadrant: background tasks use background cascade (if (member sensor '(:heartbeat :delegation :tool-output :loop-error)) (let ((entry (car (or *model-cascade-background* '((:ollama . "phi-2")))))) (cdr entry)) ;; Foreground: classify complexity, use slot cascade (let* ((slot (model-classify-complexity text)) (cascade (case slot (:code *model-cascade-code*) (:plan *model-cascade-plan*) (t *model-cascade-chat*))) (entry (model-cascade-find (or cascade '((:ollama . "qwen2.5:14b"))) backend))) (if entry (cdr entry) :skip))))) (defun model-router-init () "Read env vars and wire model-select into *model-selector*." (flet ((parse-cascade (str) (when (and str (> (length str) 0)) (let ((*read-eval* nil)) (read-from-string str))))) (setf *model-cascade-code* (parse-cascade (uiop:getenv "MODEL_CASCADE_CODE")) *model-cascade-plan* (parse-cascade (uiop:getenv "MODEL_CASCADE_PLAN")) *model-cascade-chat* (parse-cascade (uiop:getenv "MODEL_CASCADE_CHAT")) *model-cascade-background* (parse-cascade (uiop:getenv "MODEL_CASCADE_BACKGROUND")) *local-backends* (let ((env (uiop:getenv "LOCAL_BACKENDS"))) (if env (mapcar (lambda (s) (intern (string-upcase (string-trim " " s)) :keyword)) (uiop:split-string env :separator '(#\,))) '(:ollama :llama-cpp))))) (setf *model-selector* #'model-select) (log-message "MODEL-ROUTER: Initialized, selector=~a" *model-selector*)) (defskill :passepartout-model-router :priority 250 :trigger (lambda (ctx) (declare (ignore ctx)) nil)) (model-router-init)