feat(v0.2.0): unified OpenAI-compatible LLM backend

Replace Ollama-specific backend with unified org-skill-unified-llm-backend
that speaks OpenAI API. Works with:
- Local: Ollama (default), vLLM, LM Studio, llama.cpp
- Cloud: OpenRouter, OpenAI, Anthropic, Groq, Gemini

Providers auto-registered from env vars. No separate skills per provider.
Cascade order configured via PROVIDER_CASCADE env var.

Also fix .env loading path in loop (was .local/share, now .config matches wizard).
This commit is contained in:
2026-04-30 18:44:28 -04:00
parent b63f5477c1
commit a3d07209b6
5 changed files with 110 additions and 39 deletions

View File

@@ -105,7 +105,7 @@
(defun main () (defun main ()
"Entry point for OpenCortex. Initializes the system and enters idle loop." "Entry point for OpenCortex. Initializes the system and enters idle loop."
(let* ((home (uiop:getenv "HOME")) (let* ((home (uiop:getenv "HOME"))
(env-file (uiop:merge-pathnames* ".local/share/opencortex/.env" (uiop:ensure-directory-pathname home)))) (env-file (uiop:merge-pathnames* ".config/opencortex/.env" (uiop:ensure-directory-pathname home))))
(when (uiop:file-exists-p env-file) (when (uiop:file-exists-p env-file)
(cl-dotenv:load-env env-file))) (cl-dotenv:load-env env-file)))

View File

@@ -139,7 +139,7 @@ The Metabolic Loop is the fundamental rhythm of OpenCortex: the continuous proce
(defun main () (defun main ()
"Entry point for OpenCortex. Initializes the system and enters idle loop." "Entry point for OpenCortex. Initializes the system and enters idle loop."
(let* ((home (uiop:getenv "HOME")) (let* ((home (uiop:getenv "HOME"))
(env-file (uiop:merge-pathnames* ".local/share/opencortex/.env" (uiop:ensure-directory-pathname home)))) (env-file (uiop:merge-pathnames* ".config/opencortex/.env" (uiop:ensure-directory-pathname home))))
(when (uiop:file-exists-p env-file) (when (uiop:file-exists-p env-file)
(cl-dotenv:load-env env-file))) (cl-dotenv:load-env env-file)))

View File

@@ -29,7 +29,9 @@ export MEMEX_DIR="${MEMEX_DIR:-$HOME/memex}"
# Load environment variables from the standard config location # Load environment variables from the standard config location
if [ -f "$OC_CONFIG_DIR/.env" ]; then if [ -f "$OC_CONFIG_DIR/.env" ]; then
set -a
source "$OC_CONFIG_DIR/.env" source "$OC_CONFIG_DIR/.env"
set +a
fi fi
# --- Dependency Checker --- # --- Dependency Checker ---

View File

@@ -1,37 +0,0 @@
#+TITLE: SKILL: Llama Backend (org-skill-llama-backend.org)
#+AUTHOR: Agent
#+FILETAGS: :skill:llm:backend:ollama:
#+PROPERTY: header-args:lisp :tangle %%SKILLS_DIR%%/org-skill-llama-backend.lisp
* Overview
The *Llama Backend* skill provides the actual implementation for calling local models via Ollama.
* Implementation
** Ollama API Call (ollama-call)
#+begin_src lisp
(defun ollama-call (prompt system-prompt &key (model "llama3"))
"Sends a request to the local Ollama API."
(let* ((host (or (uiop:getenv "OLLAMA_HOST") "localhost:11434"))
(url (format nil "http://~a/api/generate" host))
(payload (cl-json:encode-json-to-string
`((model . ,model)
(prompt . ,prompt)
(system . ,system-prompt)
(stream . nil)))))
(handler-case
(let ((response (dex:post url :content payload :headers '(("Content-Type" . "application/json")))))
(let ((data (cl-json:decode-json-from-string response)))
(list :status :success :content (getf data :response))))
(error (c)
(list :status :error :message (format nil "Ollama Failure: ~a" c))))))
#+end_src
** Skill Registration
#+begin_src lisp
(register-probabilistic-backend :ollama #'ollama-call)
(defskill :skill-llama-backend
:priority 50
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
#+end_src

View File

@@ -0,0 +1,106 @@
#+TITLE: SKILL: Unified LLM Backend (org-skill-unified-llm-backend.org)
#+AUTHOR: Agent
#+FILETAGS: :skill:llm:backend:openai-compatible:
#+PROPERTY: header-args:lisp :tangle %%SKILLS_DIR%%/org-skill-unified-llm-backend.lisp
* Overview
The *Unified LLM Backend* provides a single OpenAI-compatible API client that works with:
- Local engines: Ollama, vLLM, LM Studio, llama.cpp (anything exposing /v1/chat/completions)
- Cloud providers: OpenRouter, OpenAI, Anthropic, Groq, Gemini (all OpenAI-compatible)
Providers are registered automatically based on available environment variables.
No separate skills per provider — just different base URLs and API keys.
* Implementation
** Provider Registry
#+begin_src lisp
(defparameter *unified-llm-providers*
'((:ollama . (:base-url nil :key-env nil :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"))))
(defun get-provider-config (provider)
"Returns the configuration plist for a provider keyword."
(cdr (assoc provider *unified-llm-providers*)))
(defun provider-available-p (provider)
"Checks if a provider is configured (has API key or is local Ollama)."
(let* ((config (get-provider-config provider))
(key-env (getf config :key-env))
(base-url (getf config :base-url)))
(cond ((eq provider :ollama) t) ; Ollama is always tried; failure is handled at call time
(key-env (let ((key (uiop:getenv key-env))) (and key (> (length key) 0))))
(base-url t))))
#+end_src
** Unified Request Execution
#+begin_src lisp
(defun execute-openai-compatible-request (prompt system-prompt &key model (provider :ollama))
"Executes a request against any OpenAI-compatible API endpoint."
(let* ((config (get-provider-config provider))
(base-url (getf config :base-url))
(key-env (getf config :key-env))
(default-model (getf config :default-model))
(api-key (when key-env (uiop:getenv key-env)))
(model-id (or model default-model))
(url (if (eq provider :ollama)
(format nil "http://~a/v1/chat/completions" (or (uiop:getenv "OLLAMA_HOST") "localhost:11434"))
(format nil "~a/chat/completions" base-url)))
(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/opencortex")
("X-Title" . "OpenCortex")))))
(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 10 :read-timeout 60))
(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))))
(error (c)
(list :status :error :message (format nil "~a Failure: ~a" provider c))))))
#+end_src
** Dynamic Backend Registration
#+begin_src lisp
(defun register-available-llm-backends ()
"Scans environment variables and registers all available LLM backends."
(dolist (entry *unified-llm-providers*)
(let ((provider (car entry)))
(when (provider-available-p provider)
(harness-log "LLM BACKEND: Registering provider ~a" provider)
(register-probabilistic-backend provider
(lambda (prompt system-prompt &key model)
(execute-openai-compatible-request prompt system-prompt :model model :provider provider)))))))
(defun initialize-provider-cascade ()
"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 *unified-llm-providers*)))))
#+end_src
** Skill Registration
#+begin_src lisp
(register-available-llm-backends)
(initialize-provider-cascade)
(defskill :skill-unified-llm-backend
:priority 50
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
#+end_src