branch: elpa/gptel
commit ea39821ba5101c6f500acc96889a573cfbd65bc1
Author: Karthik Chikmagalur <karthikchikmaga...@gmail.com>
Commit: Karthik Chikmagalur <karthikchikmaga...@gmail.com>

    gptel: Add JSON output for OpenAI, Anthropic, Gemini, Ollama
    
    Activate support for structured outputs to the OpenAI, Anthropic,
    Gemini and Ollama backends.  Some other backends like llama-cpp
    and Perplexity also work because they have OpenAI-compatible APIs.
    
    Note: There are many other providers that purport to provide OpenAI
    compatible APIs that do not support structured outputs.  This
    includes Groq, Openrouter, Deepseek and others.
    
    The approach is identical for all backends: if we find a schema in
    `gptel--schema', we process it to a backend appropriate format and
    request JSON output in the payload.  `gptel--schema' is intended
    to be let-bound around calls to `gptel--request-data'.  A less
    stateful solution for `gptel--request-data' is planned for the
    future.
    
    * gptel-anthropic.el (gptel--request-data, gptel--parse-schema):
    The Anthropic API uses tool calls to (reliably) generate JSON
    output.  Define an ersatz tool and check for JSON output as a
    special case in `gptel--handle-tool-use'.  This approach has some
    disadvantages: we cannot use both tools and require JSON output
    in the same request.  Them's the breaks.
    
    In all other cases, we use a backend-appropriate flag in the
    payload to require JSON output.
    
    * gptel-gemini.el (gptel--request-data, gptel--parse-schema):
    * gptel-openai.el (gptel-tools, gptel--parse-tools)
    gptel--request-data, gptel--parse-schema):
    * gptel-ollama.el (gptel--request-data):
    
    * NEWS: Mention structured output support, the previously added
    command to copy the Curl command for a request from the dry-run
    buffer and Open WebUI support.
---
 NEWS               | 17 +++++++++++++++++
 gptel-anthropic.el | 16 ++++++++++++++++
 gptel-gemini.el    |  8 ++++++++
 gptel-ollama.el    |  5 ++++-
 gptel-openai.el    | 13 +++++++++++++
 5 files changed, 58 insertions(+), 1 deletion(-)

diff --git a/NEWS b/NEWS
index cb7e29754a..ac4f52e1fd 100644
--- a/NEWS
+++ b/NEWS
@@ -7,8 +7,25 @@
 - Add support for ~gemini-2.5-pro~, ~gemini-2.5-flash~,
   ~gemini-2.5-flash-lite-preview-06-17~.
 
+- Add support for Open WebUI.  Open WebUI provides an
+  OpenAI-compatible API, so the "support" is just a new section of the
+  README with instructions.
+
 ** New features and UI changes
 
+- Structured output support: ~gptel-request~ can now take an optional
+  schema argument to constrain LLM output to the specified JSON
+  schema.  The JSON schema can be provided as a serialized JSON string
+  or as an elisp object (a nested plist).  This feature works with all major
+  backends: OpenAI, Anthropic, Gemini, llama-cpp and Ollama.  It is
+  presently supported by some but not all "OpenAI-compatible API"
+  providers.  Note that this is only available via the ~gptel-request~
+  API, and currently unsupported by ~gptel-send~.
+
+- From the dry-run inspector buffer, you can now copy the Curl command
+  for the request.  Like when continuing the query, the request is
+  constructed from the contents of the buffer, which is editable.
+
 - gptel now handles Ollama models that return both reasoning content
   and tool calls in a single request.
 
diff --git a/gptel-anthropic.el b/gptel-anthropic.el
index 48714dc0e3..181e79f309 100644
--- a/gptel-anthropic.el
+++ b/gptel-anthropic.el
@@ -230,6 +230,13 @@ Mutate state INFO with response metadata."
                      (gptel--model-capable-p 'cache))
             (nconc (aref tools-array (1- (length tools-array)))
                    '(:cache_control (:type "ephemeral")))))))
+    (when gptel--schema
+      (plist-put prompts-plist :tools
+                 (vconcat
+                  (list (gptel--parse-schema backend gptel--schema))
+                  (plist-get prompts-plist :tools)))
+      (plist-put prompts-plist :tool_choice
+                 `(:type "tool" :name ,gptel--ersatz-json-tool)))
     ;; Merge request params with model and backend params.
     (gptel--merge-plists
      prompts-plist
@@ -237,6 +244,15 @@ Mutate state INFO with response metadata."
      (gptel-backend-request-params gptel-backend)
      (gptel--model-request-params  gptel-model))))
 
