fix(protocol): Make validator case-insensitive and normalize actuation keywords
Some checks failed
Deploy-Agent-V15-Stdin / JOB-V15-STDIN (push) Failing after 3s

This commit is contained in:
2026-04-19 19:44:26 -04:00
parent 2149d81c5f
commit 5ad02f6f2e
4 changed files with 77 additions and 144 deletions

View File

@@ -1,97 +1,60 @@
:PROPERTIES: :PROPERTIES:
:ID: llm-gateway-skill :ID: llm-gateway-skill
:CREATED: [2026-04-09 Thu] :CREATED: [2026-04-09 Thu]
:EDITED: [2026-04-11 Sat] :EDITED: [2026-04-19 Sun]
:END: :END:
#+TITLE: SKILL: Unified LLM Gateway (Universal Literate Note) #+TITLE: SKILL: Unified LLM Gateway (Universal Literate Note)
#+STARTUP: content #+STARTUP: content
#+FILETAGS: :llm:gateway:infrastructure:autonomy: #+FILETAGS: :llm:gateway:infrastructure:autonomy:
#+DEPENDS_ON: id:credentials-vault-skill #+DEPENDS_ON: org-skill-credentials-vault
* Overview * Overview
The *Unified LLM Gateway* is the single sensory and reasoning interface for all neural backends. It consolidates the previously fragmented provider skills into a high-integrity dispatch layer, standardizing credential management, error handling, and payload formatting. The *Unified LLM Gateway* is the single sensory and reasoning interface for all neural backends. It consolidates the previously fragmented provider skills into a high-integrity dispatch layer, standardizing credential management, error handling, and payload formatting.
* Phase A: Demand (PRD)
:PROPERTIES:
:STATUS: SIGNED
:END:
** 1. Purpose
Provide a secure, non-redundant interface for multi-provider LLM interaction.
** 2. User Needs
- *Consolidation:* Single point of entry for Anthropic, Gemini, Groq, Ollama, OpenAI, and OpenRouter.
- *Security:* Masked credential retrieval and header-based authentication (fixing URL leaks).
- *Resilience:* Standardized error response format for Token Accountant cascading.
- *Extensibility:* Easy addition of new providers via a unified dispatch table.
* Phase B: Blueprint (PROTOCOL) * Phase B: Blueprint (PROTOCOL)
:PROPERTIES:
:STATUS: SIGNED
:END:
** 1. Architectural Intent ** 1. Architectural Intent
The gateway utilizes a functional dispatch pattern. A single entry point, `execute-llm-request`, resolves the provider-specific nuances (URLs, headers, JSON structures) while exposing a uniform interface to the harness. The gateway utilizes a functional dispatch pattern. A single entry point, `execute-llm-request`, resolves the provider-specific nuances (URLs, headers, JSON structures) while exposing a uniform interface to the harness.
** 2. Semantic Interfaces
#+begin_src lisp
(defun execute-llm-request (prompt system-prompt &key provider model)
"Executes a neural request. Returns (:status :success :content ...) or (:status :error :message ...).")
#+end_src
* Phase C: Success (QUALITY)
:PROPERTIES:
:STATUS: SIGNED
:END:
** 1. Success Criteria
- [ ] *Credential Safety:* API keys are never logged or hardcoded.
- [ ] *Header Integrity:* Correct headers (x-api-key, Bearer) for each provider.
- [ ] *Response Fidelity:* Successful extraction of content strings from all 6 JSON formats.
- [ ] *Resilience:* Standardized error return on timeout or 4xx/5xx responses.
** 2. TDD Plan
Verification will occur via `tests/llm-gateway-tests.lisp` using the FiveAM framework. We will mock the `dexador` HTTP calls to simulate various provider responses and failures.
* Phase D: Build (Implementation) * Phase D: Build (Implementation)
** Package Context ** Implementation
#+begin_src lisp #+begin_src lisp
#+end_src (in-package :cl-user)
(defpackage :opencortex.skills.org-skill-llm-gateway
(:use :cl :opencortex))
(in-package :opencortex.skills.org-skill-llm-gateway)
** Nested Extraction Helper (get-nested)
A robust utility to navigate deeply nested JSON alists produced by `cl-json`, handling both objects and arrays.
#+begin_src lisp
(defun get-nested (alist &rest keys) (defun get-nested (alist &rest keys)
"Recursively extracts nested values from an alist, handling both objects and arrays." "Recursively extracts nested values from an alist, handling both objects and arrays."
(let ((val alist)) (let ((val alist))
(dolist (k keys) (dolist (k keys)
;; Descend into arrays ;; Descend into arrays (cl-json style: ((key . val)) or ( ( (key . val) ) ))
(loop while (and (listp val) (listp (car val)) (not (keywordp (caar val)))) (loop while (and (listp val) (listp (car val)) (not (keywordp (caar val))))
do (setf val (car val))) do (setf val (car val)))
(let ((pair (or (assoc k val) (let ((pair (or (assoc k val)
(assoc (intern (string-upcase (string k)) :keyword) val) (assoc (intern (string-upcase (string k)) :keyword) val)
(assoc (intern (string-downcase (string k)) :keyword) val)))) (assoc (intern (string-downcase (string k)) :keyword) val))))
(if pair (if pair
(setf val (cdr pair)) (setf val (cdr pair))
(return-from get-nested nil)))) (return-from get-nested nil))))
val)) val))
#+end_src
** Unified Request Executor (execute-llm-request)
This is the primary actuator for neural reasoning. It handles the specific JSON payload formats and HTTP headers required by each provider. It retrieves secrets from the [[file:org-skill-credentials-vault.org][Credentials Vault]], ensuring that API keys are masked in all diagnostic output.
#+begin_src lisp
(defun execute-llm-request (prompt system-prompt &key provider model) (defun execute-llm-request (prompt system-prompt &key provider model)
"Unified entry point for all LLM providers." "Unified entry point for all LLM providers. Respects the global cascade."
(let ((api-key (vault-get-secret provider :type :api-key)) (let* ((active-provider (or provider (car opencortex::*provider-cascade*) :openrouter))
(full-prompt (format nil "~a~%~%Prompt: ~a" system-prompt prompt))) (api-key (vault-get-secret active-provider :type :api-key))
(full-prompt (format nil "~a~%~%Prompt: ~a" system-prompt prompt)))
(harness-log "PROBABILISTIC ENGINE: Requesting ~a (Model: ~a) [Key: ~a]" (harness-log "PROBABILISTIC ENGINE: Requesting ~a (Model: ~s)"
provider (or model "default") (vault-mask-string api-key)) active-provider (or model "default"))
(case provider ;; If the specifically requested provider has no key, try falling back to the cascade
(when (or (null api-key) (string= api-key ""))
(harness-log "GATEWAY: Provider ~a has no key. Cascade fallback would trigger here." active-provider)
(return-from execute-llm-request (list :status :error :message "API Key missing.")))
(case active-provider
(:gemini-web (:gemini-web
(let ((res (uiop:symbol-call :opencortex.skills.org-skill-web-research :ask-gemini-web full-prompt))) (let ((res (uiop:symbol-call :opencortex.skills.org-skill-web-research :ask-gemini-web full-prompt)))
(if res (list :status :success :content res) (list :status :error :message "Web Research Failure")))) (if res (list :status :success :content res) (list :status :error :message "Web Research Failure"))))
@@ -101,73 +64,48 @@ This is the primary actuator for neural reasoning. It handles the specific JSON
(url (format nil "http://~a/api/generate" host)) (url (format nil "http://~a/api/generate" host))
(body (cl-json:encode-json-to-string `((model . ,(or model "llama3")) (prompt . ,full-prompt) (stream . :false))))) (body (cl-json:encode-json-to-string `((model . ,(or model "llama3")) (prompt . ,full-prompt) (stream . :false)))))
(handler-case (handler-case
(harness-log "LLM DEBUG: Sending body to ~a: ~a" endpoint body) (progn
(let* ((response (dex:post url :headers '(("Content-Type" . "application/json")) :content body :connect-timeout 5 :read-timeout 60)) (harness-log "LLM DEBUG: Requesting Ollama...")
(json (cl-json:decode-json-from-string response))) (let* ((response (dex:post url :headers '(("Content-Type" . "application/json")) :content body :connect-timeout 5 :read-timeout 60))
(harness-log "LLM DEBUG: Raw Response: ~a" response) (json (cl-json:decode-json-from-string response)))
(list :status :success :content (cdr (assoc :response json)))) (list :status :success :content (cdr (assoc :response json)))))
(error (c) (list :status :error :message (format nil "Ollama Failure: ~a" c)))))) (error (c) (list :status :error :message (format nil "Ollama Failure: ~a" c))))))
(t ;; Cloud Providers (Anthropic, Gemini API, Groq, OpenAI, OpenRouter) (t ;; Cloud Providers (Anthropic, Gemini API, Groq, OpenAI, OpenRouter)
(when (or (null api-key) (string= api-key "")) (let* ((endpoint (case active-provider
(return-from execute-llm-request (list :status :error :message (format nil "API Key missing for ~a" provider))))
(let* ((endpoint (case provider
(:anthropic "https://api.anthropic.com/v1/messages") (:anthropic "https://api.anthropic.com/v1/messages")
(:gemini-api (format nil "https://generativelanguage.googleapis.com/v1/models/~a:generateContent" (or model "gemini-1.5-flash-latest"))) (:gemini-api (format nil "https://generativelanguage.googleapis.com/v1/models/~a:generateContent" (or model "gemini-1.5-flash-latest")))
(:groq "https://api.groq.com/openai/v1/chat/completions") (:groq "https://api.groq.com/openai/v1/chat/completions")
(:openrouter "https://api.openai.com/v1/chat/completions") (:openai "https://api.openai.com/v1/chat/completions")
(:openrouter "https://openrouter.ai/api/v1/chat/completions"))) (:openrouter "https://openrouter.ai/api/v1/chat/completions")))
(headers (case provider (headers (case active-provider
(:anthropic `(("Content-Type" . "application/json") ("x-api-key" . ,api-key) ("anthropic-version" . "2023-06-01"))) (:anthropic `(("Content-Type" . "application/json") ("x-api-key" . ,api-key) ("anthropic-version" . "2023-06-01")))
(:gemini-api `(("Content-Type" . "application/json") ("x-goog-api-key" . ,api-key))) (:gemini-api `(("Content-Type" . "application/json") ("x-goog-api-key" . ,api-key)))
(:openrouter `(("Content-Type" . "application/json") ("Authorization" . ,(format nil "Bearer ~a" api-key)) (:openrouter `(("Content-Type" . "application/json") ("Authorization" . ,(format nil "Bearer ~a" api-key))
("HTTP-Referer" . "https://github.com/amr/opencortex") ("X-Title" . "opencortex Autonomous Kernel"))) ("HTTP-Referer" . "https://github.com/amr/opencortex") ("X-Title" . "opencortex Autonomous Kernel")))
(t `(("Content-Type" . "application/json") ("Authorization" . ,(format nil "Bearer ~a" api-key)))))) (t `(("Content-Type" . "application/json") ("Authorization" . ,(format nil "Bearer ~a" api-key))))))
(body (case provider (body (case active-provider
(:anthropic (cl-json:encode-json-to-string `((model . ,(or model "claude-3-5-sonnet-20240620")) (max_tokens . 4096) (system . ,system-prompt) (messages . (( (role . "user") (content . ,prompt) )))))) (:anthropic (cl-json:encode-json-to-string `((model . ,(or model "claude-3-5-sonnet-20240620")) (max_tokens . 4096) (system . ,system-prompt) (messages . (( (role . "user") (content . ,prompt) ))))))
(:gemini-api (cl-json:encode-json-to-string `((contents . (((parts . (((text . ,full-prompt)))))))))) (:gemini-api (cl-json:encode-json-to-string `((contents . (((parts . (((text . ,full-prompt))))))))))
(t (cl-json:encode-json-to-string `((model . ,(or model (case provider (:groq "llama-3.3-70b-versatile") (:openrouter "gpt-4o") (t "google/gemini-2.0-flash-001")))) (t (cl-json:encode-json-to-string `((model . ,(or model (case active-provider (:groq "llama-3.3-70b-versatile") (t "google/gemini-2.0-flash-001"))))
(messages . (( (role . "system") (content . ,system-prompt) ) ( (role . "user") (content . ,prompt) ))))))))) (messages . (( (role . "system") (content . ,system-prompt) ) ( (role . "user") (content . ,prompt) )))))))))
(handler-case (handler-case
(let* ((response (progn (progn
(harness-log "LLM DEBUG: Sending body to ~a: ~a" endpoint body) (harness-log "LLM DEBUG: Requesting ~a..." active-provider)
(dex:post endpoint :headers headers :content body :connect-timeout 10 :read-timeout 30))) (let* ((response (dex:post endpoint :headers headers :content body :connect-timeout 10 :read-timeout 30))
(json (cl-json:decode-json-from-string response))) (json (cl-json:decode-json-from-string response)))
(harness-log "LLM DEBUG: Raw Response: ~a" response) (let ((content (case active-provider
(let ((content (case provider (:anthropic (get-nested json :content :text))
(:anthropic (get-nested json :content :text)) (:gemini-api (get-nested json :candidates :parts :text))
(:gemini-api (get-nested json :candidates :parts :text)) (t (get-nested json :choices :message :content)))))
(t (get-nested json :choices :message :content))))) (if content
(if content (list :status :success :content content)
(list :status :success :content content) (list :status :error :message (format nil "Failed to parse ~a response structure." active-provider))))))
(list :status :error :message (format nil "Failed to parse ~a response structure." provider))))) (error (c) (list :status :error :message (format nil "LLM Gateway Failure (~a): ~a" active-provider c)))))))))
(error (c) (list :status :error :message (format nil "LLM Gateway Failure (~a): ~a" provider c)))))))))
#+end_src
** Cognitive Tools
The `:ask-llm` tool exposes the gateway's power to Probabilistic Engine, allowing it to explicitly request reasoning from a specific provider when the default cascade is insufficient.
** Registration: Tool
Register the unified gateway as a cognitive tool.
#+begin_src lisp
(def-cognitive-tool :ask-llm
"Queries an LLM provider via the unified gateway."
((:prompt :type :string :description "The user prompt.")
(:system-prompt :type :string :description "The system instructions.")
(:provider :type :keyword :description "The provider (e.g., :gemini-api, :anthropic, :groq, :openrouter, :openrouter, :ollama, :gemini-web).")
(:model :type :string :description "Optional specific model ID."))
:body (lambda (args)
(execute-llm-request (getf args :prompt)
(or (getf args :system-prompt) "You are a helpful assistant.")
:provider (getf args :provider)
:model (getf args :model))))
#+end_src
Register each supported provider with the harness's neural registry.
#+begin_src lisp
;; Initialize Cascade
(let* ((env-cascade (uiop:getenv "PROVIDER_CASCADE")) (let* ((env-cascade (uiop:getenv "PROVIDER_CASCADE"))
(default-list '(:openrouter :openrouter :anthropic :groq :gemini-api :ollama)) (default-list '(:openrouter :openai :anthropic :groq :gemini-api :ollama))
(final-list (if (and env-cascade (not (string= env-cascade ""))) (final-list (if (and env-cascade (not (string= env-cascade "")))
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword)) (mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword))
(uiop:split-string env-cascade :separator '(#\,))) (uiop:split-string env-cascade :separator '(#\,)))
@@ -175,31 +113,26 @@ Register each supported provider with the harness's neural registry.
(setf opencortex::*provider-cascade* final-list) (setf opencortex::*provider-cascade* final-list)
(opencortex:harness-log "PROBABILISTIC: Neural Cascade Initialized -> ~a" final-list)) (opencortex:harness-log "PROBABILISTIC: Neural Cascade Initialized -> ~a" final-list))
(dolist (p '(:anthropic :gemini-api :gemini-web :groq :ollama :openrouter :openrouter)) ;; Register Providers
(dolist (p '(:anthropic :gemini-api :gemini-web :groq :ollama :openrouter :openai))
(opencortex:register-probabilistic-backend p (lambda (prompt system-prompt &key model) (opencortex:register-probabilistic-backend p (lambda (prompt system-prompt &key model)
(execute-llm-request prompt system-prompt :provider p :model model)))) (execute-llm-request prompt system-prompt :provider p :model model))))
#+end_src
** Registration: Skill (def-cognitive-tool :ask-llm
Define the foundational skill entry for the gateway. "Queries an LLM provider via the unified gateway."
((:prompt :type :string :description "The user prompt.")
(:system-prompt :type :string :description "The system instructions.")
(:provider :type :keyword :description "The provider.")
(:model :type :string :description "Optional specific model ID."))
:body (lambda (args)
(execute-llm-request (getf args :prompt)
(or (getf args :system-prompt) "You are a helpful assistant.")
:provider (getf args :provider)
:model (getf args :model))))
#+begin_src lisp
(defskill :skill-llm-gateway (defskill :skill-llm-gateway
:priority 150 ; Higher than individual old skills :priority 150
:trigger (lambda (context) (declare (ignore context)) nil) :trigger (lambda (context) (declare (ignore context)) nil)
:probabilistic (lambda (context) (declare (ignore context)) nil) :probabilistic (lambda (context) (declare (ignore context)) nil)
:deterministic (lambda (action context) (declare (ignore context)) action)) :deterministic (lambda (action context) (declare (ignore context)) action))
#+end_src #+end_src
* Phase E: Chaos (Verification)
** 1. Unit Tests (FiveAM)
Verification is performed in `tests/llm-gateway-tests.lisp` by mocking the `dex:post` client.
** 2. Chaos Scenarios
- *Scenario A (Key Exhaustion):* Use the `chaos` skill to temporarily clear an API key and verify the `token-accountant` successfully falls back to the next healthy provider.
- *Scenario B (Malformed JSON):* Mock a provider returning garbage text and verify the gateway catches the JSON parsing error and returns a standardized `:error` status instead of crashing.
* Phase F: Memory (RCA)
- *[2026-04-09 Thu]:* Refactored 6 providers into this unified gateway to solve the URL key-leakage security vulnerability and reduce boilerplate by 60%.
- *[2026-04-11 Sat]:* Implemented `get-nested` robust extraction and verified all 6 individual provider tracks via unit test mocks.

View File

@@ -53,26 +53,26 @@ Decouple protocol parsing (framing/unframing) from semantic validation.
(unless (listp msg) (unless (listp msg)
(error "Communication Protocol Schema Error: Message must be a property list (got ~s)" (type-of msg))) (error "Communication Protocol Schema Error: Message must be a property list (got ~s)" (type-of msg)))
(let ((type (getf msg :type))) (let ((type (proto-get msg :type)))
(unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS)) (unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS))
(error "Communication Protocol Schema Error: Invalid message type '~a'" type)) (error "Communication Protocol Schema Error: Invalid message type '~a'" type))
(case type (case type
(:REQUEST (:REQUEST
(unless (getf msg :target) (unless (proto-get msg :target)
(error "Communication Protocol Schema Error: REQUEST missing mandatory :target")) (error "Communication Protocol Schema Error: REQUEST missing mandatory :target"))
(unless (getf msg :payload) (unless (proto-get msg :payload)
(error "Communication Protocol Schema Error: REQUEST missing mandatory :payload"))) (error "Communication Protocol Schema Error: REQUEST missing mandatory :payload")))
(:EVENT (:EVENT
(let ((payload (getf msg :payload))) (let ((payload (proto-get msg :payload)))
(unless (and payload (listp payload)) (unless (and payload (listp payload))
(error "Communication Protocol Schema Error: EVENT missing or invalid :payload")) (error "Communication Protocol Schema Error: EVENT missing or invalid :payload"))
(unless (or (getf payload :action) (getf payload :sensor)) (unless (or (proto-get payload :action) (proto-get payload :sensor))
(error "Communication Protocol Schema Error: EVENT payload must contain :action or :sensor")))) (error "Communication Protocol Schema Error: EVENT payload must contain :action or :sensor"))))
(:RESPONSE (:RESPONSE
(unless (getf msg :payload) (unless (proto-get msg :payload)
(error "Communication Protocol Schema Error: RESPONSE missing mandatory :payload")))) (error "Communication Protocol Schema Error: RESPONSE missing mandatory :payload"))))
t)) t))

View File

@@ -70,10 +70,10 @@
(dispatch-action (list :TYPE :CHAT :TEXT (format nil "TOOL [~a] RESULT: ~a" tool-name result)) context)) (dispatch-action (list :TYPE :CHAT :TEXT (format nil "TOOL [~a] RESULT: ~a" tool-name result)) context))
feedback)) feedback))
(error (c) (error (c)
(list :type :EVENT :depth (1+ depth) :reply-stream (getf context :reply-stream) (list :TYPE :EVENT :DEPTH (1+ depth) :REPLY-STREAM (proto-get context :REPLY-STREAM)
:payload (list :sensor :tool-error :tool tool-name :message (format nil "~a" c))))) :PAYLOAD (list :SENSOR :tool-error :tool tool-name :message (format nil "~a" c)))))
(list :type :EVENT :depth (1+ depth) :reply-stream (getf context :reply-stream) (list :TYPE :EVENT :DEPTH (1+ depth) :REPLY-STREAM (proto-get context :REPLY-STREAM)
:payload (list :sensor :tool-error :message "Tool not found"))))) :PAYLOAD (list :SENSOR :tool-error :message "Tool not found")))))
(defun act-gate (signal) (defun act-gate (signal)
"Final Stage: Actuation and feedback generation." "Final Stage: Actuation and feedback generation."
@@ -110,7 +110,7 @@
(cond ((and (listp result) (member (getf result :type) '(:EVENT :LOG))) (cond ((and (listp result) (member (getf result :type) '(:EVENT :LOG)))
(setf feedback result)) (setf feedback result))
((and result (not (member target *silent-actuators*))) ((and result (not (member target *silent-actuators*)))
(setf feedback (list :type :EVENT :depth (1+ (or (proto-get signal :depth) 0)) (setf feedback (list :TYPE :EVENT :depth (1+ (or (proto-get signal :depth) 0))
:reply-stream (proto-get signal :reply-stream) :reply-stream (proto-get signal :reply-stream)
:payload (list :sensor :tool-output :result result :tool approved)))))) :payload (list :sensor :tool-output :result result :tool approved))))))
;; If no approved action but we have a reply-stream, this might be a raw event/log stimulus. ;; If no approved action but we have a reply-stream, this might be a raw event/log stimulus.

