#+TITLE: SKILL: Unified LLM Backend (org-skill-unified-llm-backend.org) #+AUTHOR: Agent #+FILETAGS: :skill:model:provider:llm: #+PROPERTY: header-args:lisp :tangle ../lisp/system-model-provider.lisp * Architectural Intent ~system-model-provider~ is the universal LLM client. It speaks the OpenAI-compatible ~/v1/chat/completions~ protocol, which covers every modern provider — OpenRouter, OpenAI, Anthropic, Groq, Gemini, DeepSeek, NVIDIA NIM, plus any local engine (Ollama, vLLM, LM Studio, llama.cpp) when running behind an OpenAI-compatible adapter. One function, eight (and counting) providers. The same JSON payload, the same response format, the same error handling. Adding a new provider is a one-line config entry: a keyword, a base URL, an API key env var name, and a default model. Providers register themselves at boot. No API key? That provider doesn't register. No local URL set? The local entry stays dormant. Only the providers you actually configure appear in ~*probabilistic-backends*~ at runtime. The old code assumed Ollama was always available; this code requires an env var like everything else. =*provider-cascade*= defaults to cloud-only (all providers except ~:local~ and ~:ollama~). If you want a local fallback, set ~LOCAL_BASE_URL~ in your env and add ~:local~ to the ~PROVIDER_CASCADE~ list. ** Contract 1. (provider-config provider): returns the configuration plist for a provider keyword, or nil if unregistered. 2. (provider-available-p provider): returns T if the provider's API key or base URL is configured. 3. (provider-openai-request prompt system-prompt &key model provider): executes an OpenAI-compatible /v1/chat/completions request. Returns ~(:status :success :content ...)~ or ~(:status :error :message ...)~. 4. (provider-cascade-initialize): reads ~PROVIDER_CASCADE~ from env and sets ~*provider-cascade*~. * Implementation ** Provider registry #+begin_src lisp (in-package :passepartout) (defparameter *provider-configs* '((:local . (:base-url nil :key-env nil :url-env "LOCAL_BASE_URL" :default-model "llama3")) (:openrouter . (:base-url "https://openrouter.ai/api/v1" :key-env "OPENROUTER_API_KEY" :default-model "openrouter/auto")) (:openai . (:base-url "https://api.openai.com/v1" :key-env "OPENAI_API_KEY" :default-model "gpt-4o-mini")) (:anthropic . (:base-url "https://api.anthropic.com/v1" :key-env "ANTHROPIC_API_KEY" :default-model "claude-3-5-sonnet-20241022")) (:groq . (:base-url "https://api.groq.com/openai/v1" :key-env "GROQ_API_KEY" :default-model "llama-3.1-70b-versatile")) (:gemini . (:base-url "https://generativelanguage.googleapis.com/v1beta/openai" :key-env "GEMINI_API_KEY" :default-model "gemini-2.0-flash")) (:deepseek . (:base-url "https://api.deepseek.com/v1" :key-env "DEEPSEEK_API_KEY" :default-model "deepseek-chat")) (:nvidia . (:base-url "https://integrate.api.nvidia.com/v1" :key-env "NVIDIA_API_KEY" :default-model "meta/llama-3.1-405b-instruct")))) #+end_src ** Provider config lookup #+begin_src lisp (defun provider-config (provider) "Returns the configuration plist for a provider keyword." (cdr (assoc provider *provider-configs*))) #+end_src ** Availability check #+begin_src lisp (defun provider-available-p (provider) "Checks if a provider is configured. Checks API key or URL env vars." (let* ((config (provider-config provider)) (key-env (getf config :key-env)) (url-env (getf config :url-env)) (base-url (getf config :base-url))) (cond (key-env (let ((key (uiop:getenv key-env))) (and key (> (length key) 0)))) (url-env (let ((url (uiop:getenv url-env))) (and url (> (length url) 0)))) (base-url t)))) #+end_src ** Unified request execution #+begin_src lisp (defun provider-openai-request (prompt system-prompt &key model (provider :openrouter)) "Executes a request against any OpenAI-compatible API endpoint." (let* ((config (provider-config provider)) (base-url (getf config :base-url)) (key-env (getf config :key-env)) (url-env (getf config :url-env)) (default-model (getf config :default-model)) (api-key (when key-env (uiop:getenv key-env))) (model-id (or model default-model)) (url (if url-env (let ((host (uiop:getenv url-env))) (if host (format nil "http://~a/v1/chat/completions" host) (format nil "~a/chat/completions" base-url))) (format nil "~a/chat/completions" base-url))) (timeout (or (ignore-errors (parse-integer (uiop:getenv "LLM_REQUEST_TIMEOUT"))) 30)) (headers `(("Content-Type" . "application/json") ,@(when api-key `(("Authorization" . ,(format nil "Bearer ~a" api-key)))) ,@(when (eq provider :openrouter) `(("HTTP-Referer" . "https://github.com/amrgharbeia/passepartout") ("X-Title" . "Passepartout"))))) (body (cl-json:encode-json-to-string `((model . ,model-id) (messages . (( (role . "system") (content . ,system-prompt) ) ( (role . "user") (content . ,prompt) ))))))) (handler-case (let* ((response (dex:post url :headers headers :content body :connect-timeout (min 10 timeout) :read-timeout (max 10 (- timeout 5)))) (json (cl-json:decode-json-from-string response)) (choices (cdr (assoc :choices json))) (first-choice (car choices)) (message (cdr (assoc :message first-choice))) (content (cdr (assoc :content message)))) (if content (list :status :success :content content) (list :status :error :message (format nil "~a: No content" provider)))) (error (c) (list :status :error :message (format nil "~a Failure: ~a" provider c)))))) #+end_src ** Register all available providers #+begin_src lisp (defun provider-register-all () "Scans environment variables and registers all available LLM backends." (dolist (entry *provider-configs*) (let ((provider (car entry))) (when (provider-available-p provider) (log-message "LLM BACKEND: Registering provider ~a" provider) (register-probabilistic-backend provider (lambda (prompt system-prompt &key model) (provider-openai-request prompt system-prompt :model model :provider provider))))))) #+end_src ** Initialize cascade #+begin_src lisp (defun provider-cascade-initialize () "Reads PROVIDER_CASCADE from env and sets *provider-cascade*." (let ((cascade-str (uiop:getenv "PROVIDER_CASCADE"))) (if cascade-str (setf *provider-cascade* (mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword)) (uiop:split-string cascade-str :separator '(#\,)))) (setf *provider-cascade* (mapcar #'car (remove-if (lambda (e) (member (car e) '(:local))) *provider-configs*)))))) #+end_src ** Provider connection test (for TUI config) ;; REPL-verified: 2026-05-04 #+begin_src lisp (defun test-provider-connection (provider &optional api-key) "Test a provider API key by hitting its models endpoint. Returns (:ok) on success, (:fail reason) on failure. If API-KEY is nil, reads from environment." (let* ((config (provider-config provider)) (base-url (getf config :base-url)) (key-env (getf config :key-env)) (url-env (getf config :url-env)) (key (or api-key (when key-env (uiop:getenv key-env))))) (handler-case (let ((url (if url-env (let ((host (or (uiop:getenv url-env) ""))) (format nil "http://~a/api/tags" host)) (format nil "~a/models" (or base-url ""))))) (if key-env (progn (dex:get url :headers `(("Authorization" . ,(format nil "Bearer ~a" key))) :connect-timeout 5 :read-timeout 10) '(:ok)) (if url-env (progn (dex:get url :connect-timeout 5 :read-timeout 10) '(:ok)) '(:fail "No URL source for this provider")))) (error (c) `(:fail ,(format nil "~a" c)))))) #+end_src ** Boot registration #+begin_src lisp (provider-register-all) (provider-cascade-initialize) #+end_src ** Skill registration #+begin_src lisp (defskill :passepartout-system-model-provider :priority 50 :trigger (lambda (ctx) (declare (ignore ctx)) nil)) #+end_src * Test Suite #+begin_src lisp (eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t)) (defpackage :passepartout-llm-gateway-tests (:use :cl :passepartout) (:export #:llm-gateway-suite)) (in-package :passepartout-llm-gateway-tests) (fiveam:def-suite llm-gateway-suite :description "Tests for the LLM provider backend") (fiveam:in-suite llm-gateway-suite) (fiveam:test test-provider-rejects-bad-keyword "Edge: provider-openai-request returns :error for unregistered provider." (let ((result (provider-openai-request "hello" "test" :provider :not-a-real-provider))) (fiveam:is (eq (getf result :status) :error)))) (fiveam:test test-provider-config-registered "Contract: provider-config returns configuration plist for registered provider." (let ((config (provider-config :openrouter))) (fiveam:is (listp config)) (fiveam:is (getf config :base-url)))) #+end_src