+(cl-defmethod gptel--parse-schema ((_backend gptel-anthropic) schema)
+  ;; Unlike the other backends, Anthropic generates JSON using a tool call.  We
+  ;; write the tool here, meant to be added to :tools.
+  (list
+   :name "response_json"
+   :description "Record JSON output according to user prompt"
+   :input_schema (gptel--preprocess-schema
+                  (gptel--dispatch-schema-type schema))))
+
 (cl-defmethod gptel--parse-tools ((_backend gptel-anthropic) tools)
   "Parse TOOLS to the Anthropic API tool definition spec.
 
diff --git a/gptel-gemini.el b/gptel-gemini.el
index 690edddedc..e58863b5ac 100644
--- a/gptel-gemini.el
+++ b/gptel-gemini.el
@@ -145,6 +145,9 @@ list."
     (when gptel-include-reasoning
       (setq params
             (plist-put params :thinkingConfig '(:includeThoughts t))))
+    (when gptel--schema
+      (setq params (nconc params (gptel--gemini-filter-schema
+                                  (gptel--parse-schema backend 
gptel--schema)))))
     (when params
       (plist-put prompts-plist
                  :generationConfig params))
@@ -155,6 +158,11 @@ list."
      (gptel-backend-request-params gptel-backend)
      (gptel--model-request-params  gptel-model))))
 
+(cl-defmethod gptel--parse-schema ((_backend gptel-gemini) schema)
+  (list :responseMimeType "application/json"
+        :responseSchema (gptel--preprocess-schema
+                         (gptel--dispatch-schema-type schema))))
+
 (defun gptel--gemini-filter-schema (schema)
   "Destructively filter unsupported attributes from SCHEMA.
 
diff --git a/gptel-ollama.el b/gptel-ollama.el
index 26b4b75d01..1c5a07afad 100644
--- a/gptel-ollama.el
+++ b/gptel-ollama.el
@@ -101,7 +101,10 @@ Store response metadata in state INFO."
           (gptel--merge-plists
            `(:model ,(gptel--model-name gptel-model)
              :messages [,@prompts]
-             :stream ,(or gptel-stream :json-false))
+             :stream ,(or gptel-stream :json-false)
+             ,@(and gptel--schema
+                `(:format ,(gptel--preprocess-schema
+                            (gptel--dispatch-schema-type gptel--schema)))))
            gptel--request-params
            (gptel-backend-request-params gptel-backend)
            (gptel--model-request-params  gptel-model)))
diff --git a/gptel-openai.el b/gptel-openai.el
index 36d3e4ed45..0a7554a0a0 100644
--- a/gptel-openai.el
+++ b/gptel-openai.el
@@ -40,6 +40,7 @@
 (defvar gptel-track-media)
 (defvar gptel-use-tools)
 (defvar gptel-tools)
+(defvar gptel--schema)
 (declare-function gptel-context--collect-media "gptel-context")
 (declare-function gptel--base64-encode "gptel")
 (declare-function gptel--trim-prefixes "gptel")
@@ -58,6 +59,7 @@
 (declare-function gptel-context--wrap "gptel-context")
 (declare-function gptel--inject-prompt "gptel")
 (declare-function gptel--parse-tools "gptel")
+(declare-function gptel--parse-schema "gptel")
 
 ;; JSON conversion semantics used by gptel
 ;; empty object "{}" => empty list '() == nil
@@ -307,6 +309,9 @@ Mutate state INFO with response metadata."
       (plist-put prompts-plist
                  (if reasoning-model-p :max_completion_tokens :max_tokens)
                  gptel-max-tokens))
+    (when gptel--schema
+      (plist-put prompts-plist
+                 :response_format (gptel--parse-schema backend gptel--schema)))
     ;; Merge request params with model and backend params.
     (gptel--merge-plists
      prompts-plist
@@ -314,6 +319,14 @@ Mutate state INFO with response metadata."
      (gptel-backend-request-params gptel-backend)
      (gptel--model-request-params  gptel-model))))
 
+(cl-defmethod gptel--parse-schema ((_backend gptel-openai) schema)
+  (list :type "json_schema"
+        :json_schema
+        (list :name (md5 (format "%s" (random)))
+              :schema (gptel--preprocess-schema
+                       (gptel--dispatch-schema-type schema))
+              :strict t)))
+
 ;; NOTE: No `gptel--parse-tools' method required for gptel-openai, since this 
is
 ;; handled by its defgeneric implementation
 

Reply via email to