Files
passepartout/org/system-model-provider.org
Amr Gharbeia 740ff3bb89
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 2s
provider: add bt:with-timeout + LLM_REQUEST_TIMEOUT env var
2026-05-04 18:21:10 -04:00

8.3 KiB

SKILL: Unified LLM Backend (org-skill-unified-llm-backend.org)

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.

Implementation

Provider registry

(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"))))

Provider config lookup

(defun provider-config (provider)
  "Returns the configuration plist for a provider keyword."
  (cdr (assoc provider *provider-configs*)))

Availability check

(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))))

Unified request execution

(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
        (bt:with-timeout (timeout)
          (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 in response (~s)" provider json)))))
      (bt:timeout ()
        (list :status :error :message (format nil "~a: Request timed out after ~d seconds" provider timeout)))
      (error (c)
        (list :status :error :message (format nil "~a Failure: ~a" provider c))))))

Register all available providers

(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)))))))

Initialize cascade

(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*))))))

Provider connection test (for TUI config)

;; REPL-verified: 2026-05-04

(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))))))

Boot registration

(provider-register-all)
(provider-cascade-initialize)

Skill registration

(defskill :passepartout-system-model-provider
  :priority 50
  :trigger (lambda (ctx) (declare (ignore ctx)) nil))