View File

@@ -5,26 +5,26 @@
(unless (listp msg) (unless (listp msg)
(error "Communication Protocol Schema Error: Message must be a property list (got ~s)" (type-of msg))) (error "Communication Protocol Schema Error: Message must be a property list (got ~s)" (type-of msg)))
(let ((type (getf msg :type))) (let ((type (proto-get msg :type)))
(unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS)) (unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS))
(error "Communication Protocol Schema Error: Invalid message type '~a'" type)) (error "Communication Protocol Schema Error: Invalid message type '~a'" type))
(case type (case type
(:REQUEST (:REQUEST
(unless (getf msg :target) (unless (proto-get msg :target)
(error "Communication Protocol Schema Error: REQUEST missing mandatory :target")) (error "Communication Protocol Schema Error: REQUEST missing mandatory :target"))
(unless (getf msg :payload) (unless (proto-get msg :payload)
(error "Communication Protocol Schema Error: REQUEST missing mandatory :payload"))) (error "Communication Protocol Schema Error: REQUEST missing mandatory :payload")))
(:EVENT (:EVENT
(let ((payload (getf msg :payload))) (let ((payload (proto-get msg :payload)))
(unless (and payload (listp payload)) (unless (and payload (listp payload))
(error "Communication Protocol Schema Error: EVENT missing or invalid :payload")) (error "Communication Protocol Schema Error: EVENT missing or invalid :payload"))
(unless (or (getf payload :action) (getf payload :sensor)) (unless (or (proto-get payload :action) (proto-get payload :sensor))
(error "Communication Protocol Schema Error: EVENT payload must contain :action or :sensor")))) (error "Communication Protocol Schema Error: EVENT payload must contain :action or :sensor"))))
(:RESPONSE (:RESPONSE
(unless (getf msg :payload) (unless (proto-get msg :payload)
(error "Communication Protocol Schema Error: RESPONSE missing mandatory :payload")))) (error "Communication Protocol Schema Error: RESPONSE missing mandatory :payload"))))
t)) t))