├── .dir-locals.el ├── .elpaignore ├── .github └── ISSUE_TEMPLATE │ ├── bug-report.yaml │ ├── enhancement-or-feature-request.md │ └── support-request.md ├── .gitignore ├── .gitmodules ├── LICENSE ├── NEWS ├── README.org ├── gptel-anthropic.el ├── gptel-bedrock.el ├── gptel-context.el ├── gptel-curl.el ├── gptel-gemini.el ├── gptel-gh.el ├── gptel-integrations.el ├── gptel-kagi.el ├── gptel-ollama.el ├── gptel-openai-extras.el ├── gptel-openai.el ├── gptel-org.el ├── gptel-rewrite.el ├── gptel-transient.el └── gptel.el /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((indent-tabs-mode . nil) 2 | (sentence-end-double-space . t) 3 | (bug-reference-url-format . "https://github.com/karthink/gptel/issues/%s")))) 4 | -------------------------------------------------------------------------------- /.elpaignore: -------------------------------------------------------------------------------- 1 | test 2 | .github 3 | .elpaignore 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ["bug"] 4 | assignees: [] 5 | body: 6 | - type: checkboxes 7 | id: updated-gptel 8 | attributes: 9 | label: Please update gptel first -- errors are often fixed by the time they're reported. 10 | options: 11 | - label: I have updated gptel to the latest commit and tested that the issue still exists 12 | required: true 13 | - type: textarea 14 | id: bug-description 15 | attributes: 16 | label: Bug Description 17 | description: Provide a short description of the bug 18 | placeholder: A clear and concise description of the bug 19 | validations: 20 | required: true 21 | - type: dropdown 22 | id: backend 23 | attributes: 24 | label: Backend 25 | description: Is the issue with a particular gptel backend? (Leave blank otherwise) 26 | options: 27 | - OpenAI/Azure 28 | - Ollama 29 | - Gemini 30 | - Anthropic 31 | - Kagi 32 | - Other (please specify in Additional Context) 33 | validations: 34 | required: false 35 | - type: textarea 36 | id: reproduction-steps 37 | attributes: 38 | label: Steps to Reproduce 39 | description: Provide detailed steps to reproduce the bug 40 | placeholder: | 41 | 1. Set '...' 42 | 2. Run '....' 43 | 4. See error '...' 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: additional-context 48 | attributes: 49 | label: Additional Context 50 | description: Provide any additional context (e.g., Emacs version, operating system) 51 | placeholder: | 52 | Emacs version: 53 | Operating System: 54 | Any other information (Curl version, gptel backend etc -- only if relevant): 55 | validations: 56 | required: true 57 | - type: markdown 58 | attributes: 59 | value: "### Backtrace and log" 60 | - type: markdown 61 | attributes: 62 | value: "#### Backtrace" 63 | - type: markdown 64 | attributes: 65 | value: | 66 | If an error was signaled, please generate a backtrace as follows: 67 | 1. Run `M-x toggle-debug-on-error` 68 | 2. Reproduce the error 69 | 3. Paste the contents of the backtrace here 70 | - type: textarea 71 | id: backtrace 72 | attributes: 73 | label: Backtrace 74 | description: Paste the backtrace here 75 | render: emacs-lisp 76 | - type: markdown 77 | attributes: 78 | value: "#### Logging and simulation instructions" 79 | - type: markdown 80 | attributes: 81 | value: | 82 | Depending on the bug, it might be useful to include the data gptel sends and receives from the LLM, or to simulate a request. 83 | 84 | **To see gptel's log** 85 | 1. Turn on logging by running `(setq gptel-log-level 'info)`. Set it to `debug` for more information 86 | 2. Use gptel 87 | 3. Check the `*gptel-log*` buffer 88 | 89 | **For a gptel request dry-run** 90 | 1. Run `(setq gptel-expert-commands t)` 91 | 2. From gptel's menu (`M-x gptel-menu`), use one of the newly visible `dry run` options. 92 | - type: textarea 93 | id: log-info 94 | attributes: 95 | label: Log Information 96 | description: (If relevant) paste the relevant log information or dry-run output here 97 | placeholder: (If relevant) paste the contents of the *gptel-log* buffer or dry-run output here 98 | render: json 99 | validations: 100 | required: false 101 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-or-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement or feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **NOTE**: I have limited time to work on gptel and am prioritizing bug reports, so I might take a while to get back to you. I will eventually reply! 11 | 12 | **IF your feature request is related to a problem** 13 | A clear and concise description of what the problem is. 14 | 15 | **Describe the solution you'd like** 16 | A concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | Any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: Help or support for gptel 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Briefly describe what you are trying to do** 11 | 12 | 13 | 14 | **Additional context** 15 | Emacs version: 16 | Operating system: 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.elc 2 | gptel-autoloads.el 3 | gptel-pkg.el -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "gptel-test"] 2 | path = test 3 | url = https://github.com/karthink/gptel-test.git 4 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | # -*- mode: org; -*- 2 | 3 | * 0.9.9 4 | 5 | ** Breaking changes 6 | 7 | - ~gptel-org-branching-context~ is now a global variable. It was 8 | buffer-local by default in past releases. 9 | 10 | - The following models have been removed from the default ChatGPT backend: 11 | - ~o1-preview~: use ~o1~ instead. 12 | - ~gpt-4-turbo-preview~: use ~gpt-4o~ or ~gpt-4-turbo~ instead. 13 | - ~gpt-4-32k~, ~gpt-4-0125-preview~ and ~gpt-4-1106-preview~: use 14 | ~gpt-4o~ or ~gpt-4~ instead. 15 | Alternatively, you can add these models back to the backend in your 16 | personal configuration: 17 | #+begin_src emacs-lisp 18 | (push 'gpt-4-turbo-preview 19 | (gptel-backend-models (gptel-get-backend "ChatGPT"))) 20 | #+end_src 21 | 22 | - Only relevant if you use ~gptel-request~ in your elisp code, 23 | /interactive gptel usage is unaffected/: ~gptel-request~ now takes a 24 | new, optional =:transforms= argument. Any prompt modifications 25 | (like adding context to requests) must now be specified via this 26 | argument. See the definition of ~gptel-send~ for an example. 27 | 28 | ** New models and backends 29 | 30 | - Add support for ~gpt-4.1~, ~gpt-4.1-mini~, ~gpt-4.1-nano~, ~o3~ and 31 | ~o4-mini~. 32 | 33 | - Add support for ~gemini-2.5-pro-exp-03-25~, 34 | ~gemini-2.5-flash-preview-04-17~, ~gemini-2.5-pro-preview-05-06~ and 35 | ~gemini-2.5-pro-preview-06-05~. 36 | 37 | - Add support for ~claude-sonnet-4-20250514~ and 38 | ~claude-opus-4-20250514~. 39 | 40 | - Add support for AWS Bedrock models. You can create an AWS Bedrock 41 | gptel backend with ~gptel-make-bedrock~, which see. Please note: 42 | AWS Bedrock support requires Curl 8.5.0 or higher. 43 | 44 | - You can now create an xAI backend with ~gptel-make-xai~, which see. 45 | (xAI was supported before but the model configuration is now handled 46 | for you by this function.) 47 | 48 | - Add support for GitHub Copilot Chat. See the README and 49 | ~gptel-make-gh-copilot~. Please note: this is only the chat 50 | component of GitHub Copilot. Copilot's ~completion-at-point~ 51 | (tab-completion) functionality is not supported by gptel. 52 | 53 | - Add support for Sambanova. This is an OpenAI-compatible API so you 54 | can create a backend with ~gptel-make-openai~, see the README for 55 | details. 56 | 57 | - Add support for Mistral Le Chat. This is an an OpenAI-compatible 58 | API so you can create a backend with ~gptel-make-openai~, see the 59 | README for details. 60 | 61 | ** New features and UI changes 62 | 63 | - gptel now supports handling reasoning/thinking blocks in responses 64 | from Gemini models. This is controlled by 65 | ~gptel-include-reasoning~, in the same way that it handles other 66 | APIs. 67 | 68 | - The new option ~gptel-curl-extra-args~ can be used to specify extra 69 | arguments to the Curl command used for the request. This is the 70 | global version of the gptel-backend-specific ~:curl-args~ slot, 71 | which can be used to specify Curl arguments when using a specific 72 | backend. 73 | 74 | - Tools now run in the buffer from which the request originates. This 75 | can be significant when tools read or manipulate Emacs' state. 76 | 77 | - gptel can access MCP server tools by integrating with the mcp.el 78 | package, which is at https://github.com/lizqwerscott/mcp.el. 79 | (mcp.el is available on MELPA.) To help with the integration, two 80 | new commands are provided: ~gptel-mcp-connect~ and 81 | ~gptel-mcp-disconnect~. You can use these to start MCP servers 82 | selectively and add tools to gptel. These commands are also 83 | available from gptel's tools menu. 84 | 85 | These commands are currently not autoloaded by gptel. To access 86 | them, require the ~gptel-integrations~ feature. 87 | 88 | - You can now define "presets", which are a bundle of gptel options, 89 | such as the backend, model, system message, included tools, 90 | temperature and so on. This set of options can be applied together, 91 | making it easy to switch between different tasks using gptel. From 92 | gptel's transient menu, you can save the current configuration as a 93 | preset or apply another one. Presets can be applied globally, 94 | buffer-locally or for the next request only. To persist presets 95 | across Emacs sessions, define presets in your configuration using 96 | ~gptel-make-preset~. 97 | 98 | - When using ~gptel-send~ from anywhere in Emacs, you can now include 99 | a "cookie" of the form =@preset-name= in the prompt text to apply 100 | that preset before sending. The preset is applied for that request 101 | only. This is an easy way to specify models, tools, system 102 | messages (etc) on the fly. In chat buffers the preset cookie is 103 | fontified and available for completion via ~completion-at-point~. 104 | 105 | - For scripting purposes, provide a ~gptel-with-preset~ macro to 106 | create an environment with a preset applied. 107 | 108 | - Links to plain-text files in chat buffers can be followed, and their 109 | contents included with the request. Using Org or Markdown links is 110 | an easy, intuitive, persistent and buffer-local way to specify 111 | context. To enable this behavior, turn on ~gptel-track-media~. This 112 | is a pre-existing option that also controls whether image/document 113 | links are followed and sent (when the model supports it). 114 | 115 | - A new hook ~gptel-prompt-transform-functions~ is provided for 116 | arbitrary transformations of the prompt prior to sending a request. 117 | This hook runs in a temporary buffer containing the text to be sent. 118 | Any aspect of the request (the text, destination, request 119 | parameters, response handling preferences) can be modified 120 | buffer-locally here. These hook functions can be asynchronous. 121 | 122 | - The user option ~gptel-use-curl~ can now be used to specify a Curl 123 | path. 124 | 125 | - The current kill can be added to gptel's context. To enable this, 126 | turn on ~gptel-expert-commands~ and use gptel's transient menu. 127 | 128 | ** Notable Bug fixes 129 | 130 | - Fix more Org markup conversion edge cases involving nested Markdown 131 | delimiters. 132 | 133 | * 0.9.8 2025-03-13 134 | 135 | Version 0.9.8 adds support for new Gemini, Anthropic, OpenAI, 136 | Perplexity, and DeepSeek models, introduces LLM tool use/function 137 | calling, a redesign of ~gptel-menu~, includes new customization hooks, 138 | dry-run options and refined settings, improvements to the rewrite 139 | feature and control of LLM "reasoning" content. 140 | 141 | ** Breaking changes 142 | 143 | - ~gemini-pro~ has been removed from the list of Gemini models, as 144 | this model is no longer supported by the Gemini API. 145 | 146 | - Sending an active region in Org mode will now apply Org 147 | mode-specific rules to the text, such as branching context. 148 | 149 | - The following obsolete variables and functions have been removed: 150 | - ~gptel-send-menu~: Use ~gptel-menu~ instead. 151 | - ~gptel-host~: Use ~gptel-make-openai~ instead. 152 | - ~gptel-playback~: Use ~gptel-stream~ instead. 153 | - ~gptel--debug~: Use ~gptel-log-level~ instead. 154 | 155 | ** New models and backends 156 | 157 | - Add support for several new Gemini models including 158 | ~gemini-2.0-flash~, ~gemini-2.0-pro-exp~ and 159 | ~gemini-2.0-flash-thinking-exp~, among others. 160 | 161 | - Add support for the Anthropic model ~claude-3-7-sonnet-20250219~, 162 | including its "reasoning" output. 163 | 164 | - Add support for OpenAI's ~o1~, ~o3-mini~ and ~gpt-4.5-preview~ 165 | models. 166 | 167 | - Add support for Perplexity. While gptel supported Perplexity in 168 | earlier releases by reusing its OpenAI support, there is now first 169 | class support for the Perplexity API, including citations. 170 | 171 | - Add support for DeepSeek. While gptel supported DeepSeek in earlier 172 | releases by reusing its OpenAI support, there is now first class 173 | support for the DeepSeek API, including support for handling 174 | "reasoning" output. 175 | 176 | ** New features and UI changes 177 | 178 | - ~gptel-rewrite~ now supports iterating on responses. 179 | 180 | - gptel supports the ability to simulate/dry-run requests so you can 181 | see exactly what will be sent. This payload preview can now be 182 | edited in place and the request continued. 183 | 184 | - Directories can now be added to gptel's global context. Doing so 185 | will add all files in the directory recursively. 186 | 187 | - "Oneshot" settings: when using gptel's Transient menus, request 188 | parameters, directives and tools can now be set for the next request 189 | only in addition to globally across the Emacs session and 190 | buffer-locally. This is useful for making one-off requests with 191 | different settings. 192 | 193 | - ~gptel-mode~ can now be used in all modes derived from ~text-mode~. 194 | 195 | - gptel now tries to handle LLM responses that are in mixed 196 | Org/Markdown markup correctly. 197 | 198 | - Add ~gptel-org-convert-response~ to toggle the automatic conversion 199 | of (possibly) Markdown-formatted LLM responses to Org markup where 200 | appropriate. 201 | 202 | - You can now look up registered gptel backends using the 203 | ~gptel-get-backend~ function. This is intended to make scripting 204 | and configuring gptel easier. ~gptel-get-backend~ is a generalized 205 | variable so you can (un)set backends with ~setf~. 206 | 207 | - Tool use: gptel now supports LLM tool use, or function calling. 208 | Essentially you can equip the LLM with capabilities (such as 209 | filesystem access, web search, control of Emacs or introspection of 210 | Emacs' state and more) that it can use to perform tasks for you. 211 | gptel runs these tools using argument values provided by the LLMs. 212 | This requires specifying tools, which are elisp functions with plain 213 | text descriptions of their arguments and results. gptel does not 214 | include any tools out of the box yet. 215 | 216 | - You can look up registered gptel tools using the ~gptel-get-tool~ 217 | function. This is intended to make scripting and configuring gptel 218 | easier. ~gptel-get-tool~ is a generalized variable so you can 219 | (un)set tools with ~setf~. 220 | 221 | - New hooks for customization: 222 | + ~gptel-prompt-filter-hook~ runs in a temporary buffer containing 223 | the text to be sent, before the full query is created. It can be 224 | used for arbitrary text transformations to the source text. 225 | + ~gptel-post-request-hook~ runs after the request is sent, and 226 | (possibly) before any response is received. This is intended for 227 | preparatory/reset code. 228 | + ~gptel-post-rewrite-hook~ runs after a ~gptel-rewrite~ request is 229 | successfully and fully received. 230 | 231 | - ~gptel-menu~ has been redesigned. It now shows a verbose 232 | description of what will be sent and where the output will go. This 233 | is intended to provide clarity on gptel's default prompting 234 | behavior, as well as the effect of the various prompt/response 235 | redirection it provides. Incompatible combinations of options are 236 | now disallowed. 237 | 238 | - The spacing between the end of the prompt and the beginning of the 239 | response in buffers is now customizable via 240 | ~gptel-response-separator~, and can be any string. 241 | 242 | - ~gptel-context-remove-all~ is now an interactive command. 243 | 244 | - gptel now handles "reasoning" content produced by LLMs. Some LLMs 245 | include in their response a "thinking" or "reasoning" section. This 246 | text improves the quality of the LLM’s final output, but may not be 247 | interesting to you by itself. The new user option 248 | ~gptel-include-reasoning~ controls whether and how gptel displays 249 | this content. 250 | 251 | - (Anthropic API only) Some LLM backends can cache content sent to it 252 | by gptel, so that only the newly included part of the text needs to 253 | be processed on subsequent conversation turns. This results in 254 | faster and significantly cheaper processing. The new user option 255 | ~gptel-cache~ can be used to specify caching preferences for 256 | prompts, the system message and/or tool definitions. This is 257 | supported only by the Anthropic API right now. 258 | 259 | - (Org mode) Org property drawers are now stripped from the prompt 260 | text before sending queries. You can control this behavior or 261 | specify additional Org elements to ignore via 262 | ~gptel-org-ignore-elements~. (For more complex pre-processing you 263 | can use ~gptel-prompt-filter-hook~.) 264 | 265 | ** Notable Bug fixes 266 | 267 | - Fix response mix-up when running concurrent requests in Org mode 268 | buffers. 269 | - gptel now works around an Org fontification bug where streaming 270 | responses in Org mode buffers sometimes caused source code blocks to 271 | remain unfontified. 272 | 273 | * 0.9.7 2024-12-04 274 | 275 | Version 0.9.7 adds dynamic directives, a better rewrite interface, 276 | streaming support to the gptel request API, and more flexible 277 | model/backend configuration. 278 | 279 | ** Breaking changes 280 | ~gptel-rewrite-menu~ has been obsoleted. Use ~gptel-rewrite~ instead. 281 | 282 | ** Backends 283 | - Add support for OpenAI's ~o1-preview~ and ~o1-mini~. 284 | 285 | - Add support for Anthropic's Claude 3.5 Haiku. 286 | 287 | - Add support for xAI. 288 | 289 | - Add support for Novita AI. 290 | 291 | ** New features and UI changes 292 | 293 | - gptel's directives (see ~gptel-directives~) can now be dynamic, and 294 | include more than the system message. You can "pre-fill" a 295 | conversation with canned user/LLM messages. Directives can now be 296 | functions that dynamically generate the system message and 297 | conversation history based on the current context. This paves the 298 | way for fully flexible task-specific templates, which the UI does 299 | not yet support in full. 300 | 301 | - gptel's rewrite interface has been reworked. If using a streaming 302 | endpoint, the rewritten text is streamed in as a preview placed over 303 | the original. In all cases, clicking on the preview brings up a 304 | dispatch you can use to easily diff, ediff, merge, accept or reject 305 | the changes (4ae9c1b2), and you can configure gptel to run one of 306 | these actions automatically. See the README for examples. 307 | 308 | - ~gptel-abort~, used to cancel requests in progress, now works across 309 | the board, including when not using Curl or with ~gptel-rewrite~. 310 | 311 | - The ~gptel-request~ API now explicitly supports streaming responses 312 | , making it easy to write your own helpers or features with 313 | streaming support. The API also supports ~gptel-abort~ to stop and 314 | clean up responses. 315 | 316 | - You can now unset the system message -- different from setting it to 317 | an empty string. gptel will also automatically disable the system 318 | message when using models that don't support it. 319 | 320 | - Support for including PDFs with requests to Anthropic models has 321 | been added. (These queries are cached, so you pay only 10% of the 322 | token cost of the PDF in follow-up queries.) Note that document 323 | support (PDFs etc) for Gemini models has been available since 324 | v0.9.5. 325 | 326 | - When defining a gptel model or backend, you can specify arbitrary 327 | parameters to be sent with each request. This includes the (many) 328 | API options across all APIs that gptel does not yet provide explicit 329 | support for. 330 | 331 | - New transient command option to easily remove all included context 332 | chunks. 333 | 334 | ** Notable Bug fixes 335 | - Pressing ~RET~ on included files in the context inspector buffer now 336 | pops up the file correctly. 337 | - API keys are stripped of whitespace before sending. 338 | - Multiple UI, backend and prompt construction bugs have been fixed. 339 | -------------------------------------------------------------------------------- /gptel-context.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-context.el --- Context aggregator for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: daedsidog 6 | ;; Keywords: convenience, buffers 7 | 8 | ;; SPDX-License-Identifier: GPL-3.0-or-later 9 | 10 | ;; This program is free software; you can redistribute it and/or modify 11 | ;; it under the terms of the GNU General Public License as published by 12 | ;; the Free Software Foundation, either version 3 of the License, or 13 | ;; (at your option) any later version. 14 | 15 | ;; This program is distributed in the hope that it will be useful, 16 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | ;; GNU General Public License for more details. 19 | 20 | ;; You should have received a copy of the GNU General Public License 21 | ;; along with this program. If not, see . 22 | 23 | ;;; Commentary: 24 | 25 | ;; The context allows you to conveniently create contexts which can be fed 26 | ;; to gptel. 27 | 28 | ;;; Code: 29 | 30 | ;;; -*- lexical-binding: t -*- 31 | (require 'gptel) 32 | (require 'cl-lib) 33 | 34 | (declare-function gptel-menu "gptel-transient") 35 | (declare-function dired-get-marked-files "dired") 36 | (declare-function image-file-name-regexp "image-file") 37 | (declare-function create-image "image") 38 | 39 | (defface gptel-context-highlight-face 40 | '((((background dark) (min-colors 88)) :background "gray4" :extend t) 41 | (((background light) (min-colors 88)) :background "alice blue" :extend t) 42 | (t :inherit mode-line)) 43 | "Face used to highlight gptel contexts in buffers." 44 | :group 'gptel) 45 | 46 | (defface gptel-context-deletion-face 47 | '((((class color) (min-colors 257) (background light)) 48 | :background "#ffeeee" :extend t) 49 | (((class color) (min-colors 88) (background light)) 50 | :background "#ffdddd" :extend t) 51 | (((class color) (min-colors 88) (background dark)) 52 | :background "#553333" :extend t) 53 | (((class color)) :foreground "red" :extend t)) 54 | "Face used to highlight gptel contexts to be deleted. 55 | 56 | This is used in gptel context buffers." 57 | :group 'gptel) 58 | 59 | (defvar gptel-context-wrap-function nil 60 | "Function to format the context string sent with the gptel request.") 61 | (make-obsolete-variable 62 | 'gptel-context-wrap-function 63 | "Custom functions for wrapping context are no longer supported by gptel.\ 64 | See `gptel-context--wrap-in-buffer' for details." 65 | "0.9.9") 66 | 67 | (defcustom gptel-context-string-function #'gptel-context--string 68 | "Function to prepare the context string sent with the gptel request. 69 | 70 | This function can be synchronous or asynchronous, and receives one or 71 | two arguments respectively. 72 | 73 | Synchronous: An alist of contexts with buffers or files (the context 74 | alist). 75 | Asynchronous: A callback to call with the result, and the context alist. 76 | 77 | The context alist is structured as follows: 78 | 79 | ((buffer1 . (overlay1 overlay2) 80 | (\"path/to/file\") 81 | (buffer2 . (overlay3 overlay4 overlay5)) 82 | (\"path/to/image/file\" :mime \"image/jpeg\"))) 83 | 84 | Each gptel \"context\" is either a file path or an overlay in a 85 | buffer. Each overlay covers a buffer region containing the 86 | context chunk. This is accessible as, for example: 87 | 88 | (with-current-buffer buffer1 89 | (buffer-substring (overlay-start overlay1) 90 | (overlay-end overlay1)))" 91 | :group 'gptel 92 | :type 'function) 93 | 94 | (defun gptel-context-add-current-kill (&optional arg) 95 | "Add current-kill to gptel, accumulating if arg is non-nil" 96 | (interactive "P") 97 | (let ((kill (current-kill 0))) 98 | (with-current-buffer (get-buffer-create " *gptel-kill-ring-context*") 99 | (if (not arg) 100 | (kill-region (point-min) (point-max)) 101 | (goto-char (point-max)) 102 | (unless (bobp) 103 | (insert "\n----\n"))) 104 | (insert kill) 105 | (gptel-context--add-region (current-buffer) 106 | (point-min) (point-max)) 107 | (message "*current-kill* has been added as context.")))) 108 | 109 | (defun gptel-context-add (&optional arg confirm) 110 | "Add context to gptel in a DWIM fashion. 111 | 112 | - If a region is selected, add the selected region to the 113 | context. If there is already a gptel context at point, remove it 114 | instead. 115 | 116 | - If in Dired, add marked files or file at point to the context. If 117 | the selection includes directories, add all their files recursively, 118 | prompting the user for confirmation if called interactively or 119 | CONFIRM is non-nil. With negative prefix ARG, remove all files from 120 | the context instead. 121 | 122 | - Otherwise add the current buffer to the context. With positive 123 | prefix ARG, prompt for a buffer name and add it to the context. 124 | 125 | - With negative prefix ARG, remove all gptel contexts from the current 126 | buffer, prompting the user for confirmation if called interactively 127 | or CONFIRM is non-nil." 128 | (interactive "P\np") 129 | (cond 130 | ;; A region is selected. 131 | ((use-region-p) 132 | (gptel-context--add-region (current-buffer) 133 | (region-beginning) 134 | (region-end)) 135 | (deactivate-mark) 136 | (message "Current region added as context.")) 137 | ;; If in dired 138 | ((derived-mode-p 'dired-mode) 139 | (let* ((files (dired-get-marked-files)) 140 | (dirs (cl-remove-if-not #'file-directory-p files)) 141 | (remove-p (< (prefix-numeric-value arg) 0)) 142 | (action-fn (if remove-p 143 | #'gptel-context-remove 144 | #'gptel-context-add-file))) 145 | (when (or remove-p (null dirs) (null confirm) 146 | (y-or-n-p (format "Recursively add files from %d director%s? " 147 | (length dirs) 148 | (if (= (length dirs) 1) "y" "ies")))) 149 | (mapc action-fn files)))) 150 | ;; If in an image buffer 151 | ((and (derived-mode-p 'image-mode) 152 | (gptel--model-capable-p 'media) 153 | (buffer-file-name)) 154 | (funcall (if (and arg (< (prefix-numeric-value arg) 0)) 155 | #'gptel-context-remove 156 | #'gptel-context-add-file) 157 | (buffer-file-name))) 158 | ;; No region is selected, and ARG is positive. 159 | ((and arg (> (prefix-numeric-value arg) 0)) 160 | (let* ((buffer-name (read-buffer "Choose buffer to add as context: " 161 | (current-buffer) t)) 162 | (start (with-current-buffer buffer-name (point-min))) 163 | (end (with-current-buffer buffer-name (point-max)))) 164 | (gptel-context--add-region 165 | (get-buffer buffer-name) start end t) 166 | (message "Buffer '%s' added as context." buffer-name))) 167 | ;; No region is selected, and ARG is negative. 168 | ((and arg (< (prefix-numeric-value arg) 0)) 169 | (when (or (null confirm) 170 | (y-or-n-p "Remove all contexts from this buffer? ")) 171 | (let ((removed-contexts 0)) 172 | (cl-loop for cov in 173 | (gptel-context--in-region (current-buffer) (point-min) (point-max)) 174 | do (progn 175 | (cl-incf removed-contexts) 176 | (gptel-context-remove cov))) 177 | (message (format "%d context%s removed from current buffer." 178 | removed-contexts 179 | (if (= removed-contexts 1) "" "s")))))) 180 | (t ; Default behavior 181 | (if (gptel-context--at-point) 182 | (progn 183 | (gptel-context-remove (car (gptel-context--in-region (current-buffer) 184 | (max (point-min) (1- (point))) 185 | (point)))) 186 | (message "Context under point has been removed.")) 187 | (gptel-context--add-region (current-buffer) (point-min) (point-max) t) 188 | (message "Current buffer added as context."))))) 189 | 190 | ;;;###autoload (autoload 'gptel-add "gptel-context" "Add/remove regions or buffers from gptel's context." t) 191 | (defalias 'gptel-add #'gptel-context-add) 192 | 193 | (defun gptel-context--add-text-file (path) 194 | "Add text file at PATH to context." 195 | (cl-pushnew (list path) gptel-context--alist :test #'equal) 196 | (message "File \"%s\" added to context." path) 197 | path) 198 | 199 | (defun gptel-context--add-binary-file (path) 200 | "Add binary file at PATH to context if supported. 201 | Return PATH if added, nil if ignored." 202 | (if-let* (((gptel--model-capable-p 'media)) 203 | (mime (mailcap-file-name-to-mime-type path)) 204 | ((gptel--model-mime-capable-p mime))) 205 | (prog1 path 206 | (cl-pushnew (list path :mime mime) 207 | gptel-context--alist :test #'equal) 208 | (message "File \"%s\" added to context." path)) 209 | (message "Ignoring unsupported binary file \"%s\"." path) 210 | nil)) 211 | 212 | (defun gptel-context--add-directory (path action) 213 | "Process all files in directory at PATH according to ACTION. 214 | ACTION should be either `add' or `remove'." 215 | (let ((files (directory-files-recursively path "." t))) 216 | (mapc (lambda (file) 217 | (unless (file-directory-p file) 218 | (pcase-exhaustive action 219 | ('add 220 | (if (gptel--file-binary-p file) 221 | (gptel-context--add-binary-file file) 222 | (gptel-context--add-text-file file))) 223 | ('remove 224 | (setf (alist-get file gptel-context--alist nil 'remove #'equal) nil))))) 225 | files) 226 | (when (eq action 'remove) 227 | (message "Directory \"%s\" removed from context." path)))) 228 | 229 | (defun gptel-context-add-file (path) 230 | "Add the file at PATH to the gptel context. 231 | 232 | If PATH is a directory, recursively add all files in it. 233 | PATH should be readable as text." 234 | (interactive "fChoose file to add to context: ") 235 | (cond ((file-directory-p path) 236 | (gptel-context--add-directory path 'add)) 237 | ((gptel--file-binary-p path) 238 | (gptel-context--add-binary-file path)) 239 | ((gptel-context--add-text-file path)))) 240 | 241 | ;;;###autoload (autoload 'gptel-add-file "gptel-context" "Add files to gptel's context." t) 242 | (defalias 'gptel-add-file #'gptel-context-add-file) 243 | 244 | (defun gptel-context-remove (&optional context) 245 | "Remove the CONTEXT overlay from the contexts list. 246 | 247 | If CONTEXT is nil, removes the context at point. 248 | If selection is active, removes all contexts within selection. 249 | If CONTEXT is a directory, recursively removes all files in it." 250 | (cond 251 | ((overlayp context) 252 | (delete-overlay context) 253 | ;; FIXME: Quadratic cost when clearing a bunch of contexts at once 254 | (unless 255 | (cl-loop 256 | for ov in (alist-get (current-buffer) gptel-context--alist) 257 | thereis (overlay-start ov)) 258 | (setf (alist-get (current-buffer) gptel-context--alist nil 'remove) nil))) 259 | ((stringp context) ;file or directory 260 | (if (file-directory-p context) 261 | (gptel-context--add-directory context 'remove) 262 | (setf (alist-get context gptel-context--alist nil 'remove #'equal) nil) 263 | (message "File \"%s\" removed from context." context))) 264 | ((region-active-p) 265 | (when-let* ((contexts (gptel-context--in-region (current-buffer) 266 | (region-beginning) 267 | (region-end)))) 268 | (cl-loop for ctx in contexts do (delete-overlay ctx)))) 269 | (t 270 | (when-let* ((ctx (gptel-context--at-point))) 271 | (delete-overlay ctx))))) 272 | 273 | (defun gptel-context-remove-all (&optional verbose) 274 | "Remove all gptel context. 275 | 276 | If VERBOSE is non-nil, ask for confirmation and message 277 | afterwards." 278 | (interactive (list t)) 279 | (if (null gptel-context--alist) 280 | (when verbose (message "No gptel context sources to remove.")) 281 | (when (or (not verbose) (y-or-n-p "Remove all context? ")) 282 | (cl-loop 283 | for (source . ovs) in gptel-context--alist 284 | if (bufferp source) do ;Buffers and buffer regions 285 | (mapc #'gptel-context-remove ovs) 286 | else do (gptel-context-remove source) ;files or other types 287 | finally do (setq gptel-context--alist nil))) 288 | (when verbose (message "Removed all gptel context sources.")))) 289 | 290 | (defun gptel-context--make-overlay (start end &optional advance) 291 | "Highlight the region from START to END. 292 | 293 | ADVANCE controls the overlay boundary behavior." 294 | (let ((overlay (make-overlay start end nil (not advance) advance))) 295 | (overlay-put overlay 'evaporate t) 296 | (overlay-put overlay 'face 'gptel-context-highlight-face) 297 | (overlay-put overlay 'gptel-context t) 298 | (push overlay (alist-get (current-buffer) 299 | gptel-context--alist)) 300 | overlay)) 301 | 302 | ;;;###autoload 303 | (defun gptel-context--wrap (callback data-buf) 304 | "Add request context to DATA-BUF and run CALLBACK. 305 | 306 | DATA-BUF is the buffer where the request prompt is constructed." 307 | (if (= (car (func-arity gptel-context-string-function)) 2) 308 | (funcall gptel-context-string-function 309 | (lambda (c) (with-current-buffer data-buf 310 | (gptel-context--wrap-in-buffer c)) 311 | (funcall callback)) 312 | (gptel-context--collect)) 313 | (with-current-buffer data-buf 314 | (thread-last (gptel-context--collect) 315 | (funcall gptel-context-string-function) 316 | (gptel-context--wrap-in-buffer))) 317 | (funcall callback))) 318 | 319 | (defun gptel-context--wrap-in-buffer (context-string &optional method) 320 | "Inject CONTEXT-STRING to current buffer using METHOD. 321 | 322 | METHOD is either system or user, and defaults to `gptel-use-context'. 323 | This modifies the buffer." 324 | (when (length> context-string 0) 325 | (pcase (or method gptel-use-context) 326 | ('system 327 | (if (gptel--model-capable-p 'nosystem) 328 | (gptel-context--wrap-in-buffer context-string 'user) 329 | (if gptel--system-message 330 | (cl-etypecase gptel--system-message 331 | (string 332 | (setq gptel--system-message 333 | (concat gptel--system-message "\n\n" context-string))) 334 | (function 335 | (setq gptel--system-message 336 | (gptel--parse-directive gptel--system-message 'raw)) 337 | (gptel-context--wrap-in-buffer context-string)) 338 | (list 339 | (setq gptel--system-message ;cons a new list to avoid mutation 340 | (cons (concat (car gptel--system-message) "\n\n" context-string) 341 | (cdr gptel--system-message))))) 342 | (setq gptel--system-message context-string)))) 343 | ('user 344 | (goto-char (point-max)) 345 | (text-property-search-backward 'gptel nil t) 346 | (and gptel-mode 347 | (looking-at 348 | (concat "[\n[:blank:]]*" 349 | (and-let* ((prefix (gptel-prompt-prefix-string)) 350 | ((not (string-empty-p prefix)))) 351 | (concat "\\(?:" (regexp-quote prefix) "\\)?")))) 352 | (delete-region (match-beginning 0) (match-end 0))) 353 | (insert "\n" context-string "\n\n"))))) 354 | 355 | (defun gptel-context--collect-media (&optional contexts) 356 | "Collect media CONTEXTS. 357 | 358 | CONTEXTS, which are typically paths to binary files, are 359 | base64-encoded and prepended to the first user prompt." 360 | (cl-loop for context in (or contexts gptel-context--alist) 361 | for (path . props) = context 362 | when (and (stringp path) (plist-get props :mime)) 363 | collect (cons :media context))) 364 | 365 | (cl-defun gptel-context--add-region (buffer region-beginning region-end &optional advance) 366 | "Add region delimited by REGION-BEGINNING, REGION-END in BUFFER as context. 367 | 368 | If ADVANCE is non-nil, the context overlay envelopes changes at 369 | the beginning and end." 370 | ;; Remove existing contexts in the same region, if any. 371 | (mapc #'gptel-context-remove 372 | (gptel-context--in-region buffer region-beginning region-end)) 373 | (prog1 (with-current-buffer buffer 374 | (gptel-context--make-overlay region-beginning region-end advance)) 375 | (message "Region added to context buffer."))) 376 | 377 | (defun gptel-context--in-region (buffer start end) 378 | "Return the list of context overlays in the given region, if any, in BUFFER. 379 | START and END signify the region delimiters." 380 | (with-current-buffer buffer 381 | (cl-remove-if-not (lambda (ov) (overlay-get ov 'gptel-context)) 382 | (overlays-in start end)))) 383 | 384 | (defun gptel-context--at-point () 385 | "Return the context overlay at point, if any." 386 | (cl-find-if (lambda (ov) (overlay-get ov 'gptel-context)) 387 | (overlays-at (point)))) 388 | 389 | ;;;###autoload 390 | (defun gptel-context--collect () 391 | "Get the list of all active context overlays." 392 | ;; Get only the non-degenerate overlays, collect them, and update the overlays variable. 393 | (setq gptel-context--alist 394 | (cl-loop for (buf . ovs) in gptel-context--alist 395 | if (buffer-live-p buf) 396 | if (cl-loop for ov in ovs when (overlay-start ov) collect ov) 397 | collect (cons buf it) into elements 398 | end 399 | else if (and (stringp buf) (file-exists-p buf)) 400 | if (plist-get ovs :mime) 401 | collect (cons buf ovs) into elements 402 | else collect (list buf) into elements 403 | finally return elements))) 404 | 405 | (defun gptel-context--insert-buffer-string (buffer contexts) 406 | "Insert at point a context string from all CONTEXTS in BUFFER." 407 | (let ((is-top-snippet t) 408 | (previous-line 1)) 409 | (insert (format "In buffer `%s`:" (buffer-name buffer)) 410 | "\n\n```" (gptel--strip-mode-suffix (buffer-local-value 411 | 'major-mode buffer)) 412 | "\n") 413 | (dolist (context contexts) 414 | (let* ((start (overlay-start context)) 415 | (end (overlay-end context)) 416 | content) 417 | (let (lineno column) 418 | (with-current-buffer buffer 419 | (without-restriction 420 | (setq lineno (line-number-at-pos start t) 421 | column (save-excursion (goto-char start) 422 | (current-column)) 423 | content (buffer-substring-no-properties start end)))) 424 | ;; We do not need to insert a line number indicator if we have two regions 425 | ;; on the same line, because the previous region should have already put the 426 | ;; indicator. 427 | (unless (= previous-line lineno) 428 | (unless (= lineno 1) 429 | (unless is-top-snippet 430 | (insert "\n")) 431 | (insert (format "... (Line %d)\n" lineno)))) 432 | (setq previous-line lineno) 433 | (unless (zerop column) (insert " ...")) 434 | (if is-top-snippet 435 | (setq is-top-snippet nil) 436 | (unless (= previous-line lineno) (insert "\n")))) 437 | (insert content))) 438 | (unless (>= (overlay-end (car (last contexts))) (point-max)) 439 | (insert "\n...")) 440 | (insert "\n```"))) 441 | 442 | (defun gptel-context--string (context-alist) 443 | "Format the aggregated gptel context as annotated markdown fragments. 444 | 445 | Returns a string. CONTEXT-ALIST is a structure containing 446 | context overlays, see `gptel-context--alist'." 447 | (with-temp-buffer 448 | (cl-loop for (buf . ovs) in context-alist 449 | if (bufferp buf) 450 | do (gptel-context--insert-buffer-string buf ovs) 451 | else if (not (plist-get ovs :mime)) 452 | do (gptel--insert-file-string buf) end 453 | do (insert "\n\n") 454 | finally do 455 | (skip-chars-backward "\n\t\r ") 456 | (delete-region (point) (point-max)) 457 | (unless (bobp) 458 | (goto-char (point-min)) 459 | (insert "Request context:\n\n")) 460 | finally return 461 | (and (> (buffer-size) 0) 462 | (buffer-string))))) 463 | 464 | ;;; Major mode for context inspection buffers 465 | (defvar-keymap gptel-context-buffer-mode-map 466 | "C-c C-c" #'gptel-context-confirm 467 | "C-c C-k" #'gptel-context-quit 468 | "RET" #'gptel-context-visit 469 | "n" #'gptel-context-next 470 | "p" #'gptel-context-previous 471 | "d" #'gptel-context-flag-deletion) 472 | 473 | (define-derived-mode gptel-context-buffer-mode special-mode "gptel-context" 474 | "Major-mode for inspecting context used by gptel." 475 | :group 'gptel 476 | (add-hook 'post-command-hook #'gptel-context--post-command 477 | nil t) 478 | (setq-local revert-buffer-function #'gptel-context--buffer-setup)) 479 | 480 | (defun gptel-context--buffer-setup (&optional _ignore-auto _noconfirm) 481 | "Set up the gptel context buffer." 482 | (with-current-buffer (get-buffer-create "*gptel-context*") 483 | (gptel-context-buffer-mode) 484 | (let ((inhibit-read-only t)) 485 | (erase-buffer) 486 | (setq header-line-format 487 | (substitute-command-keys 488 | (concat 489 | "\\[gptel-context-flag-deletion]: Mark/unmark deletion, " 490 | "\\[gptel-context-next]/\\[gptel-context-previous]: next/previous, " 491 | "\\[gptel-context-visit]: visit, " 492 | "\\[gptel-context-confirm]: apply, " 493 | "\\[gptel-context-quit]: cancel, " 494 | "\\[quit-window]: quit"))) 495 | (save-excursion 496 | (let ((contexts gptel-context--alist)) 497 | (if (length> contexts 0) 498 | (let (beg ov l1 l2) 499 | (pcase-dolist (`(,buf . ,ovs) contexts) 500 | (if (bufferp buf) 501 | ;; It's a buffer with some overlay(s) 502 | (dolist (source-ov ovs) 503 | (with-current-buffer buf 504 | (setq l1 (line-number-at-pos (overlay-start source-ov)) 505 | l2 (line-number-at-pos (overlay-end source-ov)))) 506 | (insert (propertize (format "In buffer %s (lines %d-%d):\n\n" 507 | (buffer-name buf) l1 l2) 508 | 'face 'bold)) 509 | (setq beg (point)) 510 | (insert-buffer-substring 511 | buf (overlay-start source-ov) (overlay-end source-ov)) 512 | (insert "\n") 513 | (setq ov (make-overlay beg (point))) 514 | (overlay-put ov 'gptel-context source-ov) 515 | (overlay-put ov 'gptel-overlay t) 516 | (overlay-put ov 'evaporate t) 517 | (insert "\n" (make-separator-line) "\n")) 518 | ;; BUF is a file path, not a buffer 519 | (insert (propertize (format "In file %s:\n\n" (file-name-nondirectory buf)) 520 | 'face 'bold)) 521 | (setq beg (point)) 522 | (if-let* ((mime (plist-get ovs :mime))) 523 | ;; BUF is a binary file 524 | (if-let* (((string-match-p (image-file-name-regexp) buf)) 525 | (img (create-image buf))) 526 | (insert-image img "*") ; Can be displayed 527 | (insert 528 | buf " " (propertize "(No preview for binary file)" 529 | 'face '(:inherit shadow :slant italic)))) 530 | (insert-file-contents buf)) 531 | (goto-char (point-max)) 532 | (insert "\n") 533 | (setq ov (make-overlay beg (point))) 534 | (overlay-put ov 'gptel-context buf) 535 | (overlay-put ov 'gptel-overlay t) 536 | (overlay-put ov 'evaporate t) 537 | (insert "\n" (make-separator-line) "\n"))) 538 | (goto-char (point-min))) 539 | (insert "There are no active gptel contexts."))))) 540 | (display-buffer (current-buffer) 541 | `((display-buffer-reuse-window 542 | display-buffer-reuse-mode-window 543 | display-buffer-below-selected) 544 | (body-function . ,#'select-window) 545 | (window-height . ,#'fit-window-to-buffer))))) 546 | 547 | (defvar gptel-context--buffer-reverse nil 548 | "Last direction of cursor movement in gptel context buffer. 549 | 550 | If non-nil, indicates backward movement.") 551 | 552 | (defalias 'gptel-context--post-command 553 | (let ((highlight-overlay)) 554 | (lambda () 555 | ;; Only update if point moved outside the current region. 556 | (unless (memq highlight-overlay (overlays-at (point))) 557 | (let ((context-overlay 558 | (cl-loop for ov in (overlays-at (point)) 559 | thereis (and (overlay-get ov 'gptel-overlay) ov)))) 560 | (when highlight-overlay 561 | (overlay-put highlight-overlay 'face nil)) 562 | (when context-overlay 563 | (overlay-put context-overlay 'face 'highlight)) 564 | (setq highlight-overlay context-overlay)))))) 565 | 566 | (defun gptel-context-visit () 567 | "Display the location of this gptel context chunk in its original buffer." 568 | (interactive) 569 | (let ((ov-here (car (overlays-at (point))))) 570 | (if-let* ((source (overlay-get ov-here 'gptel-context)) 571 | (buf (if (overlayp source) 572 | (overlay-buffer source) 573 | (find-file-noselect source))) 574 | (offset (- (point) (overlay-start ov-here)))) 575 | (with-selected-window (display-buffer buf) 576 | (goto-char (if (overlayp source) 577 | (overlay-start source) 578 | (point-min))) 579 | (forward-char offset) 580 | (recenter)) 581 | (message "No source location for this gptel context chunk.")))) 582 | 583 | (defun gptel-context-next () 584 | "Move to next gptel context chunk." 585 | (interactive) 586 | (let ((ov-here (car (overlays-at (point)))) 587 | (next-start (next-overlay-change (point)))) 588 | (when (and (/= (point-max) next-start) ov-here) 589 | ;; We were inside the overlay, so we want the next overlay change, which 590 | ;; would be the start of the next overlay. 591 | (setq next-start (next-overlay-change next-start))) 592 | (when (/= next-start (point-max)) 593 | (setq gptel-context--buffer-reverse nil) 594 | (goto-char next-start) 595 | (recenter (floor (window-height) 4))))) 596 | 597 | (defun gptel-context-previous () 598 | "Move to previous gptel context chunk." 599 | (interactive) 600 | (let ((ov-here (car (overlays-at (point))))) 601 | (when ov-here (goto-char (overlay-start ov-here))) 602 | (let ((previous-context-pos (previous-overlay-change 603 | (previous-overlay-change (point))))) 604 | ;; Prevent point from jumping to the start of the buffer. 605 | (unless (= previous-context-pos (point-min)) 606 | (goto-char previous-context-pos) 607 | (recenter (floor (window-height) 4)) 608 | (setq gptel-context--buffer-reverse t))))) 609 | 610 | (defun gptel-context-flag-deletion () 611 | "Mark gptel context chunk at point for removal." 612 | (interactive) 613 | (let* ((overlays (if (use-region-p) 614 | (overlays-in (region-beginning) (region-end)) 615 | (overlays-at (point)))) 616 | (deletion-ov) 617 | (marked-ovs (cl-remove-if-not (lambda (ov) (overlay-get ov 'gptel-context-deletion-mark)) 618 | overlays))) 619 | (if marked-ovs 620 | (mapc #'delete-overlay marked-ovs) 621 | (save-excursion 622 | (dolist (ov overlays) 623 | (when (overlay-get ov 'gptel-context) 624 | (goto-char (overlay-start ov)) 625 | (setq deletion-ov (make-overlay (overlay-start ov) (overlay-end ov))) 626 | (overlay-put deletion-ov 'gptel-context (overlay-get ov 'gptel-context)) 627 | (overlay-put deletion-ov 'priority -80) 628 | (overlay-put deletion-ov 'face 'gptel-context-deletion-face) 629 | (overlay-put deletion-ov 'gptel-context-deletion-mark t))))) 630 | (if (use-region-p) 631 | (deactivate-mark) 632 | (if gptel-context--buffer-reverse 633 | (gptel-context-previous) 634 | (gptel-context-next))))) 635 | 636 | (defun gptel-context-quit () 637 | "Cancel pending operations and return to gptel's menu." 638 | (interactive) 639 | (quit-window) 640 | (call-interactively #'gptel-menu)) 641 | 642 | (defun gptel-context-confirm () 643 | "Confirm pending operations and return to gptel's menu." 644 | (interactive) 645 | ;; Delete all the context overlays that have been marked for deletion. 646 | (when-let* ((deletion-marks 647 | (delq nil (mapcar 648 | (lambda (ov) 649 | (and 650 | (overlay-get ov 'gptel-context-deletion-mark) 651 | (overlay-get ov 'gptel-context))) 652 | (overlays-in (point-min) (point-max)))))) 653 | (mapc #'gptel-context-remove deletion-marks) 654 | (gptel-context--collect) ;Update contexts and revert buffer (#482) 655 | (revert-buffer)) 656 | (gptel-context-quit)) 657 | 658 | (provide 'gptel-context) 659 | ;;; gptel-context.el ends here. 660 | -------------------------------------------------------------------------------- /gptel-curl.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-curl.el --- Curl support for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur;; 6 | ;; Keywords: convenience 7 | 8 | ;; SPDX-License-Identifier: GPL-3.0-or-later 9 | 10 | ;; This program is free software; you can redistribute it and/or modify 11 | ;; it under the terms of the GNU General Public License as published by 12 | ;; the Free Software Foundation, either version 3 of the License, or 13 | ;; (at your option) any later version. 14 | 15 | ;; This program is distributed in the hope that it will be useful, 16 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | ;; GNU General Public License for more details. 19 | 20 | ;; You should have received a copy of the GNU General Public License 21 | ;; along with this program. If not, see . 22 | 23 | ;;; Commentary: 24 | 25 | ;; Curl support for gptel. Utility functions. 26 | 27 | ;;; Code: 28 | 29 | (require 'gptel) 30 | 31 | (eval-when-compile 32 | (require 'cl-lib) 33 | (require 'subr-x)) 34 | (require 'map) 35 | 36 | (declare-function json-read "json" ()) 37 | (defvar json-object-type) 38 | 39 | (declare-function gptel--stream-convert-markdown->org "gptel-org") 40 | 41 | (defcustom gptel-curl-extra-args nil 42 | "Extra arguments to pass to Curl when sending queries. 43 | 44 | This should be a list of strings, each one a Curl command line 45 | argument. Note that these should not conflict with the options 46 | in `gptel-curl--common-args', which gptel requires for correct 47 | functioning. 48 | 49 | If you want to specify extra arguments only when using a specific 50 | gptel backend, use the `:curl-args' slot of the backend instead. 51 | See `gptel-backend'." 52 | :group 'gptel 53 | :type '(repeat string)) 54 | 55 | (defconst gptel-curl--common-args 56 | (if (memq system-type '(windows-nt ms-dos)) 57 | '("--disable" "--location" "--silent" "-XPOST" 58 | "-y7200" "-Y1" "-D-") 59 | '("--disable" "--location" "--silent" "--compressed" 60 | "-XPOST" "-y7200" "-Y1" "-D-")) 61 | "Arguments always passed to Curl for gptel queries.") 62 | 63 | (defun gptel-curl--get-args (info token) 64 | "Produce list of arguments for calling Curl. 65 | 66 | REQUEST-DATA is the data to send, TOKEN is a unique identifier." 67 | (let* ((data (plist-get info :data)) 68 | ;; We have to let-bind the following two variables since their dynamic 69 | ;; values are used for key lookup and url resoloution 70 | (gptel-backend (plist-get info :backend)) 71 | (gptel-stream (plist-get info :stream)) 72 | (url (let ((backend-url (gptel-backend-url gptel-backend))) 73 | (if (functionp backend-url) 74 | (with-current-buffer (plist-get info :buffer) 75 | (funcall backend-url)) 76 | backend-url))) 77 | (data-json (encode-coding-string (gptel--json-encode data) 'utf-8)) 78 | (headers 79 | (append '(("Content-Type" . "application/json")) 80 | (when-let* ((header (gptel-backend-header gptel-backend))) 81 | (if (functionp header) 82 | (funcall header) header))))) 83 | (when gptel-log-level 84 | (when (eq gptel-log-level 'debug) 85 | (gptel--log (gptel--json-encode 86 | (mapcar (lambda (pair) (cons (intern (car pair)) (cdr pair))) 87 | headers)) 88 | "request headers")) 89 | (gptel--log data-json "request body")) 90 | (append 91 | gptel-curl--common-args 92 | gptel-curl-extra-args 93 | (and-let* ((curl-args (gptel-backend-curl-args gptel-backend))) 94 | (if (functionp curl-args) (funcall curl-args) curl-args)) 95 | (list (format "-w(%s . %%{size_header})" token)) 96 | (if (length< data-json gptel-curl-file-size-threshold) 97 | (list (format "-d%s" data-json)) 98 | (letrec 99 | ((temp-filename (make-temp-file "gptel-curl-data" nil ".json" data-json)) 100 | (cleanup-fn (lambda (&rest _) 101 | (when (file-exists-p temp-filename) 102 | (delete-file temp-filename) 103 | (remove-hook 'gptel-post-response-functions cleanup-fn))))) 104 | (add-hook 'gptel-post-response-functions cleanup-fn) 105 | (list "--data-binary" 106 | (format "@%s" temp-filename)))) 107 | (when (not (string-empty-p gptel-proxy)) 108 | (list "--proxy" gptel-proxy 109 | "--proxy-negotiate" 110 | "--proxy-user" ":")) 111 | (cl-loop for (key . val) in headers 112 | collect (format "-H%s: %s" key val)) 113 | (list url)))) 114 | 115 | ;;TODO: The :transformer argument here is an alternate implementation of 116 | ;;`gptel-response-filter-functions'. The two need to be unified. 117 | ;;;###autoload 118 | (defun gptel-curl-get-response (fsm) 119 | "Fetch response to prompt in state FSM from the LLM using Curl. 120 | 121 | FSM is the state machine driving this request. 122 | 123 | FSM is the state machine driving this request. Its INFO slot 124 | contains the data required for setting up the request. INFO is a 125 | plist with the following keys, among others: 126 | - :data (the data being sent) 127 | - :buffer (the gptel buffer) 128 | - :position (marker at which to insert the response). 129 | - :callback (optional, the request callback) 130 | 131 | Call CALLBACK with the response and INFO afterwards. If omitted 132 | the response is inserted into the current buffer after point." 133 | (let* ((token (md5 (format "%s%s%s%s" 134 | (random) (emacs-pid) (user-full-name) 135 | (recent-keys)))) 136 | (info (gptel-fsm-info fsm)) 137 | (backend (plist-get info :backend)) 138 | (args (gptel-curl--get-args info token)) 139 | (stream (plist-get info :stream)) 140 | (process (apply #'start-process "gptel-curl" 141 | (gptel--temp-buffer " *gptel-curl*") (gptel--curl-path) args))) 142 | (when (eq gptel-log-level 'debug) 143 | (gptel--log (mapconcat #'shell-quote-argument (cons (gptel--curl-path) args) " \\\n") 144 | "request Curl command" 'no-json)) 145 | (with-current-buffer (process-buffer process) 146 | (cond 147 | ((eq (gptel-backend-coding-system backend) 'binary) 148 | ;; set-buffer-file-coding-system is not needed since we don't save this buffer 149 | (set-buffer-multibyte nil) 150 | (set-process-coding-system process 'binary 'binary)) 151 | (t 152 | ;; Don't try to convert cr-lf to cr on Windows so that curl's "header size 153 | ;; in bytes" stays correct. Explicitly set utf-8 for non-win systems too, 154 | ;; for cases when buffer coding system is not set to utf-8. 155 | (set-process-coding-system process 'utf-8-unix 'utf-8-unix))) 156 | (set-process-query-on-exit-flag process nil) 157 | (if (plist-get info :token) ;not the first run, set only the token 158 | (plist-put info :token token) 159 | (setf (gptel-fsm-info fsm) ;fist run, set all process parameters 160 | (nconc (list :token token 161 | :transformer 162 | (when (with-current-buffer (plist-get info :buffer) 163 | (and (derived-mode-p 'org-mode) 164 | gptel-org-convert-response)) 165 | (gptel--stream-convert-markdown->org 166 | (plist-get info :position)))) 167 | (unless (plist-get info :callback) 168 | (list :callback (if stream 169 | #'gptel-curl--stream-insert-response 170 | #'gptel--insert-response))) 171 | info))) 172 | (if stream 173 | (progn (set-process-sentinel process #'gptel-curl--stream-cleanup) 174 | (set-process-filter process #'gptel-curl--stream-filter)) 175 | (set-process-sentinel process #'gptel-curl--sentinel)) 176 | (setf (alist-get process gptel--request-alist) 177 | (cons fsm 178 | #'(lambda () 179 | ;; Clean up Curl process 180 | (set-process-sentinel process #'ignore) 181 | (delete-process process) 182 | (kill-buffer (process-buffer process)))))))) 183 | 184 | ;; ;; Ahead-Of-Time dispatch code for the parsers 185 | ;; :parser ; FIXME `cl--generic-*' are internal functions 186 | ;; (cl--generic-method-function 187 | ;; (if stream 188 | ;; (cl-loop 189 | ;; for type in 190 | ;; (cl--class-allparents (get (type-of backend) 'cl--class)) 191 | ;; with methods = (cl--generic-method-table 192 | ;; (cl--generic 'gptel-curl--parse-stream)) 193 | ;; when (cl--generic-member-method `(,type t) nil methods) 194 | ;; return (car it)) 195 | ;; (cl-loop 196 | ;; for type in 197 | ;; (cl--class-allparents (get (type-of backend) 'cl--class)) 198 | ;; with methods = (cl--generic-method-table 199 | ;; (cl--generic 'gptel--parse-response)) 200 | ;; when (cl--generic-member-method `(,type t t) nil methods) 201 | ;; return (car it)))) 202 | 203 | (defun gptel-curl--log-response (proc-buf proc-info) 204 | "Parse response buffer PROC-BUF and log response. 205 | 206 | PROC-INFO is the plist containing process metadata." 207 | (with-current-buffer proc-buf 208 | (save-excursion 209 | (goto-char (point-min)) 210 | (when (re-search-forward " ?\n ?\n" nil t) 211 | (when (eq gptel-log-level 'debug) 212 | (gptel--log (gptel--json-encode 213 | (buffer-substring-no-properties 214 | (point-min) (1- (point)))) 215 | "response headers")) 216 | (let ((p (point))) 217 | (when (search-forward (plist-get proc-info :token) nil t) 218 | (goto-char (1- (match-beginning 0))) 219 | (gptel--log (buffer-substring-no-properties p (point)) 220 | "response body"))))))) 221 | 222 | ;; TODO: Separate user-messaging from this function 223 | (defun gptel-curl--stream-cleanup (process _status) 224 | "Process sentinel for gptel curl requests. 225 | 226 | PROCESS and _STATUS are process parameters." 227 | (let ((proc-buf (process-buffer process))) 228 | (let* ((fsm (car (alist-get process gptel--request-alist))) 229 | (info (gptel-fsm-info fsm)) 230 | (http-status (plist-get info :http-status))) 231 | (when gptel-log-level (gptel-curl--log-response proc-buf info)) ;logging 232 | (if (member http-status '("200" "100")) ;Finish handling response 233 | ;; Run the callback one last time to signal that the process has ended 234 | (with-demoted-errors "gptel callback error: %S" 235 | (funcall (plist-get info :callback) t info)) 236 | (with-current-buffer proc-buf ; Or Capture error message 237 | (goto-char (point-max)) 238 | (search-backward (plist-get info :token)) 239 | (backward-char) 240 | (pcase-let* ((`(,_ . ,header-size) (read (current-buffer))) 241 | (response (progn (goto-char header-size) 242 | (condition-case nil (gptel--json-read) 243 | (error 'json-read-error)))) 244 | (error-data (plist-get response :error))) 245 | (cond 246 | (error-data 247 | (plist-put info :error error-data)) 248 | ((eq response 'json-read-error) 249 | (plist-put info :error "Malformed JSON in response.")) 250 | (t (plist-put info :error "Could not parse HTTP response."))))) 251 | (with-demoted-errors "gptel callback error: %S" 252 | (funcall (plist-get info :callback) nil info))) 253 | (gptel--fsm-transition fsm)) ; Move to next state 254 | (setf (alist-get process gptel--request-alist nil 'remove) nil) 255 | (kill-buffer proc-buf))) 256 | 257 | (defun gptel-curl--stream-insert-response (response info &optional raw) 258 | "Insert streaming RESPONSE from an LLM into the gptel buffer. 259 | 260 | INFO is a mutable plist containing information relevant to this buffer. 261 | See `gptel--url-get-response' for details. 262 | 263 | Optional RAW disables text properties and transformation." 264 | (pcase response 265 | ((pred stringp) 266 | (let ((start-marker (plist-get info :position)) 267 | (tracking-marker (plist-get info :tracking-marker)) 268 | (transformer (plist-get info :transformer))) 269 | (with-current-buffer (marker-buffer start-marker) 270 | (save-excursion 271 | (unless tracking-marker 272 | (goto-char start-marker) 273 | (unless (or (bobp) (plist-get info :in-place)) 274 | (insert gptel-response-separator) 275 | (when gptel-mode 276 | ;; Put prefix before AI response. 277 | (insert (gptel-response-prefix-string))) 278 | (move-marker start-marker (point))) 279 | (setq tracking-marker (set-marker (make-marker) (point))) 280 | (set-marker-insertion-type tracking-marker t) 281 | (plist-put info :tracking-marker tracking-marker)) 282 | (goto-char tracking-marker) 283 | (unless raw 284 | (when transformer 285 | (setq response (funcall transformer response))) 286 | (add-text-properties 287 | 0 (length response) '(gptel response front-sticky (gptel)) 288 | response)) 289 | ;; (run-hooks 'gptel-pre-stream-hook) 290 | (insert response) 291 | (run-hooks 'gptel-post-stream-hook))))) 292 | (`(reasoning . ,text) 293 | (gptel--display-reasoning-stream text info)) 294 | (`(tool-call . ,tool-calls) 295 | (gptel--display-tool-calls tool-calls info)) 296 | (`(tool-result . ,tool-results) 297 | (gptel--display-tool-results tool-results info)))) 298 | 299 | (defun gptel-curl--stream-filter (process output) 300 | (let* ((fsm (car (alist-get process gptel--request-alist))) 301 | (proc-info (gptel-fsm-info fsm)) 302 | (callback (or (plist-get proc-info :callback) 303 | #'gptel-curl--stream-insert-response))) 304 | (with-current-buffer (process-buffer process) 305 | ;; Insert output 306 | (save-excursion 307 | (goto-char (process-mark process)) 308 | (insert output) 309 | (set-marker (process-mark process) (point))) 310 | 311 | ;; Find HTTP status 312 | (unless (plist-get proc-info :http-status) 313 | (save-excursion 314 | (goto-char (point-min)) 315 | (when-let* (((not (= (line-end-position) (point-max)))) 316 | (http-msg (buffer-substring (line-beginning-position) 317 | (line-end-position))) 318 | (http-status 319 | (save-match-data 320 | (and (string-match "HTTP/[.0-9]+ +\\([0-9]+\\)" http-msg) 321 | (match-string 1 http-msg))))) 322 | (plist-put proc-info :http-status http-status) 323 | (plist-put proc-info :status (string-trim http-msg)) 324 | (gptel--fsm-transition fsm)))) 325 | 326 | (when-let* ((http-msg (plist-get proc-info :status)) 327 | (http-status (plist-get proc-info :http-status))) 328 | ;; Find data chunk(s) and run callback 329 | ;; FIXME Handle the case where HTTP 100 is followed by HTTP (not 200) BUG #194 330 | (when (member http-status '("200" "100")) 331 | (let ((response (gptel-curl--parse-stream 332 | (plist-get proc-info :backend) proc-info)) 333 | (reasoning-block (plist-get proc-info :reasoning-block))) 334 | ;; Depending on the API, there are two modes that reasoning or 335 | ;; chain-of-thought content appears: as part of the main response 336 | ;; but surrounded by ... tags, or as a separate 337 | ;; JSON field in the response stream. 338 | ;; 339 | ;; These cases are handled using two PROC-INFO keys: 340 | ;; 341 | ;; :reasoning-block is nil before checking for reasoning, 'in when 342 | ;; in a reasoning block, t when we reach the end of the block, and 343 | ;; 'done afterwards or if no reasoning block is found. This 344 | ;; applies to both the modes above. 345 | ;; 346 | ;; :reasoning contains the reasoning text parsed from the separate 347 | ;; JSON field. 348 | ;; 349 | ;; NOTE: We assume here that the reasoning block always 350 | ;; precedes the main response block. 351 | (unless (eq reasoning-block 'done) 352 | (let ((reasoning (plist-get proc-info :reasoning))) 353 | (cond 354 | ((stringp reasoning) 355 | ;; Obtained from separate JSON field in response 356 | (funcall callback (cons 'reasoning reasoning) proc-info) 357 | (unless reasoning-block ;Record that we're in a reasoning block (#709) 358 | (plist-put proc-info :reasoning-block 'in)) 359 | (plist-put proc-info :reasoning nil)) ;Reset for next parsing round 360 | ((and (null reasoning-block) (length> response 0)) 361 | (if (string-match-p "^ *" response) 362 | ;; Obtained from main response stream 363 | (progn (setq response (cons 'reasoning response)) 364 | (plist-put proc-info :reasoning-block 'in)) 365 | (plist-put proc-info :reasoning-block 'done))) 366 | ((length> response 0) 367 | (if-let* ((idx (string-match-p "" response))) 368 | (progn 369 | (funcall callback 370 | (cons 'reasoning ;last reasoning chunk 371 | (string-trim-left 372 | (substring response nil (+ idx 8)))) 373 | proc-info) 374 | ;; Signal end of reasoning stream 375 | (funcall callback '(reasoning . t) proc-info) 376 | (setq response (substring response (+ idx 8))) 377 | (plist-put proc-info :reasoning-block 'done)) 378 | (setq response (cons 'reasoning response))))) 379 | (when (eq reasoning-block t) ;End of reasoning block 380 | (funcall callback '(reasoning . t) proc-info) 381 | (plist-put proc-info :reasoning-block 'done)))) 382 | (unless (equal response "") ;Response callback 383 | (funcall callback response proc-info)))))))) 384 | 385 | (cl-defgeneric gptel-curl--parse-stream (backend proc-info) 386 | "Stream parser for gptel-curl. 387 | 388 | Implementations of this function run as part of the process 389 | filter for the active query, and return partial responses from 390 | the LLM. 391 | 392 | BACKEND is the LLM backend in use. 393 | 394 | PROC-INFO is a plist with process information and other context. 395 | See `gptel-curl--get-response' for its contents.") 396 | 397 | (defun gptel-curl--sentinel (process _status) 398 | "Process sentinel for gptel curl requests. 399 | 400 | PROCESS and _STATUS are process parameters." 401 | (let ((proc-buf (process-buffer process))) 402 | (when-let* (((eq (process-status process) 'exit)) 403 | (fsm (car (alist-get process gptel--request-alist))) 404 | (proc-info (gptel-fsm-info fsm)) 405 | (proc-callback (plist-get proc-info :callback))) 406 | (when gptel-log-level (gptel-curl--log-response proc-buf proc-info)) ;logging 407 | (pcase-let ((`(,response ,http-status ,http-msg ,error) 408 | (with-current-buffer proc-buf 409 | (gptel-curl--parse-response proc-info)))) 410 | (plist-put proc-info :http-status http-status) 411 | (plist-put proc-info :status http-msg) 412 | (gptel--fsm-transition fsm) ;WAIT -> TYPE 413 | (when error (plist-put proc-info :error error)) 414 | (when response ;Look for a reasoning block 415 | (if (string-match-p "^ *\n" response) 416 | (when-let* ((idx (string-search "\n" response))) 417 | (with-demoted-errors "gptel callback error: %S" 418 | (funcall proc-callback 419 | (cons 'reasoning (substring response nil (+ idx 8))) 420 | proc-info)) 421 | (setq response 422 | (string-trim-left (substring response (+ idx 8))))) 423 | (when-let* ((reasoning (plist-get proc-info :reasoning)) 424 | ((stringp reasoning))) 425 | (funcall proc-callback (cons 'reasoning reasoning) proc-info)))) 426 | (when (or response (not (member http-status '("200" "100")))) 427 | (with-demoted-errors "gptel callback error: %S" 428 | (funcall proc-callback response proc-info)))) 429 | (gptel--fsm-transition fsm)) ;TYPE -> next 430 | (setf (alist-get process gptel--request-alist nil 'remove) nil) 431 | (kill-buffer proc-buf))) 432 | 433 | (defun gptel-curl--parse-response (proc-info) 434 | "Parse the buffer BUF with curl's response. 435 | 436 | PROC-INFO is a plist with contextual information." 437 | (let ((token (plist-get proc-info :token))) 438 | (goto-char (point-max)) 439 | (search-backward token) 440 | (backward-char) 441 | (pcase-let* ((`(,_ . ,header-size) (read (current-buffer)))) 442 | (goto-char (point-min)) 443 | 444 | (if-let* ((http-msg (string-trim 445 | (buffer-substring (line-beginning-position) 446 | (line-end-position)))) 447 | (http-status 448 | (save-match-data 449 | (and (string-match "HTTP/[.0-9]+ +\\([0-9]+\\)" http-msg) 450 | (match-string 1 http-msg)))) 451 | (response (progn (goto-char header-size) 452 | (condition-case nil 453 | (gptel--json-read) 454 | (error 'json-read-error))))) 455 | (cond 456 | ;; FIXME Handle the case where HTTP 100 is followed by HTTP (not 200) BUG #194 457 | ((member http-status '("200" "100")) 458 | (list (and-let* ((resp (gptel--parse-response 459 | (plist-get proc-info :backend) response proc-info)) 460 | ((not (string-blank-p resp)))) 461 | (string-trim resp)) 462 | http-status http-msg)) 463 | ((plist-get response :error) 464 | (list nil http-status http-msg (plist-get response :error))) 465 | ((eq response 'json-read-error) 466 | (list nil http-status (concat "(" http-msg ") Malformed JSON in response.") 467 | "Malformed JSON in response")) 468 | (t (list nil http-status (concat "(" http-msg ") Could not parse HTTP response.") 469 | "Could not parse HTTP response."))) 470 | (list nil http-status (concat "(" http-msg ") Could not parse HTTP response.") 471 | "Could not parse HTTP response."))))) 472 | 473 | (provide 'gptel-curl) 474 | ;;; gptel-curl.el ends here 475 | -------------------------------------------------------------------------------- /gptel-gemini.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-gemini.el --- Gemini suppport for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; This file adds support for the Gemini API to gptel 23 | 24 | ;;; Code: 25 | (require 'gptel) 26 | (require 'cl-generic) 27 | (require 'map) 28 | (eval-when-compile (require 'cl-lib)) 29 | 30 | (declare-function prop-match-value "text-property-search") 31 | (declare-function text-property-search-backward "text-property-search") 32 | (declare-function json-read "json") 33 | (declare-function gptel-context--wrap "gptel-context") 34 | (declare-function gptel-context--collect-media "gptel-context") 35 | (defvar json-object-type) 36 | 37 | ;;; Gemini 38 | (cl-defstruct 39 | (gptel-gemini (:constructor gptel--make-gemini) 40 | (:copier nil) 41 | (:include gptel-backend))) 42 | 43 | ;; TODO: Using alt=sse in the query url generates an OpenAI style streaming 44 | ;; response, with more immediate updates. Maybe we should switch to that and 45 | ;; rewrite the stream parser? 46 | (cl-defmethod gptel-curl--parse-stream ((_backend gptel-gemini) info) 47 | "Parse a Gemini data stream. 48 | 49 | Return the text response accumulated since the last call to this 50 | function. Additionally, mutate state INFO to add tool-use 51 | information if the stream contains it." 52 | (let* ((content-strs)) 53 | (condition-case nil 54 | (while (prog1 (search-forward "{" nil t) ; while-let is Emacs 29.1+ only 55 | (backward-char 1)) 56 | (save-match-data 57 | (when-let* ((response (gptel--json-read)) 58 | (text (gptel--parse-response 59 | (plist-get info :backend) 60 | response info 'include))) 61 | (push text content-strs)))) 62 | (error 63 | (goto-char (match-beginning 0)))) 64 | (apply #'concat (nreverse content-strs)))) 65 | 66 | (cl-defmethod gptel--parse-response ((_backend gptel-gemini) response info 67 | &optional include-text) 68 | "Parse an Gemini (non-streaming) RESPONSE and return response text. 69 | 70 | Mutate state INFO with response metadata. 71 | 72 | If INCLUDE-TEXT is non-nil, include response text in the prompts 73 | list." 74 | (let* ((cand0 (map-nested-elt response '(:candidates 0))) 75 | (parts (map-nested-elt cand0 '(:content :parts)))) 76 | (plist-put info :stop-reason (plist-get cand0 :finishReason)) 77 | (plist-put info :output-tokens 78 | (map-nested-elt 79 | response '(:usageMetadata :candidatesTokenCount))) 80 | (cl-loop 81 | for part across parts 82 | for tx = (plist-get part :text) 83 | if (and tx (not (eq tx :null))) 84 | if (plist-get part :thought) 85 | do (unless (plist-get info :reasoning-block) 86 | (plist-put info :reasoning-block 'in)) 87 | (plist-put info :reasoning (concat (plist-get info :reasoning) tx)) 88 | else do 89 | (if (eq (plist-get info :reasoning-block) 'in) 90 | (plist-put info :reasoning-block t)) 91 | and collect tx into content-strs end 92 | else if (plist-get part :functionCall) 93 | collect (copy-sequence it) into tool-use 94 | finally do ;Add text and tool-calls to prompts list 95 | (when (or tool-use include-text) 96 | (let* ((data (plist-get info :data)) 97 | (prompts (plist-get data :contents)) 98 | (last-prompt (aref prompts (1- (length prompts))))) 99 | (if (equal (plist-get last-prompt :role) "model") 100 | ;; When streaming, the last prompt may already have the role 101 | ;; "model" from prior calls to this function. Append to its parts 102 | ;; instead of adding a new model role then. 103 | (plist-put last-prompt :parts 104 | (vconcat (plist-get last-prompt :parts) parts)) 105 | (plist-put ;otherwise create a new "model" role 106 | data :contents 107 | (vconcat prompts `((:role "model" :parts ,parts))))))) 108 | (when tool-use ;Capture tool call data for running tools 109 | (plist-put info :tool-use 110 | (nconc (plist-get info :tool-use) tool-use))) 111 | finally return 112 | (and content-strs (apply #'concat content-strs))))) 113 | 114 | (cl-defmethod gptel--request-data ((backend gptel-gemini) prompts) 115 | "JSON encode PROMPTS for sending to Gemini." 116 | (let ((prompts-plist 117 | `(:contents [,@prompts] 118 | :safetySettings [(:category "HARM_CATEGORY_HARASSMENT" 119 | :threshold "BLOCK_NONE") 120 | (:category "HARM_CATEGORY_SEXUALLY_EXPLICIT" 121 | :threshold "BLOCK_NONE") 122 | (:category "HARM_CATEGORY_DANGEROUS_CONTENT" 123 | :threshold "BLOCK_NONE") 124 | (:category "HARM_CATEGORY_HATE_SPEECH" 125 | :threshold "BLOCK_NONE")])) 126 | params) 127 | (if gptel--system-message 128 | (plist-put prompts-plist :system_instruction 129 | `(:parts (:text ,gptel--system-message)))) 130 | (when gptel-use-tools 131 | (when (eq gptel-use-tools 'force) 132 | (plist-put prompts-plist :tool_config 133 | '(:function_calling_config (:mode "ANY")))) 134 | (when gptel-tools 135 | (plist-put prompts-plist :tools 136 | (gptel--parse-tools backend gptel-tools)))) 137 | (when gptel-temperature 138 | (setq params 139 | (plist-put params 140 | :temperature (max gptel-temperature 1.0)))) 141 | (when gptel-max-tokens 142 | (setq params 143 | (plist-put params 144 | :maxOutputTokens gptel-max-tokens))) 145 | (when gptel-include-reasoning 146 | (setq params 147 | (plist-put params :thinkingConfig '(:includeThoughts t)))) 148 | (when params 149 | (plist-put prompts-plist 150 | :generationConfig params)) 151 | ;; Merge request params with model and backend params. 152 | (gptel--merge-plists 153 | prompts-plist 154 | (gptel-backend-request-params gptel-backend) 155 | (gptel--model-request-params gptel-model)))) 156 | 157 | (defun gptel--gemini-filter-schema (schema) 158 | "Destructively filter unsupported attributes from SCHEMA. 159 | 160 | Gemini's API does not support `additionalProperties'." 161 | (cl-remf schema :additionalProperties) 162 | (when (plistp schema) 163 | (cl-loop for (key val) on schema by #'cddr 164 | do (cond 165 | ;; Recursively modify schemas within vectors (anyOf/allOf) 166 | ((memq key '(:anyOf :allOf)) 167 | (dotimes (i (length val)) 168 | (aset val i (gptel--gemini-filter-schema (aref val i))))) 169 | ;; Recursively modify plist values, which may contain sub-schemas 170 | ((plistp val) 171 | (when (cl-remf val :additionalProperties) 172 | (cl-remf (plist-get schema key) :additionalProperties)) 173 | (gptel--gemini-filter-schema val)) 174 | ;; Default: do nothing to other key-value pairs yet. 175 | (t nil)))) 176 | schema) 177 | 178 | (cl-defmethod gptel--parse-tools ((_backend gptel-gemini) tools) 179 | "Parse TOOLS to the Gemini API tool definition spec. 180 | 181 | TOOLS is a list of `gptel-tool' structs, which see." 182 | (cl-loop 183 | for tool in (ensure-list tools) 184 | collect 185 | (list 186 | :name (gptel-tool-name tool) 187 | :description (gptel-tool-description tool) 188 | :parameters 189 | (if (not (gptel-tool-args tool)) 190 | :null ;NOTE: Gemini wants :null if the function takes no args 191 | (list :type "object" 192 | ;; See the generic implementation for an explanation of this 193 | ;; transformation. 194 | :properties 195 | (cl-loop 196 | for arg in (gptel-tool-args tool) 197 | for argspec = (copy-sequence arg) 198 | for name = (plist-get arg :name) ;handled differently 199 | for newname = (or (and (keywordp name) name) 200 | (make-symbol (concat ":" name))) 201 | do ;ARGSPEC is ARG without unrecognized keys 202 | (cl-remf argspec :name) 203 | (cl-remf argspec :optional) 204 | if (equal (plist-get arg :type) "object") 205 | do (unless (plist-member argspec :required) 206 | (plist-put argspec :required [])) 207 | if (equal (plist-get arg :type) "string") 208 | do (cl-remf argspec :format) 209 | append (list newname (gptel--gemini-filter-schema argspec))) 210 | :required 211 | (vconcat 212 | (delq nil (mapcar 213 | (lambda (arg) (and (not (plist-get arg :optional)) 214 | (plist-get arg :name))) 215 | (gptel-tool-args tool))))))) 216 | into tool-specs 217 | finally return `[(:function_declarations ,(vconcat tool-specs))])) 218 | 219 | (cl-defmethod gptel--parse-tool-results ((_backend gptel-gemini) tool-use) 220 | "Return a prompt containing tool call results in TOOL-USE." 221 | (list 222 | :role "user" 223 | :parts 224 | (vconcat 225 | (mapcar 226 | (lambda (tool-call) 227 | (let ((result (plist-get tool-call :result)) 228 | (name (plist-get tool-call :name))) 229 | `(:functionResponse 230 | (:name ,name :response 231 | (:name ,name :content ,result))))) 232 | tool-use)))) 233 | 234 | (cl-defmethod gptel--inject-prompt ((_backend gptel-gemini) data new-prompt &optional _position) 235 | "Append NEW-PROMPT to existing prompts in query DATA. 236 | 237 | See generic implementation for full documentation." 238 | (let ((prompts (plist-get data :contents))) 239 | (plist-put data :contents (vconcat prompts (list new-prompt))))) 240 | 241 | (cl-defmethod gptel--parse-list ((backend gptel-gemini) prompt-list) 242 | (if (consp (car prompt-list)) 243 | (let ((full-prompt)) ; Advanced format, list of lists 244 | (dolist (entry prompt-list) 245 | (pcase entry 246 | (`(prompt . ,msg) 247 | (push (list :role "user" 248 | :parts `[(:text ,(or (car-safe msg) msg))]) 249 | full-prompt)) 250 | (`(response . ,msg) 251 | (push (list :role "model" 252 | :parts `[(:text ,(or (car-safe msg) msg))]) 253 | full-prompt)) 254 | (`(tool . ,call) 255 | (push (list :role "model" 256 | :parts (vector `(:functionCall ( :name ,(plist-get call :name) 257 | :args ,(plist-get call :args))))) 258 | full-prompt) 259 | (push (gptel--parse-tool-results backend (list (cdr entry))) full-prompt)))) 260 | (nreverse full-prompt)) 261 | (cl-loop for text in prompt-list ; Simple format, list of strings 262 | for role = t then (not role) 263 | if text 264 | if role 265 | collect (list :role "user" :parts `[(:text ,text)]) into prompts 266 | else collect (list :role "model" :parts `(:text ,text)) into prompts 267 | finally return prompts))) 268 | 269 | (cl-defmethod gptel--parse-buffer ((backend gptel-gemini) &optional max-entries) 270 | (let ((prompts) (prev-pt (point))) 271 | (if (or gptel-mode gptel-track-response) 272 | (while (and (or (not max-entries) (>= max-entries 0)) 273 | (goto-char (previous-single-property-change 274 | (point) 'gptel nil (point-min))) 275 | (not (= (point) prev-pt))) 276 | (pcase (get-char-property (point) 'gptel) 277 | ('response 278 | (when-let* ((content (gptel--trim-prefixes 279 | (buffer-substring-no-properties (point) prev-pt)))) 280 | (push (list :role "model" :parts (list :text content)) prompts))) 281 | (`(tool . ,_id) 282 | (save-excursion 283 | (condition-case nil 284 | (let* ((tool-call (read (current-buffer))) 285 | (name (plist-get tool-call :name)) 286 | (arguments (plist-get tool-call :args))) 287 | (plist-put tool-call :result 288 | (string-trim (buffer-substring-no-properties 289 | (point) prev-pt))) 290 | (push (gptel--parse-tool-results backend (list tool-call)) 291 | prompts) 292 | (push (list :role "model" 293 | :parts 294 | (vector `(:functionCall ( :name ,name 295 | :args ,arguments)))) 296 | prompts)) 297 | ((end-of-file invalid-read-syntax) 298 | (message (format "Could not parse tool-call on line %s" 299 | (line-number-at-pos (point)))))))) 300 | ('ignore) 301 | ('nil 302 | (if gptel-track-media 303 | (when-let* ((content (gptel--gemini-parse-multipart 304 | (gptel--parse-media-links major-mode (point) prev-pt)))) 305 | (when (> (length content) 0) 306 | (push (list :role "user" :parts content) prompts))) 307 | (when-let* ((content (gptel--trim-prefixes 308 | (buffer-substring-no-properties 309 | (point) prev-pt)))) 310 | (push (list :role "user" :parts `[(:text ,content)]) prompts))))) 311 | (setq prev-pt (point)) 312 | (and max-entries (cl-decf max-entries))) 313 | (let ((content (string-trim (buffer-substring-no-properties 314 | (point-min) (point-max))))) 315 | (push (list :role "user" :parts `[(:text ,content)]) prompts))) 316 | prompts)) 317 | 318 | (defun gptel--gemini-parse-multipart (parts) 319 | "Convert a multipart prompt PARTS to the Gemini API format. 320 | 321 | The input is an alist of the form 322 | ((:text \"some text\") 323 | (:media \"/path/to/media.png\" :mime \"image/png\") 324 | (:text \"More text\")). 325 | 326 | The output is a vector of entries in a backend-appropriate 327 | format." 328 | (cl-loop 329 | for part in parts 330 | for n upfrom 1 331 | with last = (length parts) 332 | for text = (plist-get part :text) 333 | for media = (plist-get part :media) 334 | if text do 335 | (and (or (= n 1) (= n last)) (setq text (gptel--trim-prefixes text))) and 336 | if text 337 | collect (list :text text) into parts-array end 338 | else if media 339 | collect 340 | `(:inline_data 341 | (:mime_type ,(plist-get part :mime) 342 | :data ,(gptel--base64-encode media))) 343 | into parts-array 344 | else if (plist-get part :textfile) 345 | collect 346 | (list :text (with-temp-buffer 347 | (gptel--insert-file-string (plist-get part :textfile)) 348 | (buffer-string))) 349 | into parts-array 350 | finally return (vconcat parts-array))) 351 | 352 | (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-gemini) prompts 353 | &optional inject-media) 354 | "Wrap the last user prompt in PROMPTS with the context string. 355 | 356 | If INJECT-MEDIA is non-nil wrap it with base64-encoded media 357 | files in the context." 358 | (if inject-media 359 | ;; Wrap the first user prompt with included media files/contexts 360 | (when-let* ((media-list (gptel-context--collect-media))) 361 | (cl-callf (lambda (current) 362 | (vconcat (gptel--gemini-parse-multipart media-list) 363 | current)) 364 | (plist-get (car prompts) :parts))) 365 | ;; Wrap the last user prompt with included text contexts 366 | (cl-callf (lambda (current) 367 | (if-let* ((wrapped (gptel-context--wrap nil))) 368 | (vconcat `((:text ,wrapped)) current) 369 | current)) 370 | (plist-get (car (last prompts)) :parts)))) 371 | 372 | (defconst gptel--gemini-models 373 | '((gemini-1.5-pro-latest 374 | :description "Google's latest model with enhanced capabilities across various tasks" 375 | :capabilities (tool-use json media) 376 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 377 | "application/pdf" "text/plain" "text/csv" "text/html") 378 | :context-window 2000 379 | ;; input & output price is halved for prompts of 128k tokens or less 380 | :input-cost 2.50 381 | :output-cost 10 382 | :cutoff-date "2024-05") 383 | (gemini-2.0-flash-exp 384 | :description "Next generation features, superior speed, native tool use" 385 | :capabilities (tool-use json media) 386 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 387 | "application/pdf" "text/plain" "text/csv" "text/html") 388 | :context-window 1000 389 | :cutoff-date "2024-12") 390 | (gemini-1.5-flash 391 | :description "A faster, more efficient version of Gemini 1.5 optimized for speed" 392 | :capabilities (tool-use json media) 393 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 394 | "application/pdf" "text/plain" "text/csv" "text/html") 395 | :context-window 1000 396 | ;; input & output price is halved for prompts of 128k tokens or less 397 | :input-cost 0.15 398 | :output-cost 0.60 399 | :cutoff-date "2024-05") 400 | (gemini-1.5-flash-8b 401 | :description "High volume and lower intelligence tasks" 402 | :capabilities (tool-use json media) 403 | :context-window 1000 404 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 405 | "application/pdf" "text/plain" "text/csv" "text/html") 406 | ;; input & output price is halved for prompts of 128k tokens or less 407 | :input-cost 0.075 408 | :output-cost 0.30 409 | :cutoff-date "2024-10") 410 | (gemini-exp-1206 411 | :description "Improved coding, reasoning and vision capabilities" 412 | :capabilities (tool-use json media) 413 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 414 | "application/pdf" "text/plain" "text/csv" "text/html") 415 | :cutoff-date "2024-12") 416 | (gemini-2.0-flash 417 | :description "Next gen, high speed, multimodal for a diverse variety of tasks" 418 | :capabilities (tool-use json media) 419 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 420 | "application/pdf" "text/plain" "text/csv" "text/html") 421 | :context-window 1000 422 | :input-cost 0.10 423 | :output-cost 0.40 424 | :cutoff-date "2024-08") 425 | (gemini-2.0-flash-lite-preview-02-05 426 | :description "Gemini 2.0 Flash model optimized for cost efficiency and low latency" 427 | :capabilities (json) 428 | :context-window 1000 429 | :input-cost 0.075 430 | :output-cost 0.30 431 | :cutoff-date "2024-08") 432 | (gemini-2.0-pro-exp-02-05 433 | :description "Next gen, high speed, multimodal for a diverse variety of tasks" 434 | :capabilities (tool-use json) 435 | :context-window 2000 436 | :input-cost 0.00 437 | :output-cost 0.00 438 | :cutoff-date "2024-08") 439 | (gemini-2.0-flash-thinking-exp-01-21 440 | :description "Next gen, high speed, multimodal for a diverse variety of tasks" 441 | :capabilities (json) 442 | :input-cost 0.00 443 | :output-cost 0.00 444 | :cutoff-date "2024-08") 445 | (gemini-2.0-flash-exp 446 | :description "Multi-modal, streaming, tool use 2000 RPM" 447 | :capabilities (tool-use json media) 448 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 449 | "application/pdf" "text/plain" "text/csv" "text/html") 450 | :context-window 1000 451 | :input-cost 0.00 452 | :output-cost 0.00 453 | :cutoff-date "2024-08") 454 | (gemini-2.5-pro-exp-03-25 455 | :description "Like gemini-2.5-pro-preview-03-25 but limited to 5 req/min, 25 req/day" 456 | :capabilities (tool-use json media) 457 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 458 | "application/pdf" "text/plain" "text/csv" "text/html") 459 | :context-window 1000 460 | :input-cost 0.00 461 | :output-cost 0.00 462 | :cutoff-date "2025-01") 463 | (gemini-2.5-pro-preview-03-25 464 | :description "Enhanced reasoning, multimodal understanding & advanced coding" 465 | :capabilities (tool-use json media) 466 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 467 | "application/pdf" "text/plain" "text/csv" "text/html") 468 | :context-window 1000 469 | :input-cost 1.25 ; 2.50 for >200k tokens 470 | :output-cost 10.00 ; 15 for >200k tokens 471 | :cutoff-date "2025-01") 472 | (gemini-2.5-flash-preview-04-17 473 | :description "Best Gemini model in terms of price-performance, offering well-rounded capabilities" 474 | :capabilities (tool-use json media) 475 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 476 | "application/pdf" "text/plain" "text/csv" "text/html") 477 | :context-window 1000 478 | :input-cost 0.15 479 | :output-cost 0.60 ; 3.50 for thinking 480 | :cutoff-date "2025-01") 481 | (gemini-2.5-flash-preview-05-20 482 | :description "Best Gemini model in terms of price-performance, offering well-rounded capabilities" 483 | :capabilities (tool-use json media) 484 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 485 | "application/pdf" "text/plain" "text/csv" "text/html") 486 | :context-window 1048 ; 65536 output token limit 487 | :input-cost 0.15 488 | :output-cost 0.60 ; 3.50 for thinking 489 | :cutoff-date "2025-01") 490 | (gemini-2.5-pro-preview-05-06 491 | :description "Previously the most powerful Gemini thinking model with state-of-the-art performance" 492 | :capabilities (tool-use json media) 493 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 494 | "application/pdf" "text/plain" "text/csv" "text/html") 495 | :context-window 1048 ; 65536 output token limit 496 | :input-cost 1.25 ; 2.50 for >200k tokens 497 | :output-cost 10.00 ; 15 for >200k tokens 498 | :cutoff-date "2025-01") 499 | (gemini-2.5-pro-preview-06-05 500 | :description "Most powerful Gemini thinking model with state-of-the-art performance" 501 | :capabilities (tool-use json media) 502 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 503 | "application/pdf" "text/plain" "text/csv" "text/html") 504 | :context-window 1048 ; 65536 output token limit 505 | :input-cost 1.25 ; 2.50 for >200k tokens 506 | :output-cost 10.00 ; 15 for >200k tokens 507 | :cutoff-date "2025-01") 508 | (gemini-2.0-flash-thinking-exp 509 | :description "DEPRECATED: Please use gemini-2.0-flash-thinking-exp-01-21 instead." 510 | :capabilities (tool-use media) 511 | :context-window 32 512 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 513 | "text/plain" "text/csv" "text/html") 514 | :cutoff-date "2024-08")) 515 | "List of available Gemini models and associated properties. 516 | Keys: 517 | 518 | - `:description': a brief description of the model. 519 | 520 | - `:capabilities': a list of capabilities supported by the model. 521 | 522 | - `:mime-types': a list of supported MIME types for media files. 523 | 524 | - `:context-window': the context window size, in thousands of tokens. 525 | 526 | - `:input-cost': the input cost, in US dollars per million tokens. 527 | 528 | - `:output-cost': the output cost, in US dollars per million tokens. 529 | 530 | - `:cutoff-date': the knowledge cutoff date. 531 | 532 | - `:request-params': a plist of additional request parameters to 533 | include when using this model. 534 | 535 | Information about the Gemini models was obtained from the following 536 | source: 537 | 538 | - 539 | - 540 | - ") 541 | 542 | ;;;###autoload 543 | (cl-defun gptel-make-gemini 544 | (name &key curl-args header key request-params 545 | (stream nil) 546 | (host "generativelanguage.googleapis.com") 547 | (protocol "https") 548 | (models gptel--gemini-models) 549 | (endpoint "/v1beta/models")) 550 | 551 | "Register a Gemini backend for gptel with NAME. 552 | 553 | Keyword arguments: 554 | 555 | CURL-ARGS (optional) is a list of additional Curl arguments. 556 | 557 | HOST (optional) is the API host, defaults to 558 | \"generativelanguage.googleapis.com\". 559 | 560 | MODELS is a list of available model names, as symbols. 561 | Additionally, you can specify supported LLM capabilities like 562 | vision or tool-use by appending a plist to the model with more 563 | information, in the form 564 | 565 | (model-name . plist) 566 | 567 | For a list of currently recognized plist keys, see 568 | `gptel--gemini-models'. An example of a model specification 569 | including both kinds of specs: 570 | 571 | :models 572 | \\='(gemini-2.0-flash-lite ;Simple specs 573 | gemini-1.5-flash 574 | (gemini-1.5-pro-latest ;Full spec 575 | :description 576 | \"Complex reasoning tasks, problem solving and data extraction\" 577 | :capabilities (tool json) 578 | :mime-types 579 | (\"image/jpeg\" \"image/png\" \"image/webp\" \"image/heic\"))) 580 | 581 | 582 | STREAM is a boolean to enable streaming responses, defaults to 583 | false. 584 | 585 | PROTOCOL (optional) specifies the protocol, \"https\" by default. 586 | 587 | ENDPOINT (optional) is the API endpoint for completions, defaults to 588 | \"/v1beta/models\". 589 | 590 | HEADER (optional) is for additional headers to send with each 591 | request. It should be an alist or a function that retuns an 592 | alist, like: 593 | ((\"Content-Type\" . \"application/json\")) 594 | 595 | KEY (optional) is a variable whose value is the API key, or 596 | function that returns the key. 597 | 598 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 599 | parameters (as plist keys) and values supported by the API. Use 600 | these to set parameters that gptel does not provide user options 601 | for." 602 | (declare (indent 1)) 603 | (let ((backend (gptel--make-gemini 604 | :curl-args curl-args 605 | :name name 606 | :host host 607 | :header header 608 | :models (gptel--process-models models) 609 | :protocol protocol 610 | :endpoint endpoint 611 | :stream stream 612 | :request-params request-params 613 | :key key 614 | :url (lambda () 615 | (let ((method 616 | (if (and stream gptel-use-curl gptel-stream) 617 | "streamGenerateContent" 618 | "generateContent"))) 619 | (format "%s://%s%s/%s:%s?key=%s" 620 | protocol 621 | host 622 | endpoint 623 | gptel-model 624 | method 625 | (gptel--get-api-key))))))) 626 | (prog1 backend 627 | (setf (alist-get name gptel--known-backends 628 | nil nil #'equal) 629 | backend)))) 630 | 631 | (provide 'gptel-gemini) 632 | ;;; gptel-gemini.el ends here 633 | -------------------------------------------------------------------------------- /gptel-gh.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-gh.el --- Github Copilot AI suppport for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; This program is free software; you can redistribute it and/or modify 4 | ;; it under the terms of the GNU General Public License as published by 5 | ;; the Free Software Foundation, either version 3 of the License, or 6 | ;; (at your option) any later version. 7 | 8 | ;; This program is distributed in the hope that it will be useful, 9 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ;; GNU General Public License for more details. 12 | 13 | ;; You should have received a copy of the GNU General Public License 14 | ;; along with this program. If not, see . 15 | 16 | ;;; Commentary: 17 | 18 | ;; This file adds support for Github Copilot API to gptel 19 | 20 | ;;; Code: 21 | 22 | (eval-when-compile 23 | (require 'cl-lib)) 24 | (require 'map) 25 | (require 'gptel) 26 | (require 'browse-url) 27 | 28 | ;;; Github Copilot 29 | (defconst gptel--gh-models 30 | '((gpt-4o 31 | :description 32 | "Advanced model for complex tasks; cheaper & faster than GPT-Turbo" 33 | :capabilities (media tool-use json url) 34 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp") 35 | :context-window 128 :input-cost 2.5 :output-cost 10 :cutoff-date "2023-10") 36 | (gpt-4o-copilot 37 | :description "Cheap model for fast tasks; cheaper & more capable than GPT-3.5 Turbo" 38 | :context-window 128 39 | :input-cost 0.15 40 | :output-cost 0.60 41 | :cutoff-date "2023-10") 42 | (gpt-4.1 43 | :description "Flagship model for complex tasks" 44 | :capabilities (media tool-use json url) 45 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp") 46 | :context-window 1024 47 | :input-cost 2.0 48 | :output-cost 8.0 49 | :cutoff-date "2024-05") 50 | (gpt-4.5-preview 51 | :description "Largest and most capable GPT model to date" 52 | :capabilities (url) 53 | :context-window 128 54 | :input-cost 75 55 | :output-cost 150 56 | :cutoff-date "2023-10") 57 | (o1 58 | :description "Reasoning model designed to solve hard problems across domains" 59 | :capabilities (reasoning tool-use) 60 | :context-window 200 61 | :input-cost 15 62 | :output-cost 60 63 | :cutoff-date "2023-10" 64 | :request-params (:stream :json-false)) 65 | (o3 66 | :description "Well-rounded and powerful model across domains" 67 | :capabilities (reasoning media tool-use json url) 68 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp") 69 | :context-window 200 70 | :input-cost 10 71 | :output-cost 40 72 | :cutoff-date "2024-05") 73 | (o3-mini 74 | :description "High intelligence at the same cost and latency targets of o1-mini" 75 | :capabilities (reasoning tool-use) 76 | :context-window 200 77 | :input-cost 3 78 | :output-cost 12 79 | :cutoff-date "2023-10") 80 | (o4-mini 81 | :description "Fast, effective reasoning with efficient performance in coding and visual tasks" 82 | :capabilities (reasoning media tool-use json url) 83 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp") 84 | :context-window 200 85 | :input-cost 1.10 86 | :output-cost 4.40 87 | :cutoff-date "2024-05") 88 | (claude-3.5-sonnet 89 | :description "Highest level of intelligence and capability" 90 | :capabilities (media tool-use cache) 91 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp" "application/pdf") 92 | :context-window 200 93 | :input-cost 3 94 | :output-cost 15 95 | :cutoff-date "2024-04") 96 | (claude-3.7-sonnet 97 | :description "Hybrid model capable of standard thinking and extended thinking modes" 98 | :capabilities (media tool-use cache) 99 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp" "application/pdf") 100 | :context-window 200 101 | :input-cost 3 102 | :output-cost 15 103 | :cutoff-date "2025-02") 104 | (claude-3.7-sonnet-thought 105 | :description "Claude 3.7 Sonnet Thinking" 106 | :capabilities (media cache) 107 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp" "application/pdf") 108 | :context-window 200 109 | :input-cost 3 110 | :output-cost 15 111 | :cutoff-date "2025-02") 112 | (claude-sonnet-4 113 | :description "High-performance model with exceptional reasoning and efficiency" 114 | :capabilities (media tool-use cache) 115 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp" "application/pdf") 116 | :context-window 200 117 | :input-cost 3 118 | :output-cost 15 119 | :cutoff-date "2025-03") 120 | (claude-opus-4 121 | :description "Most capable model for complex reasoning and advanced coding" 122 | :capabilities (media tool-use cache) 123 | :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp" "application/pdf") 124 | :context-window 200 125 | :input-cost 15 126 | :output-cost 75 127 | :cutoff-date "2025-03") 128 | (gemini-2.0-flash-001 129 | :description "Next gen, high speed, multimodal for a diverse variety of tasks" 130 | :capabilities (json media) 131 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 132 | "application/pdf" "text/plain" "text/csv" "text/html") 133 | :context-window 1000 134 | :input-cost 0.10 135 | :output-cost 0.40 136 | :cutoff-date "2024-08") 137 | (gemini-2.5-pro 138 | :description "Next gen, high speed, multimodal for a diverse variety of tasks" 139 | :capabilities (tool-use json media) 140 | :mime-types ("image/png" "image/jpeg" "image/webp" "image/heic" "image/heif" 141 | "application/pdf" "text/plain" "text/csv" "text/html") 142 | :context-window 1000 143 | :input-cost 0.10 144 | :output-cost 0.40 145 | :cutoff-date "2024-08"))) 146 | 147 | (cl-defstruct (gptel--gh (:include gptel-openai) 148 | (:copier nil) 149 | (:constructor gptel--make-gh)) 150 | token github-token sessionid machineid) 151 | 152 | (defcustom gptel-gh-github-token-file (expand-file-name ".cache/copilot-chat/github-token" 153 | user-emacs-directory) 154 | "File where the GitHub token is stored." 155 | :type 'string 156 | :group 'gptel) 157 | 158 | (defcustom gptel-gh-token-file (expand-file-name ".cache/copilot-chat/token" 159 | user-emacs-directory) 160 | "File where the chat token is cached." 161 | :type 'string 162 | :group 'gptel) 163 | 164 | (defconst gptel--gh-auth-common-headers 165 | `(("editor-plugin-version" . "gptel/*") 166 | ("editor-version" . ,(concat "emacs/" emacs-version)))) 167 | 168 | (defconst gptel--gh-client-id "Iv1.b507a08c87ecfe98") 169 | 170 | ;; https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random) 171 | (defun gptel--gh-uuid () 172 | "Generate a UUID v4-1." 173 | (format "%04x%04x-%04x-4%03x-8%03x-%04x%04x%04x" 174 | (random #x10000) (random #x10000) 175 | (random #x10000) 176 | (random #x1000) 177 | (random #x1000) 178 | (random #x10000) (random #x10000) (random #x10000))) 179 | 180 | (defun gptel--gh-machine-id () 181 | "Generate a machine ID." 182 | (let ((hex-chars "0123456789abcdef") 183 | (length 65) 184 | hex) 185 | (dotimes (_ length) 186 | (setq hex (nconc hex (list (aref hex-chars (random 16)))))) 187 | (apply #'string hex))) 188 | 189 | (defun gptel--gh-restore (file) 190 | "Restore saved object from FILE." 191 | (when (file-exists-p file) 192 | ;; We set the coding system to `utf-8-auto-dos' when reading so that 193 | ;; files with CR EOL can still be read properly 194 | (let ((coding-system-for-read 'utf-8-auto-dos)) 195 | (with-temp-buffer 196 | (set-buffer-multibyte nil) 197 | (insert-file-contents-literally file) 198 | (goto-char (point-min)) 199 | (read (current-buffer)))))) 200 | 201 | (defun gptel--gh-save (file obj) 202 | "Save OBJ to FILE." 203 | (let ((print-length nil) 204 | (print-level nil) 205 | (coding-system-for-write 'utf-8-unix)) 206 | (make-directory (file-name-directory file) t) 207 | (write-region (prin1-to-string obj) nil file nil :silent) 208 | obj)) 209 | 210 | (defun gptel--gh-login() 211 | "Manage github login." 212 | (pcase-let (((map :device_code :user_code :verification_uri) 213 | (gptel--url-retrieve 214 | "https://github.com/login/device/code" 215 | :method 'post 216 | :headers gptel--gh-auth-common-headers 217 | :data `( :client_id ,gptel--gh-client-id 218 | :scope "read:user")))) 219 | (gui-set-selection 'CLIPBOARD user_code) 220 | (read-from-minibuffer 221 | (format "Your one-time code %s is copied. \ 222 | Press ENTER to open GitHub in your browser. \ 223 | If your browser does not open automatically, browse to %s." 224 | user_code verification_uri)) 225 | (browse-url verification_uri) 226 | (read-from-minibuffer "Press ENTER after authorizing.") 227 | (thread-last 228 | (plist-get 229 | (gptel--url-retrieve 230 | "https://github.com/login/oauth/access_token" 231 | :method 'post 232 | :headers gptel--gh-auth-common-headers 233 | :data `( :client_id ,gptel--gh-client-id 234 | :device_code ,device_code 235 | :grant_type "urn:ietf:params:oauth:grant-type:device_code")) 236 | :access_token) 237 | (gptel--gh-save gptel-gh-github-token-file) 238 | (setf (gptel--gh-github-token gptel-backend))))) 239 | 240 | (defun gptel--gh-renew-token () 241 | "Renew session token." 242 | (let ((token 243 | (gptel--url-retrieve 244 | "https://api.github.com/copilot_internal/v2/token" 245 | :method 'get 246 | :headers `(("authorization" 247 | . ,(format "token %s" (gptel--gh-github-token gptel-backend))) 248 | ,@gptel--gh-auth-common-headers)))) 249 | (if (not (plist-get token :token)) 250 | (progn 251 | (setf (gptel--gh-github-token gptel-backend) nil) 252 | (user-error "Error: You might not have access to Github Copilot Chat!")) 253 | (thread-last 254 | (gptel--gh-save gptel-gh-token-file token) 255 | (setf (gptel--gh-token gptel-backend)))))) 256 | 257 | (defun gptel--gh-auth () 258 | "Authenticate with GitHub Copilot API. 259 | 260 | We first need github authorization (github token). 261 | Then we need a session token." 262 | (unless (gptel--gh-github-token gptel-backend) 263 | (let ((token (gptel--gh-restore gptel-gh-github-token-file))) 264 | (if token 265 | (setf (gptel--gh-github-token gptel-backend) token) 266 | (gptel--gh-login)))) 267 | 268 | (when (null (gptel--gh-token gptel-backend)) 269 | ;; try to load token from `gptel-gh-token-file' 270 | (setf (gptel--gh-token gptel-backend) 271 | (gptel--gh-restore gptel-gh-token-file))) 272 | 273 | (pcase-let (((map :token :expires_at) 274 | (gptel--gh-token gptel-backend))) 275 | (when (or (null token) 276 | (and expires_at 277 | (> (round (float-time (current-time))) 278 | expires_at))) 279 | (gptel--gh-renew-token)))) 280 | 281 | ;;;###autoload 282 | (cl-defun gptel-make-gh-copilot 283 | (name &key curl-args request-params 284 | (header (lambda () 285 | (gptel--gh-auth) 286 | `(("openai-intent" . "conversation-panel") 287 | ("authorization" . ,(concat "Bearer " 288 | (plist-get (gptel--gh-token gptel-backend) :token))) 289 | ("x-request-id" . ,(gptel--gh-uuid)) 290 | ("vscode-sessionid" . ,(or (gptel--gh-sessionid gptel-backend) "")) 291 | ("vscode-machineid" . ,(or (gptel--gh-machineid gptel-backend) "")) 292 | ,@(when (and gptel-track-media 293 | (gptel--model-capable-p 'media)) 294 | `(("copilot-vision-request" . "true"))) 295 | ("copilot-integration-id" . "vscode-chat")))) 296 | (host "api.githubcopilot.com") 297 | (protocol "https") 298 | (endpoint "/chat/completions") 299 | (stream t) 300 | (models gptel--gh-models)) 301 | "Register a Github Copilot chat backend for gptel with NAME. 302 | 303 | Keyword arguments: 304 | 305 | CURL-ARGS (optional) is a list of additional Curl arguments. 306 | 307 | HOST (optional) is the API host, typically \"api.githubcopilot.com\". 308 | 309 | MODELS is a list of available model names, as symbols. 310 | Additionally, you can specify supported LLM capabilities like 311 | vision or tool-use by appending a plist to the model with more 312 | information, in the form 313 | 314 | (model-name . plist) 315 | 316 | For a list of currently recognized plist keys, see 317 | `gptel--openai-models'. An example of a model specification 318 | including both kinds of specs: 319 | 320 | :models 321 | \\='(gpt-3.5-turbo ;Simple specs 322 | gpt-4-turbo 323 | (gpt-4o-mini ;Full spec 324 | :description 325 | \"Affordable and intelligent small model for lightweight tasks\" 326 | :capabilities (media tool json url) 327 | :mime-types 328 | (\"image/jpeg\" \"image/png\" \"image/gif\" \"image/webp\"))) 329 | 330 | Defaults to a list of models supported by GitHub Copilot. 331 | 332 | STREAM is a boolean to toggle streaming responses, defaults to 333 | false. 334 | 335 | PROTOCOL (optional) specifies the protocol, https by default. 336 | 337 | ENDPOINT (optional) is the API endpoint for completions, defaults to 338 | \"/chat/completions\". 339 | 340 | HEADER (optional) is for additional headers to send with each 341 | request. It should be an alist or a function that returns an 342 | alist, like: 343 | ((\"Content-Type\" . \"application/json\")) 344 | 345 | Defaults to headers required by GitHub Copilot. 346 | 347 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 348 | parameters (as plist keys) and values supported by the API. Use 349 | these to set parameters that gptel does not provide user options 350 | for." 351 | (declare (indent 1)) 352 | (let ((backend (gptel--make-gh 353 | :name name 354 | :host host 355 | :header header 356 | :models (gptel--process-models models) 357 | :protocol protocol 358 | :endpoint endpoint 359 | :stream stream 360 | :request-params request-params 361 | :curl-args curl-args 362 | :url (concat protocol "://" host endpoint) 363 | :machineid (gptel--gh-machine-id)))) 364 | (setf (alist-get name gptel--known-backends nil nil #'equal) backend) 365 | backend)) 366 | 367 | (provide 'gptel-gh) 368 | ;;; gptel-gh.el ends here 369 | 370 | ;; Local Variables: 371 | ;; byte-compile-warnings: (not docstrings) 372 | ;; End: 373 | -------------------------------------------------------------------------------- /gptel-integrations.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-transient.el --- Integrations for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | ;; Keywords: convenience 7 | 8 | ;; SPDX-License-Identifier: GPL-3.0-or-later 9 | 10 | ;; This program is free software; you can redistribute it and/or modify 11 | ;; it under the terms of the GNU General Public License as published by 12 | ;; the Free Software Foundation, either version 3 of the License, or 13 | ;; (at your option) any later version. 14 | 15 | ;; This program is distributed in the hope that it will be useful, 16 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | ;; GNU General Public License for more details. 19 | 20 | ;; You should have received a copy of the GNU General Public License 21 | ;; along with this program. If not, see . 22 | 23 | ;;; Commentary: 24 | 25 | ;; Integrations with related packages for gptel. To use these, run 26 | ;; 27 | ;; (require 'gptel-integrations) 28 | ;; 29 | ;; For MCP integration: 30 | ;; - Run M-x `gptel-mcp-connect' and M-x `gptel-mcp-disconnect', OR 31 | ;; - Use gptel's tools menu, M-x `gptel-tools', OR 32 | ;; - Access tools from `gptel-menu' 33 | 34 | ;;; Code: 35 | (require 'gptel) 36 | (require 'cl-lib) 37 | (eval-when-compile (require 'transient)) 38 | 39 | ;;;; MCP integration - requires the mcp package 40 | (declare-function mcp-hub-get-all-tool "mcp-hub") 41 | (declare-function mcp-hub-get-servers "mcp-hub") 42 | (declare-function mcp-hub-start-all-server "mcp-hub") 43 | (declare-function mcp-stop-server "mcp") 44 | (declare-function mcp-hub "mcp-hub") 45 | (declare-function mcp--status "mcp-hub") 46 | (declare-function mcp--tools "mcp-hub") 47 | (declare-function mcp-make-text-tool "mcp-hub") 48 | (defvar mcp-hub-servers) 49 | (defvar mcp-server-connections) 50 | 51 | (defun gptel-mcp-connect (&optional servers server-callback interactive) 52 | "Add gptel tools from MCP servers using the mcp package. 53 | 54 | MCP servers are started if required. SERVERS is a list of server 55 | names (strings) to connect to. If nil, all known servers are 56 | considered. 57 | 58 | If INTERACTIVE is non-nil (or called interactively), guide the user 59 | through setting up mcp, and query for servers to retrieve tools from. 60 | 61 | Call SERVER-CALLBACK after starting MCP servers." 62 | (interactive (list nil nil t)) 63 | (if (locate-library "mcp-hub") 64 | (unless (require 'mcp-hub nil t) 65 | (user-error "Could not load `mcp-hub'! Please install\ 66 | or configure the mcp package")) 67 | (user-error "Could not find mcp! Please install or configure the mcp package")) 68 | (if (null mcp-hub-servers) 69 | (user-error "No MCP servers available! Please configure `mcp-hub-servers'") 70 | (setq servers 71 | (if servers 72 | (mapcar (lambda (s) (assoc s mcp-hub-servers)) servers) 73 | mcp-hub-servers)) 74 | (let ((unregistered-servers ;Available servers minus servers already registered with gptel 75 | (cl-loop for server in servers 76 | with registered-names = 77 | (cl-loop for (cat . _tools) in gptel--known-tools 78 | if (string-prefix-p "mcp-" cat) 79 | collect (substring cat 4)) 80 | unless (member (car server) registered-names) 81 | collect server))) 82 | (if unregistered-servers 83 | (let* ((servers 84 | (if interactive 85 | (let ((picks 86 | (completing-read-multiple 87 | "Add tools from MCP servers (separate with \",\"): " 88 | (cons '("ALL") unregistered-servers) nil t))) 89 | (if (member "ALL" picks) 90 | unregistered-servers 91 | (mapcar (lambda (s) (assoc s mcp-hub-servers)) picks))) 92 | unregistered-servers)) 93 | (server-active-p 94 | (lambda (server) (gethash (car server) mcp-server-connections))) 95 | (inactive-servers (cl-remove-if server-active-p servers)) 96 | (add-all-tools 97 | (lambda (&optional server-names) 98 | "Register and add tools from servers. Report failures." 99 | (let ((tools (gptel-mcp--get-tools server-names)) 100 | (now-active (cl-remove-if-not server-active-p mcp-hub-servers))) 101 | (mapc (lambda (tool) (apply #'gptel-make-tool tool)) tools) 102 | (gptel-mcp--activate-tools tools) 103 | (if-let* ((failed (cl-set-difference inactive-servers now-active 104 | :test #'equal))) 105 | (progn 106 | (message "Inactive-before: %S, Now-Active: %S" inactive-servers now-active) 107 | (message (substitute-command-keys 108 | "%d/%d server%s failed to start: %s. Run \\[mcp-hub] to investigate.") 109 | (length failed) (length inactive-servers) 110 | (if (= (length failed) 1) "" "s") 111 | (mapconcat #'car failed ", "))) 112 | (let ((added (or server-names (mapcar #'car now-active)))) 113 | (message "Added %d tools from %d MCP server%s: %s" 114 | (length tools) (length added) 115 | (if (= (length added) 1) "" "s") 116 | (mapconcat #'identity added ", ")))) 117 | (when (functionp server-callback) (funcall server-callback)))))) 118 | 119 | (if inactive-servers ;start servers 120 | (mcp-hub-start-all-server 121 | add-all-tools (mapcar #'car inactive-servers)) 122 | (funcall add-all-tools (mapcar #'car servers)))) 123 | (message "All MCP tools are already available to gptel!") 124 | (when (functionp server-callback) (funcall server-callback)))))) 125 | 126 | (defun gptel-mcp-disconnect (&optional servers interactive) 127 | "Unregister gptel tools provided by MCP servers using the mcp package. 128 | 129 | SERVERS is a list of server names (strings) to disconnect from. 130 | 131 | If INTERACTIVE is non-nil, query the user about which tools to remove." 132 | (interactive (list nil t)) 133 | (if-let* ((names-alist 134 | (cl-loop 135 | for (category . _tools) in gptel--known-tools 136 | if (and (string-match "^mcp-\\(.*\\)" category) 137 | (or (null servers) ;Consider all if nil 138 | (member (match-string 1 category) servers))) 139 | collect (cons (match-string 1 category) category)))) 140 | (let ((remove-fn (lambda (cat-names) 141 | (setq gptel-tools ;Remove from gptel-tools 142 | (cl-delete-if (lambda (tool) (member (gptel-tool-category tool) 143 | cat-names)) 144 | gptel-tools)) 145 | (mapc (lambda (category) ;Remove from registry 146 | (setf (alist-get category gptel--known-tools 147 | nil t #'equal) 148 | nil)) 149 | cat-names)))) 150 | (if interactive 151 | (when-let* ((server-names 152 | (completing-read-multiple 153 | "Remove MCP server tools for (separate with \",\"): " 154 | (cons '("ALL" . nil) names-alist) 155 | nil t))) 156 | (when (member "ALL" server-names) 157 | (setq server-names (mapcar #'car names-alist))) 158 | (funcall remove-fn ;remove selected tool categories 159 | (mapcar (lambda (s) (cdr (assoc s names-alist))) server-names)) 160 | (if (y-or-n-p 161 | (format "Removed MCP tools from %d server%s. Also shut down MCP servers?" 162 | (length server-names) 163 | (if (= (length server-names) 1) "" "s"))) 164 | (progn (mapc #'mcp-stop-server server-names) 165 | (message "Shut down MCP servers: %S" server-names)) 166 | (message "Removed MCP tools for: %S" server-names))) 167 | (funcall remove-fn (mapcar #'cdr names-alist)))) 168 | ;; No MCP tools, ask to shut down servers 169 | (if (cl-loop 170 | for v being the hash-values of mcp-server-connections 171 | never v) 172 | (when interactive (message "No MCP servers active!")) 173 | (when (or (not interactive) 174 | (y-or-n-p "No MCP tools in gptel! Shut down all MCP servers? ")) 175 | (dolist (server mcp-hub-servers) 176 | (when (gethash (car server) mcp-server-connections) 177 | (mcp-stop-server (car server)))))))) 178 | 179 | (defun gptel-mcp--get-tools (&optional server-names) 180 | "Return tools from running MCP servers. 181 | 182 | SERVER-NAMES is a list of server names to add tools from. Add tools 183 | from all connected servers if it is nil." 184 | (unless server-names 185 | (setq server-names (hash-table-keys mcp-server-connections))) 186 | (let ((servers (mapcar (lambda (n) (gethash n mcp-server-connections)) 187 | server-names))) 188 | (cl-mapcan 189 | (lambda (name server) 190 | (when (and server (equal (mcp--status server) 'connected)) 191 | (when-let* ((tools (mcp--tools server)) 192 | (tool-names (mapcar #'(lambda (tool) (plist-get tool :name)) tools))) 193 | (mapcar (lambda (tool-name) 194 | (plist-put (mcp-make-text-tool name tool-name t) 195 | :category (format "mcp-%s" name))) 196 | tool-names)))) 197 | server-names servers))) 198 | 199 | (defun gptel-mcp--activate-tools (&optional tools) 200 | "Activate TOOLS or all MCP tools in current gptel session." 201 | (unless tools (setq tools (gptel-mcp--get-tools))) 202 | (dolist (tool tools) 203 | (cl-pushnew (gptel-get-tool (list (plist-get tool :category) 204 | (plist-get tool :name))) 205 | gptel-tools))) 206 | 207 | (with-eval-after-load 'gptel-transient 208 | (transient-define-suffix gptel--suffix-mcp-connect () 209 | "Register tools provided by MCP servers." 210 | :key "M+" 211 | :description "Add MCP server tools" 212 | :transient t 213 | (interactive) 214 | (condition-case err 215 | (gptel-mcp-connect 216 | nil (lambda () (when-let* ((transient--prefix) 217 | ((eq (oref transient--prefix command) 218 | 'gptel-tools))) 219 | (transient-setup 'gptel-tools))) 220 | t) 221 | (user-error (message "%s" (cadr err)))) 222 | (transient-setup)) 223 | 224 | (transient-define-suffix gptel--suffix-mcp-disconnect () 225 | "Remove tools provided by MCP servers from gptel." 226 | :key "M-" 227 | :description (lambda () (if (cl-some (lambda (cat) (string-match-p "^mcp-" cat)) 228 | (map-keys gptel--known-tools)) 229 | "Remove MCP server tools" 230 | "Shut down MCP servers")) 231 | :transient t 232 | :inapt-if 233 | (lambda () (or (not (boundp 'mcp-hub-servers)) 234 | (null mcp-hub-servers) 235 | (cl-loop 236 | for v being the hash-values of mcp-server-connections 237 | never v))) 238 | (interactive) 239 | (call-interactively #'gptel-mcp-disconnect) 240 | (transient-setup)) 241 | 242 | (transient-remove-suffix 'gptel-tools '(0 2)) 243 | (transient-append-suffix 'gptel-tools '(0 -1) 244 | ["" 245 | (gptel--suffix-mcp-connect) 246 | (gptel--suffix-mcp-disconnect)])) 247 | 248 | (provide 'gptel-integrations) 249 | ;;; gptel-integrations.el ends here 250 | 251 | ;; Local Variables: 252 | ;; byte-compile-warnings: (not noruntime) 253 | ;; End: 254 | -------------------------------------------------------------------------------- /gptel-kagi.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-kagi.el --- Kagi support for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | ;; Keywords: hypermedia 7 | 8 | ;; This program is free software; you can redistribute it and/or modify 9 | ;; it under the terms of the GNU General Public License as published by 10 | ;; the Free Software Foundation, either version 3 of the License, or 11 | ;; (at your option) any later version. 12 | 13 | ;; This program is distributed in the hope that it will be useful, 14 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | ;; GNU General Public License for more details. 17 | 18 | ;; You should have received a copy of the GNU General Public License 19 | ;; along with this program. If not, see . 20 | 21 | ;;; Commentary: 22 | 23 | ;; This file adds support for the Kagi FastGPT LLM API to gptel 24 | 25 | ;;; Code: 26 | (require 'gptel) 27 | (require 'cl-generic) 28 | (eval-when-compile 29 | (require 'cl-lib)) 30 | 31 | (declare-function gptel-context--wrap "gptel-context") 32 | 33 | ;;; Kagi 34 | (cl-defstruct (gptel-kagi (:constructor gptel--make-kagi) 35 | (:copier nil) 36 | (:include gptel-backend))) 37 | 38 | (cl-defmethod gptel--parse-response ((_backend gptel-kagi) response info) 39 | (let* ((data (plist-get response :data)) 40 | (output (plist-get data :output)) 41 | (references (plist-get data :references))) 42 | (if (eq references :null) (setq references nil)) 43 | (if (eq output :null) (setq output nil)) 44 | (when references 45 | (setq references 46 | (cl-loop with linker = 47 | (pcase (buffer-local-value 'major-mode 48 | (plist-get info :buffer)) 49 | ('org-mode 50 | (lambda (text url) 51 | (format "[[%s][%s]]" url text))) 52 | ('markdown-mode 53 | (lambda (text url) 54 | (format "[%s](%s)" text url))) 55 | (_ (lambda (text url) 56 | (buttonize 57 | text (lambda (data) (browse-url data)) 58 | url)))) 59 | for ref across references 60 | for title = (plist-get ref :title) 61 | for snippet = (plist-get ref :snippet) 62 | for url = (plist-get ref :url) 63 | for n upfrom 1 64 | collect 65 | (concat (format "[%d] " n) 66 | (funcall linker title url) ": " 67 | (replace-regexp-in-string 68 | "" "*" snippet)) 69 | into ref-strings 70 | finally return 71 | (concat "\n\n" (mapconcat #'identity ref-strings "\n"))))) 72 | (concat output references))) 73 | 74 | ;; TODO: Add model and backend-specific request-params support 75 | (cl-defmethod gptel--request-data ((_backend gptel-kagi) prompts) 76 | "JSON encode PROMPTS for Kagi." 77 | (pcase-exhaustive (gptel--model-name gptel-model) 78 | ("fastgpt" 79 | `(,@prompts :web_search t :cache t)) 80 | ((and model (guard (string-prefix-p "summarize" model))) 81 | `(,@prompts :engine ,(substring model 10))))) 82 | 83 | (cl-defmethod gptel--parse-buffer ((_backend gptel-kagi) &optional _max-entries) 84 | (let ((url (or (thing-at-point 'url) 85 | (get-text-property (point) 'shr-url) 86 | (get-text-property (point) 'image-url))) 87 | ;; (filename (thing-at-point 'existing-filename)) ;no file upload support yet 88 | (prop (text-property-search-backward 89 | 'gptel 'response 90 | (when (get-char-property (max (point-min) (1- (point))) 91 | 'gptel) 92 | t)))) 93 | (if (and url (string-prefix-p "summarize" (gptel--model-name gptel-model))) 94 | (list :url url) 95 | (if (and (or gptel-mode gptel-track-response) 96 | (prop-match-p prop) 97 | (prop-match-value prop)) 98 | (user-error "No user prompt found!") 99 | (let ((prompts 100 | (if (or gptel-mode gptel-track-response) 101 | (or (gptel--trim-prefixes 102 | (buffer-substring-no-properties (prop-match-beginning prop) 103 | (prop-match-end prop))) 104 | "") 105 | (string-trim (buffer-substring-no-properties (point-min) (point-max)))))) 106 | (pcase-exhaustive (gptel--model-name gptel-model) 107 | ("fastgpt" (setq prompts (list :query (if (prop-match-p prop) prompts "")))) 108 | ((and model (guard (string-prefix-p "summarize" model))) 109 | ;; If the entire contents of the prompt looks like a url, send the url 110 | ;; Else send the text of the region 111 | (setq prompts 112 | (if-let* (((prop-match-p prop)) 113 | (engine (substring model 10))) 114 | ;; It's a region of text 115 | (list :text prompts) 116 | "")))) 117 | prompts))))) 118 | 119 | (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-kagi) prompts) 120 | (cond 121 | ((plist-get prompts :url) 122 | (message "Ignoring gptel context for URL summary request.")) 123 | ((plist-get prompts :query) 124 | (cl-callf gptel-context--wrap (plist-get prompts :query))) 125 | ((plist-get prompts :text) 126 | (cl-callf gptel-context--wrap (plist-get prompts :text))))) 127 | 128 | ;;;###autoload 129 | (cl-defun gptel-make-kagi 130 | (name &key curl-args stream key 131 | (host "kagi.com") 132 | (header (lambda () `(("Authorization" . ,(concat "Bot " (gptel--get-api-key)))))) 133 | (models '((fastgpt :capabilities (nosystem)) 134 | (summarize:cecil :capabilities (nosystem)) 135 | (summarize:agnes :capabilities (nosystem)) 136 | (summarize:daphne :capabilities (nosystem)) 137 | (summarize:muriel :capabilities (nosystem)))) 138 | (protocol "https") 139 | (endpoint "/api/v0/")) 140 | "Register a Kagi FastGPT backend for gptel with NAME. 141 | 142 | Keyword arguments: 143 | 144 | CURL-ARGS (optional) is a list of additional Curl arguments. 145 | 146 | HOST is the Kagi host (with port), defaults to \"kagi.com\". 147 | 148 | MODELS is a list of available Kagi models: only fastgpt is supported. 149 | 150 | STREAM is a boolean to toggle streaming responses, defaults to 151 | false. Kagi does not support a streaming API yet. 152 | 153 | PROTOCOL (optional) specifies the protocol, https by default. 154 | 155 | ENDPOINT (optional) is the API endpoint for completions, defaults to 156 | \"/api/v0/fastgpt\". 157 | 158 | HEADER (optional) is for additional headers to send with each 159 | request. It should be an alist or a function that retuns an 160 | alist, like: 161 | ((\"Content-Type\" . \"application/json\")) 162 | 163 | KEY (optional) is a variable whose value is the API key, or 164 | function that returns the key. 165 | 166 | Example: 167 | ------- 168 | 169 | (gptel-make-kagi \"Kagi\" :key my-kagi-key)" 170 | (declare (indent 1)) 171 | stream ;Silence byte-compiler 172 | (let ((backend (gptel--make-kagi 173 | :curl-args curl-args 174 | :name name 175 | :host host 176 | :header header 177 | :key key 178 | :models (gptel--process-models models) 179 | :protocol protocol 180 | :endpoint endpoint 181 | :url 182 | (lambda () 183 | (concat protocol "://" host endpoint 184 | (if (equal gptel-model 'fastgpt) 185 | "fastgpt" "summarize")))))) 186 | (prog1 backend 187 | (setf (alist-get name gptel--known-backends 188 | nil nil #'equal) 189 | backend)))) 190 | 191 | (provide 'gptel-kagi) 192 | ;;; gptel-kagi.el ends here 193 | 194 | ;; Local Variables: 195 | ;; byte-compile-warnings: (not docstrings) 196 | ;; End: 197 | -------------------------------------------------------------------------------- /gptel-ollama.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-ollama.el --- Ollama support for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | ;; Keywords: hypermedia 7 | 8 | ;; This program is free software; you can redistribute it and/or modify 9 | ;; it under the terms of the GNU General Public License as published by 10 | ;; the Free Software Foundation, either version 3 of the License, or 11 | ;; (at your option) any later version. 12 | 13 | ;; This program is distributed in the hope that it will be useful, 14 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | ;; GNU General Public License for more details. 17 | 18 | ;; You should have received a copy of the GNU General Public License 19 | ;; along with this program. If not, see . 20 | 21 | ;;; Commentary: 22 | 23 | ;; This file adds support for the Ollama LLM API to gptel 24 | 25 | ;;; Code: 26 | (require 'gptel) 27 | (require 'cl-generic) 28 | 29 | (declare-function json-read "json" ()) 30 | (declare-function gptel-context--wrap "gptel-context") 31 | (declare-function gptel-context--collect-media "gptel-context") 32 | (defvar json-object-type) 33 | 34 | ;;; Ollama 35 | (cl-defstruct (gptel-ollama (:constructor gptel--make-ollama) 36 | (:copier nil) 37 | (:include gptel-backend))) 38 | 39 | ;; FIXME(fsm) Remove this variable 40 | (defvar-local gptel--ollama-token-count 0 41 | "Token count for ollama conversations. 42 | 43 | This variable holds the total token count for conversations with 44 | Ollama models. 45 | 46 | Intended for internal use only.") 47 | 48 | (cl-defmethod gptel-curl--parse-stream ((_backend gptel-ollama) info) 49 | "Parse response stream for the Ollama API." 50 | (when (and (bobp) (re-search-forward "^{" nil t)) 51 | (forward-line 0)) 52 | (let* ((content-strs) (content) (pt (point))) 53 | (condition-case nil 54 | (while (setq content (gptel--json-read)) 55 | (setq pt (point)) 56 | (let ((done (map-elt content :done)) 57 | (response (map-nested-elt content '(:message :content)))) 58 | (when (and response (not (eq response :null))) 59 | (push response content-strs)) 60 | (unless (eq done :json-false) 61 | (with-current-buffer (plist-get info :buffer) 62 | (cl-incf gptel--ollama-token-count 63 | (+ (or (map-elt content :prompt_eval_count) 0) 64 | (or (map-elt content :eval_count) 0)))) 65 | (goto-char (point-max))))) 66 | (error (goto-char pt))) 67 | (apply #'concat (nreverse content-strs)))) 68 | 69 | (cl-defmethod gptel--parse-response ((_backend gptel-ollama) response info) 70 | "Parse a one-shot RESPONSE from the Ollama API and return text. 71 | 72 | Store response metadata in state INFO." 73 | (plist-put info :stop-reason (plist-get response :done_reason)) 74 | (plist-put info :output-tokens (plist-get response :eval_count)) 75 | (let* ((message (plist-get response :message)) 76 | (content (plist-get message :content))) 77 | (if (and content (not (or (eq content :null) (string-empty-p content)))) 78 | content 79 | (prog1 nil ; Look for tool calls only if no content 80 | (when-let* ((tool-calls (plist-get message :tool_calls))) 81 | ;; First add the tool call to the prompts list 82 | (let* ((data (plist-get info :data)) 83 | (prompts (plist-get data :messages))) 84 | (plist-put data :messages (vconcat prompts `(,message)))) 85 | ;; Then capture the tool call data for running the tool 86 | (cl-loop 87 | for tool-call across tool-calls ;replace ":arguments" with ":args" 88 | for call-spec = (copy-sequence (plist-get tool-call :function)) 89 | do (plist-put call-spec :args 90 | (plist-get call-spec :arguments)) 91 | (plist-put call-spec :arguments nil) 92 | collect call-spec into tool-use 93 | finally (plist-put info :tool-use tool-use))))))) 94 | 95 | (cl-defmethod gptel--request-data ((backend gptel-ollama) prompts) 96 | "JSON encode PROMPTS for sending to Ollama." 97 | (when gptel--system-message 98 | (push (list :role "system" 99 | :content gptel--system-message) 100 | prompts)) 101 | (let* ((prompts-plist 102 | (gptel--merge-plists 103 | `(:model ,(gptel--model-name gptel-model) 104 | :messages [,@prompts] 105 | :stream ,(or gptel-stream :json-false)) 106 | (gptel-backend-request-params gptel-backend) 107 | (gptel--model-request-params gptel-model))) 108 | ;; the initial options (if any) from request params 109 | (options-plist (plist-get prompts-plist :options))) 110 | 111 | (when (and gptel-use-tools gptel-tools) 112 | ;; TODO(tool): Find out how to force tool use for Ollama 113 | (plist-put prompts-plist :tools 114 | (gptel--parse-tools backend gptel-tools)) 115 | (plist-put prompts-plist :stream :json-false)) 116 | ;; if the temperature and max-tokens aren't set as 117 | ;; backend/model-specific, use the global settings 118 | (when (and gptel-temperature (not (plist-get options-plist :temperature))) 119 | (setq options-plist 120 | (plist-put options-plist :temperature gptel-temperature))) 121 | (when (and gptel-max-tokens (not (plist-get options-plist :num_predict))) 122 | (setq options-plist 123 | (plist-put options-plist :num_predict gptel-max-tokens))) 124 | (plist-put prompts-plist :options options-plist))) 125 | 126 | ;; NOTE: No `gptel--parse-tools' method required for gptel-ollama, since this is 127 | ;; handled by its defgeneric implementation 128 | 129 | (cl-defmethod gptel--parse-tool-results ((_backend gptel-ollama) tool-use) 130 | "Return a prompt containing tool call results in TOOL-USE." 131 | (mapcar (lambda (tool-call) 132 | (list :role "tool" :content (plist-get tool-call :result))) 133 | tool-use)) 134 | 135 | ;; NOTE: No `gptel--inject-prompt' method required for gptel-ollama, since this is 136 | ;; handled by its defgeneric implementation 137 | 138 | (cl-defmethod gptel--parse-list ((backend gptel-ollama) prompt-list) 139 | (if (consp (car prompt-list)) 140 | (let ((full-prompt)) ; Advanced format, list of lists 141 | (dolist (entry prompt-list) 142 | (pcase entry 143 | (`(prompt . ,msg) 144 | (push (list :role "user" :content (or (car-safe msg) msg)) 145 | full-prompt)) 146 | (`(response . ,msg) 147 | (push (list :role "assistant" :content (or (car-safe msg) msg)) 148 | full-prompt)) 149 | (`(tool . ,call) 150 | (push (list :role "assistant" 151 | :content "" 152 | :tool_calls `[(:function (:name ,(plist-get call :name) 153 | :arguments ,(plist-get call :args)))]) 154 | full-prompt) 155 | (push (car (gptel--parse-tool-results backend (list (cdr entry)))) 156 | full-prompt)))) 157 | (nreverse full-prompt)) 158 | (cl-loop for text in prompt-list ; Simple format, list of strings 159 | for role = t then (not role) 160 | if text collect 161 | (list :role (if role "user" "assistant") :content text)))) 162 | 163 | (cl-defmethod gptel--parse-buffer ((backend gptel-ollama) &optional max-entries) 164 | (let ((prompts) (prev-pt (point))) 165 | (if (or gptel-mode gptel-track-response) 166 | (while (and (or (not max-entries) (>= max-entries 0)) 167 | (goto-char (previous-single-property-change 168 | (point) 'gptel nil (point-min))) 169 | (not (= (point) prev-pt))) 170 | (pcase (get-char-property (point) 'gptel) 171 | ('response 172 | (when-let* ((content (gptel--trim-prefixes 173 | (buffer-substring-no-properties (point) prev-pt)))) 174 | (push (list :role "assistant" :content content) prompts))) 175 | (`(tool . ,_id) 176 | (save-excursion 177 | (condition-case nil 178 | (let* ((tool-call (read (current-buffer))) 179 | (name (plist-get tool-call :name)) 180 | (arguments (plist-get tool-call :args))) 181 | (plist-put tool-call :result 182 | (string-trim (buffer-substring-no-properties 183 | (point) prev-pt))) 184 | (push (car (gptel--parse-tool-results backend (list tool-call))) 185 | prompts) 186 | (push (list :role "assistant" 187 | :content "" 188 | :tool_calls `[(:function (:name ,name :arguments ,arguments))]) 189 | prompts)) 190 | ((end-of-file invalid-read-syntax) 191 | (message (format "Could not parse tool-call on line %s" 192 | (line-number-at-pos (point)))))))) 193 | ('ignore) 194 | ('nil 195 | (if gptel-track-media 196 | (when-let* ((content (gptel--ollama-parse-multipart 197 | (gptel--parse-media-links major-mode (point) prev-pt)))) 198 | (when (> (length content) 0) 199 | (push (append '(:role "user") content) prompts))) 200 | (when-let* ((content (gptel--trim-prefixes (buffer-substring-no-properties 201 | (point) prev-pt)))) 202 | (push (list :role "user" :content content) prompts))))) 203 | (setq prev-pt (point)) 204 | (and max-entries (cl-decf max-entries))) 205 | (let ((content (string-trim (buffer-substring-no-properties 206 | (point-min) (point-max))))) 207 | (push (list :role "user" :content content) prompts))) 208 | prompts)) 209 | 210 | (defun gptel--ollama-parse-multipart (parts) 211 | "Convert a multipart prompt PARTS to the Ollama API format. 212 | 213 | The input is an alist of the form 214 | ((:text \"some text\") 215 | (:media \"/path/to/media.png\" :mime \"image/png\") 216 | (:text \"More text\")). 217 | 218 | The output is a vector of entries in a backend-appropriate 219 | format." 220 | (cl-loop 221 | for part in parts 222 | for n upfrom 1 223 | with last = (length parts) 224 | for text = (plist-get part :text) 225 | for media = (plist-get part :media) 226 | if text do 227 | (and (or (= n 1) (= n last)) (setq text (gptel--trim-prefixes text))) and 228 | if text 229 | collect text into text-array end 230 | else if media 231 | collect (gptel--base64-encode media) into media-array 232 | else if (plist-get part :textfile) 233 | collect 234 | (with-temp-buffer 235 | (gptel--insert-file-string (plist-get part :textfile)) 236 | (buffer-string)) 237 | into text-array 238 | finally return 239 | `(,@(and text-array (list :content (mapconcat #'identity text-array " "))) 240 | ,@(and media-array (list :images (vconcat media-array)))))) 241 | 242 | (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-ollama) prompts 243 | &optional inject-media) 244 | "Wrap the last user prompt in PROMPTS with the context string. 245 | 246 | If INJECT-MEDIA is non-nil wrap it with base64-encoded media 247 | files in the context." 248 | (if inject-media 249 | ;; Wrap the first user prompt with included media files/contexts 250 | (when-let* ((media-list (gptel-context--collect-media)) 251 | (media-processed (gptel--ollama-parse-multipart media-list))) 252 | (cl-callf (lambda (images) 253 | (vconcat (plist-get media-processed :images) 254 | images)) 255 | (plist-get (car prompts) :images))) 256 | ;; Wrap the last user prompt with included text contexts 257 | (cl-callf gptel-context--wrap (plist-get (car (last prompts)) :content)))) 258 | 259 | ;;;###autoload 260 | (cl-defun gptel-make-ollama 261 | (name &key curl-args header key models stream request-params 262 | (host "localhost:11434") 263 | (protocol "http") 264 | (endpoint "/api/chat")) 265 | "Register an Ollama backend for gptel with NAME. 266 | 267 | Keyword arguments: 268 | 269 | CURL-ARGS (optional) is a list of additional Curl arguments. 270 | 271 | HOST is where Ollama runs (with port), defaults to localhost:11434 272 | 273 | MODELS is a list of available model names, as symbols. 274 | Additionally, you can specify supported LLM capabilities like 275 | vision or tool-use by appending a plist to the model with more 276 | information, in the form 277 | 278 | (model-name . plist) 279 | 280 | Currently recognized plist keys are :description, :capabilities 281 | and :mime-types. An example of a model specification including 282 | both kinds of specs: 283 | 284 | :models 285 | \\='(mistral:latest ;Simple specs 286 | openhermes:latest 287 | (llava:13b ;Full spec 288 | :description 289 | \"Llava 1.6: Large Lanuage and Vision Assistant\" 290 | :capabilities (media) 291 | :mime-types (\"image/jpeg\" \"image/png\"))) 292 | 293 | 294 | STREAM is a boolean to toggle streaming responses, defaults to 295 | false. 296 | 297 | PROTOCOL (optional) specifies the protocol, http by default. 298 | 299 | ENDPOINT (optional) is the API endpoint for completions, defaults to 300 | \"/api/generate\". 301 | 302 | HEADER (optional) is for additional headers to send with each 303 | request. It should be an alist or a function that retuns an 304 | alist, like: 305 | ((\"Content-Type\" . \"application/json\")) 306 | 307 | KEY (optional) is a variable whose value is the API key, or 308 | function that returns the key. This is typically not required 309 | for local models like Ollama. 310 | 311 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 312 | parameters (as plist keys) and values supported by the API. Use 313 | these to set parameters that gptel does not provide user options 314 | for. 315 | 316 | Example: 317 | ------- 318 | 319 | (gptel-make-ollama 320 | \"Ollama\" 321 | :host \"localhost:11434\" 322 | :models \\='(mistral:latest) 323 | :stream t)" 324 | (declare (indent 1)) 325 | (let ((backend (gptel--make-ollama 326 | :curl-args curl-args 327 | :name name 328 | :host host 329 | :header header 330 | :key key 331 | :models (gptel--process-models models) 332 | :protocol protocol 333 | :endpoint endpoint 334 | :stream stream 335 | :request-params request-params 336 | :url (if protocol 337 | (concat protocol "://" host endpoint) 338 | (concat host endpoint))))) 339 | (prog1 backend 340 | (setf (alist-get name gptel--known-backends 341 | nil nil #'equal) 342 | backend)))) 343 | 344 | (provide 'gptel-ollama) 345 | ;;; gptel-ollama.el ends here 346 | 347 | 348 | -------------------------------------------------------------------------------- /gptel-openai-extras.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-openai-extras.el --- Extensions to the OpenAI API -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Authors: Karthik Chikmagalur and pirminj 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; This file adds support for Privategpt's Messages API and 23 | ;; Perplexity's Citations feature to gptel 24 | 25 | ;;; Code: 26 | (require 'cl-generic) 27 | (eval-when-compile 28 | (require 'cl-lib)) 29 | (require 'map) 30 | (require 'gptel) 31 | 32 | (defvar json-object-type) 33 | 34 | (declare-function prop-match-value "text-property-search") 35 | (declare-function text-property-search-backward "text-property-search") 36 | (declare-function json-read "json" ()) 37 | 38 | 39 | 40 | ;;; Privategpt (Messages API) 41 | (cl-defstruct (gptel-privategpt (:constructor gptel--make-privategpt) 42 | (:copier nil) 43 | (:include gptel-openai)) 44 | context sources) 45 | 46 | (defun gptel--privategpt-parse-sources (response) 47 | (cl-loop with source-alist 48 | for source across (map-nested-elt response '(:choices 0 :sources)) 49 | for name = (map-nested-elt source '(:document :doc_metadata :file_name)) 50 | for page = (map-nested-elt source '(:document :doc_metadata :page_label)) 51 | do (push page (alist-get name source-alist nil nil #'equal)) 52 | finally return 53 | (cl-loop for (file-name . file-pages) in source-alist 54 | for pages = (delete-dups (delq nil file-pages)) 55 | if pages 56 | collect (format "- %s (page %s)" file-name (mapconcat #'identity pages ", ")) 57 | into source-items 58 | else collect (format "- %s" file-name) into source-items 59 | finally return (mapconcat #'identity (cons "\n\nSources:" source-items) "\n")))) 60 | 61 | ;; FIXME(tool) add tool use 62 | (cl-defmethod gptel-curl--parse-stream ((_backend gptel-privategpt) info) 63 | (let* ((content-strs)) 64 | (condition-case nil 65 | (while (re-search-forward "^data:" nil t) 66 | (save-match-data 67 | (if (looking-at " *\\[DONE\\]") 68 | (when-let* ((sources-string (plist-get info :sources))) 69 | (push sources-string content-strs)) 70 | (let ((response (gptel--json-read))) 71 | (unless (or (plist-get info :sources) 72 | (not (gptel-privategpt-sources (plist-get info :backend)))) 73 | (plist-put info :sources (gptel--privategpt-parse-sources response))) 74 | (let* ((delta (map-nested-elt response '(:choices 0 :delta))) 75 | (content (plist-get delta :content))) 76 | (push content content-strs)))))) 77 | (error 78 | (goto-char (match-beginning 0)))) 79 | (apply #'concat (nreverse content-strs)))) 80 | 81 | ;; FIXME(tool) add tool use 82 | (cl-defmethod gptel--parse-response ((_backend gptel-privategpt) response info) 83 | (let ((response-string (map-nested-elt response '(:choices 0 :message :content))) 84 | (sources-string (and (gptel-privategpt-sources (plist-get info :backend)) 85 | (gptel--privategpt-parse-sources response)))) 86 | (concat response-string sources-string))) 87 | 88 | (cl-defmethod gptel--request-data ((_backend gptel-privategpt) prompts) 89 | "JSON encode PROMPTS for sending to ChatGPT." 90 | (let ((prompts-plist 91 | `(:model ,(gptel--model-name gptel-model) 92 | :messages [,@prompts] 93 | :use_context ,(or (gptel-privategpt-context gptel-backend) :json-false) 94 | :include_sources ,(or (gptel-privategpt-sources gptel-backend) :json-false) 95 | :stream ,(or gptel-stream :json-false)))) 96 | (when (and gptel--system-message 97 | (not (gptel--model-capable-p 'nosystem))) 98 | (plist-put prompts-plist :system gptel--system-message)) 99 | (when gptel-temperature 100 | (plist-put prompts-plist :temperature gptel-temperature)) 101 | (when gptel-max-tokens 102 | (plist-put prompts-plist :max_tokens gptel-max-tokens)) 103 | ;; Merge request params with model and backend params. 104 | (gptel--merge-plists 105 | prompts-plist 106 | (gptel-backend-request-params gptel-backend) 107 | (gptel--model-request-params gptel-model)))) 108 | 109 | 110 | ;;;###autoload 111 | (cl-defun gptel-make-privategpt 112 | (name &key curl-args stream key request-params 113 | (header 114 | (lambda () (when-let* ((key (gptel--get-api-key))) 115 | `(("Authorization" . ,(concat "Bearer " key)))))) 116 | (host "localhost:8001") 117 | (protocol "http") 118 | (models '(private-gpt)) 119 | (endpoint "/v1/chat/completions") 120 | (context t) (sources t)) 121 | "Register an Privategpt API-compatible backend for gptel with NAME. 122 | 123 | Keyword arguments: 124 | 125 | CURL-ARGS (optional) is a list of additional Curl arguments. 126 | 127 | HOST (optional) is the API host, \"api.privategpt.com\" by default. 128 | 129 | MODELS is a list of available model names. 130 | 131 | STREAM is a boolean to toggle streaming responses, defaults to 132 | false. 133 | 134 | PROTOCOL (optional) specifies the protocol, https by default. 135 | 136 | ENDPOINT (optional) is the API endpoint for completions, defaults to 137 | \"/v1/messages\". 138 | 139 | HEADER (optional) is for additional headers to send with each 140 | request. It should be an alist or a function that retuns an 141 | alist, like: 142 | ((\"Content-Type\" . \"application/json\")) 143 | 144 | KEY is a variable whose value is the API key, or function that 145 | returns the key. 146 | 147 | CONTEXT and SOURCES: if true (the default), use available context 148 | and provide sources used by the model to generate the response. 149 | 150 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 151 | parameters (as plist keys) and values supported by the API. Use 152 | these to set parameters that gptel does not provide user options 153 | for." 154 | (declare (indent 1)) 155 | (let ((backend (gptel--make-privategpt 156 | :curl-args curl-args 157 | :name name 158 | :host host 159 | :header header 160 | :key key 161 | :models models 162 | :protocol protocol 163 | :endpoint endpoint 164 | :stream stream 165 | :request-params request-params 166 | :url (if protocol 167 | (concat protocol "://" host endpoint) 168 | (concat host endpoint)) 169 | :context context 170 | :sources sources))) 171 | (prog1 backend 172 | (setf (alist-get name gptel--known-backends 173 | nil nil #'equal) 174 | backend)))) 175 | 176 | 177 | ;;; Perplexity 178 | (cl-defstruct (gptel-perplexity (:constructor gptel--make-perplexity) 179 | (:copier nil) 180 | (:include gptel-openai))) 181 | 182 | (defsubst gptel--perplexity-parse-citations (citations) 183 | (let ((counter 0)) 184 | (concat "\n\nCitations:\n" 185 | (mapconcat (lambda (url) 186 | (setq counter (1+ counter)) 187 | (format "[%d] %s" counter url)) 188 | citations "\n")))) 189 | 190 | (cl-defmethod gptel--parse-response ((_backend gptel-perplexity) response _info) 191 | "Parse Perplexity response RESPONSE." 192 | (let ((response-string (map-nested-elt response '(:choices 0 :message :content))) 193 | (citations-string (when-let* ((citations (map-elt response :citations))) 194 | (gptel--perplexity-parse-citations citations)))) 195 | (concat response-string citations-string))) 196 | 197 | (cl-defmethod gptel-curl--parse-stream ((_backend gptel-perplexity) info) 198 | "Parse a Perplexity API data stream with INFO. 199 | 200 | If available, collect citations at the end and include them with 201 | the response." 202 | (let ((resp (cl-call-next-method))) 203 | (unless (plist-get info :citations) 204 | (save-excursion 205 | (goto-char (point-max)) 206 | (when (search-backward (plist-get info :token) 207 | (line-beginning-position) t) 208 | (forward-line 0) 209 | (when (re-search-backward "^data: " nil t) 210 | (goto-char (match-end 0)) 211 | (ignore-errors 212 | (when-let* ((chunk (gptel--json-read)) 213 | (citations (map-elt chunk :citations))) 214 | (plist-put info :citations t) 215 | (setq resp (concat resp (gptel--perplexity-parse-citations 216 | citations))))))))) 217 | resp)) 218 | 219 | ;;;###autoload 220 | (cl-defun gptel-make-perplexity 221 | (name &key curl-args stream key 222 | (header 223 | (lambda () (when-let* ((key (gptel--get-api-key))) 224 | `(("Authorization" . ,(concat "Bearer " key)))))) 225 | (host "api.perplexity.ai") 226 | (protocol "https") 227 | ;; https://docs.perplexity.ai/guides/model-cards 228 | (models '(sonar sonar-pro sonar-reasoning sonar-reasoning-pro sonar-deep-research)) 229 | (endpoint "/chat/completions") 230 | request-params) 231 | "Register a Perplexity backend for gptel with NAME. 232 | 233 | Keyword arguments: 234 | 235 | CURL-ARGS (optional) is a list of additional Curl arguments. 236 | 237 | HOST (optional) is the API host, \"api.perplexity.ai\" by default. 238 | 239 | MODELS is a list of available model names. 240 | 241 | STREAM is a boolean to toggle streaming responses. 242 | 243 | PROTOCOL (optional) specifies the protocol, https by default. 244 | 245 | ENDPOINT (optional) is the API endpoint for completions. 246 | 247 | HEADER (optional) is for additional headers to send with each 248 | request. It should be an alist or a function that returns an 249 | alist. 250 | 251 | KEY is a variable whose value is the API key, or function that 252 | returns the key. 253 | 254 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 255 | parameters." 256 | (declare (indent 1)) 257 | (let ((backend (gptel--make-perplexity 258 | :curl-args curl-args 259 | :name name 260 | :host host 261 | :header header 262 | :key key 263 | :models models 264 | :protocol protocol 265 | :endpoint endpoint 266 | :stream stream 267 | :request-params request-params 268 | :url (if protocol 269 | (concat protocol "://" host endpoint) 270 | (concat host endpoint))))) 271 | (prog1 backend 272 | (setf (alist-get name gptel--known-backends 273 | nil nil #'equal) 274 | backend)))) 275 | 276 | ;;; Deepseek 277 | (cl-defstruct (gptel-deepseek (:include gptel-openai) 278 | (:copier nil) 279 | (:constructor gptel--make-deepseek))) 280 | 281 | (cl-defmethod gptel-curl--parse-stream :before ((_backend gptel-deepseek) info) 282 | "Capture reasoning block stream into INFO." 283 | (unless (eq (plist-get info :reasoning-block) 'done) 284 | (save-excursion 285 | (ignore-errors 286 | (catch 'done 287 | (while (re-search-forward "^data:" nil t) 288 | (unless (looking-at-p " *\\[DONE\\]") 289 | (when-let* ((response (gptel--json-read)) 290 | (delta (map-nested-elt response '(:choices 0 :delta)))) 291 | (if-let* ((reasoning (plist-get delta :reasoning_content)) 292 | ((not (eq reasoning :null)))) 293 | ;; :reasoning will be consumed by the gptel-request callback 294 | ;; and reset by the stream filter. 295 | (plist-put info :reasoning 296 | (concat (plist-get info :reasoning) reasoning)) 297 | (when-let* ((content (plist-get delta :content)) 298 | ((not (eq content :null)))) 299 | (if (eq (plist-get info :reasoning-block) 'in) ;Check if in reasoning block 300 | (plist-put info :reasoning-block t) ;End of streaming reasoning block 301 | (plist-put info :reasoning-block 'done)) ;Not using a reasoning model 302 | (throw 'done t))))))))))) 303 | 304 | (cl-defmethod gptel--parse-response :before ((_backend gptel-deepseek) response info) 305 | "Capture reasoning block in RESPONSE into INFO." 306 | (let* ((choice0 (map-nested-elt response '(:choices 0))) 307 | (message (plist-get choice0 :message)) 308 | (reasoning (plist-get message :reasoning_content))) 309 | (when (and (stringp reasoning) (length> reasoning 0)) 310 | (plist-put info :reasoning reasoning)))) 311 | 312 | (cl-defmethod gptel--parse-buffer :around ((_backend gptel-deepseek) _max-entries) 313 | "Merge successive prompts in the prompts list that have the same role. 314 | 315 | The Deepseek API requires strictly alternating roles (user/assistant) in messages." 316 | (let* ((prompts (cl-call-next-method)) 317 | (index prompts)) 318 | (prog1 prompts 319 | (while index 320 | (let ((p1 (car index)) 321 | (p2 (cadr index)) 322 | (rest (cdr index))) 323 | (when (and p2 (equal (plist-get p1 :role) 324 | (plist-get p2 :role))) 325 | (setf (plist-get p1 :content) 326 | (concat (plist-get p1 :content) "\n" 327 | (plist-get p2 :content))) 328 | (setcdr index (cdr rest))) 329 | (setq index (cdr index))))))) 330 | 331 | ;;;###autoload 332 | (cl-defun gptel-make-deepseek 333 | (name &key curl-args stream key request-params 334 | (header (lambda () (when-let* ((key (gptel--get-api-key))) 335 | `(("Authorization" . ,(concat "Bearer " key)))))) 336 | (host "api.deepseek.com") 337 | (protocol "https") 338 | (endpoint "/v1/chat/completions") 339 | (models '((deepseek-reasoner 340 | :capabilities (tool reasoning) 341 | :context-window 64 342 | :input-cost 0.55 343 | :output-cost 2.19) 344 | (deepseek-chat 345 | :capabilities (tool) 346 | :context-window 64 347 | :input-cost 0.27 348 | :output-cost 1.10)))) 349 | "Register a DeepSeek backend for gptel with NAME. 350 | 351 | For the meanings of the keyword arguments, see `gptel-make-openai'." 352 | (declare (indent 1)) 353 | (let ((backend (gptel--make-deepseek 354 | :name name 355 | :host host 356 | :header header 357 | :key key 358 | :models (gptel--process-models models) 359 | :protocol protocol 360 | :endpoint endpoint 361 | :stream stream 362 | :request-params request-params 363 | :curl-args curl-args 364 | :url (concat protocol "://" host endpoint)))) 365 | (setf (alist-get name gptel--known-backends nil nil #'equal) backend) 366 | backend)) 367 | 368 | ;;; xAI 369 | ;;;###autoload 370 | (cl-defun gptel-make-xai 371 | (name &key curl-args stream key request-params 372 | (header (lambda () (when-let* ((key (gptel--get-api-key))) 373 | `(("Authorization" . ,(concat "Bearer " key)))))) 374 | (host "api.x.ai") 375 | (protocol "https") 376 | (endpoint "/v1/chat/completions") 377 | (models '((grok-3-latest 378 | :description "Grok 3" 379 | :capabilities '(tool-use json) 380 | :context-window 131072 381 | :input-cost 3 382 | :output-cost 15) 383 | 384 | (grok-3-fast-latest 385 | :description "Faster Grok 3" 386 | :capabilities '(tool-use json) 387 | :context-window 131072 388 | :input-cost 5 389 | :output-cost 25) 390 | 391 | (grok-3-mini-latest 392 | :description "Mini Grok 3" 393 | :capabilities '(tool-use json reasoning) 394 | :context-window 131072 395 | :input-cost 0.3 396 | :output-cost 0.5) 397 | 398 | (grok-3-mini-fast-latest 399 | :description "Faster mini Grok 3" 400 | :capabilities '(tool-use json reasoning) 401 | :context-window 131072 402 | :input-cost 0.6 403 | :output-cost 4) 404 | 405 | (grok-2-vision-1212 406 | :description "Grok 2 Vision" 407 | :capabilities '(tool-use json) 408 | :mime-types '("image/jpeg" "image/png" "image/gif" "image/webp") 409 | :context-window 32768 410 | :input-cost 2 411 | :output-cost 10)))) 412 | "Register an xAI backend for gptel with NAME. 413 | 414 | Keyword arguments: 415 | 416 | KEY is a variable whose value is the API key, or function that 417 | returns the key. 418 | 419 | STREAM is a boolean to toggle streaming responses, defaults to 420 | false. 421 | 422 | The other keyword arguments are all optional. For their meanings 423 | see `gptel-make-openai'." 424 | (declare (indent 1)) 425 | (let ((backend (gptel--make-openai 426 | :name name 427 | :host host 428 | :header header 429 | :key key 430 | :models (gptel--process-models models) 431 | :protocol protocol 432 | :endpoint endpoint 433 | :stream stream 434 | :request-params request-params 435 | :curl-args curl-args 436 | :url (concat protocol "://" host endpoint)))) 437 | (setf (alist-get name gptel--known-backends nil nil #'equal) backend) 438 | backend)) 439 | 440 | 441 | (provide 'gptel-openai-extras) 442 | ;;; gptel-openai-extras.el ends here 443 | 444 | ;; Local Variables: 445 | ;; byte-compile-warnings: (not docstrings) 446 | ;; End: 447 | -------------------------------------------------------------------------------- /gptel-openai.el: -------------------------------------------------------------------------------- 1 | ;;; gptel-openai.el --- ChatGPT suppport for gptel -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023-2025 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; This file adds support for the ChatGPT API to gptel 23 | 24 | ;;; Code: 25 | (require 'cl-generic) 26 | (eval-when-compile 27 | (require 'cl-lib)) 28 | (require 'map) 29 | 30 | (defvar gptel-model) 31 | (defvar gptel-stream) 32 | (defvar gptel-use-curl) 33 | (defvar gptel-backend) 34 | (defvar gptel-temperature) 35 | (defvar gptel-max-tokens) 36 | (defvar gptel--system-message) 37 | (defvar json-object-type) 38 | (defvar gptel-mode) 39 | (defvar gptel-track-response) 40 | (defvar gptel-track-media) 41 | (defvar gptel-use-tools) 42 | (defvar gptel-tools) 43 | (declare-function gptel-context--collect-media "gptel-context") 44 | (declare-function gptel--base64-encode "gptel") 45 | (declare-function gptel--trim-prefixes "gptel") 46 | (declare-function gptel--parse-media-links "gptel") 47 | (declare-function gptel--model-capable-p "gptel") 48 | (declare-function gptel--model-name "gptel") 49 | (declare-function gptel--get-api-key "gptel") 50 | (declare-function prop-match-value "text-property-search") 51 | (declare-function text-property-search-backward "text-property-search") 52 | (declare-function json-read "json") 53 | (declare-function gptel-prompt-prefix-string "gptel") 54 | (declare-function gptel-response-prefix-string "gptel") 55 | (declare-function gptel--merge-plists "gptel") 56 | (declare-function gptel--model-request-params "gptel") 57 | (declare-function gptel-context--wrap "gptel-context") 58 | (declare-function gptel--inject-prompt "gptel") 59 | (declare-function gptel--parse-tools "gptel") 60 | 61 | ;; JSON conversion semantics used by gptel 62 | ;; empty object "{}" => empty list '() == nil 63 | ;; null => :null 64 | ;; false => :json-false 65 | 66 | ;; TODO(tool) Except when reading JSON from a string, where null => nil 67 | 68 | (defmacro gptel--json-read () 69 | (if (fboundp 'json-parse-buffer) 70 | `(json-parse-buffer 71 | :object-type 'plist 72 | :null-object :null 73 | :false-object :json-false) 74 | (require 'json) 75 | (defvar json-object-type) 76 | (defvar json-null) 77 | (declare-function json-read "json" ()) 78 | `(let ((json-object-type 'plist) 79 | (json-null :null)) 80 | (json-read)))) 81 | 82 | (defmacro gptel--json-read-string (str) 83 | (if (fboundp 'json-parse-string) 84 | `(json-parse-string ,str 85 | :object-type 'plist 86 | :null-object nil 87 | :false-object :json-false) 88 | (require 'json) 89 | (defvar json-object-type) 90 | (declare-function json-read-from-string "json" ()) 91 | `(let ((json-object-type 'plist)) 92 | (json-read-from-string ,str)))) 93 | 94 | (defmacro gptel--json-encode (object) 95 | (if (fboundp 'json-serialize) 96 | `(json-serialize ,object 97 | :null-object :null 98 | :false-object :json-false) 99 | (require 'json) 100 | (defvar json-false) 101 | (defvar json-null) 102 | (declare-function json-encode "json" (object)) 103 | `(let ((json-false :json-false) 104 | (json-null :null)) 105 | (json-encode ,object)))) 106 | 107 | (defun gptel--process-models (models) 108 | "Convert items in MODELS to symbols with appropriate properties." 109 | (let ((models-processed)) 110 | (dolist (model models) 111 | (cl-etypecase model 112 | (string (push (intern model) models-processed)) 113 | (symbol (push model models-processed)) 114 | (cons 115 | (cl-destructuring-bind (name . props) model 116 | (setf (symbol-plist name) 117 | ;; MAYBE: Merging existing symbol plists is safer, but makes it 118 | ;; difficult to reset a symbol plist, since removing keys from 119 | ;; it (as opposed to setting them to nil) is more work. 120 | ;; 121 | ;; (map-merge 'plist (symbol-plist name) props) 122 | props) 123 | (push name models-processed))))) 124 | (nreverse models-processed))) 125 | 126 | ;;; Common backend struct for LLM support 127 | (defvar gptel--known-backends nil 128 | "Alist of LLM backends known to gptel. 129 | 130 | This is an alist mapping user-provided names to backend structs, 131 | see `gptel-backend'. 132 | 133 | You can have more than one backend pointing to the same resource 134 | with differing settings.") 135 | 136 | (defun gptel-get-backend (name) 137 | "Return gptel backend with NAME. 138 | 139 | Throw an error if there is no match." 140 | (or (alist-get name gptel--known-backends nil nil #'equal) 141 | (user-error "Backend %s is not known to be defined" 142 | name))) 143 | 144 | (gv-define-setter gptel-get-backend (val name) 145 | `(setf (alist-get ,name gptel--known-backends 146 | nil t #'equal) 147 | ,val)) 148 | 149 | (cl-defstruct 150 | (gptel-backend (:constructor gptel--make-backend) 151 | (:copier gptel--copy-backend)) 152 | name host header protocol stream 153 | endpoint key models url request-params 154 | curl-args 155 | (coding-system nil :documentation "Can be set to `binary' if the backend expects non UTF-8 output.")) 156 | 157 | ;;; OpenAI (ChatGPT) 158 | (cl-defstruct (gptel-openai (:constructor gptel--make-openai) 159 | (:copier nil) 160 | (:include gptel-backend))) 161 | 162 | ;; How the following function works: 163 | ;; 164 | ;; The OpenAI API returns a stream of data chunks. Each data chunk has a 165 | ;; component that can be parsed as JSON. Besides metadata, each chunk has 166 | ;; either some text or part of a tool call. 167 | ;; 168 | ;; If we find text, we collect it in a list, concat them at the end and return 169 | ;; it. 170 | ;; 171 | ;; If we find part of a tool call, we begin collecting the pieces in 172 | ;; INFO -> :tool-use. 173 | ;; 174 | ;; Tool call arguments are themselves JSON encoded strings can be spread across 175 | ;; chunks. We collect them in INFO -> :partial_json. The end of a tool call 176 | ;; chunk is marked by the beginning of another, or by the end of the stream. In 177 | ;; either case we flaten the :partial_json we have thus far, add it to the tool 178 | ;; call spec in :tool-use and reset it. Finally we append the tool calls to the 179 | ;; (INFO -> :data -> :messages) list of prompts. 180 | 181 | (cl-defmethod gptel-curl--parse-stream ((_backend gptel-openai) info) 182 | "Parse an OpenAI API data stream. 183 | 184 | Return the text response accumulated since the last call to this 185 | function. Additionally, mutate state INFO to add tool-use 186 | information if the stream contains it." 187 | (let* ((content-strs)) 188 | (condition-case nil 189 | (while (re-search-forward "^data:" nil t) 190 | (save-match-data 191 | (if (looking-at " *\\[DONE\\]") 192 | ;; The stream has ended, so we do the following thing (if we found tool calls) 193 | ;; - pack tool calls into the messages prompts list to send (INFO -> :data -> :messages) 194 | ;; - collect tool calls (formatted differently) into (INFO -> :tool-use) 195 | (when-let* ((tool-use (plist-get info :tool-use)) 196 | (args (apply #'concat (nreverse (plist-get info :partial_json)))) 197 | (func (plist-get (car tool-use) :function))) 198 | (plist-put func :arguments args) ;Update arguments for last recorded tool 199 | (gptel--inject-prompt 200 | (plist-get info :backend) (plist-get info :data) 201 | `(:role "assistant" :content :null :tool_calls ,(vconcat tool-use))) ; :refusal :null 202 | (cl-loop 203 | for tool-call in tool-use ; Construct the call specs for running the function calls 204 | for spec = (plist-get tool-call :function) 205 | collect (list :id (plist-get tool-call :id) 206 | :name (plist-get spec :name) 207 | :args (ignore-errors (gptel--json-read-string 208 | (plist-get spec :arguments)))) 209 | into call-specs 210 | finally (plist-put info :tool-use call-specs))) 211 | (when-let* ((response (gptel--json-read)) 212 | (delta (map-nested-elt response '(:choices 0 :delta)))) 213 | (if-let* ((content (plist-get delta :content)) 214 | ((not (or (eq content :null) (string-empty-p content))))) 215 | (push content content-strs) 216 | ;; No text content, so look for tool calls 217 | (when-let* ((tool-call (map-nested-elt delta '(:tool_calls 0))) 218 | (func (plist-get tool-call :function))) 219 | (if (plist-get func :name) ;new tool block begins 220 | (progn 221 | (when-let* ((partial (plist-get info :partial_json))) 222 | (let* ((prev-tool-call (car (plist-get info :tool-use))) 223 | (prev-func (plist-get prev-tool-call :function))) 224 | (plist-put prev-func :arguments ;update args for old tool block 225 | (apply #'concat (nreverse (plist-get info :partial_json))))) 226 | (plist-put info :partial_json nil)) ;clear out finished chain of partial args 227 | ;; Start new chain of partial argument strings 228 | (plist-put info :partial_json (list (plist-get func :arguments))) 229 | ;; NOTE: Do NOT use `push' for this, it prepends and we lose the reference 230 | (plist-put info :tool-use (cons tool-call (plist-get info :tool-use)))) 231 | ;; old tool block continues, so continue collecting arguments in :partial_json 232 | (push (plist-get func :arguments) (plist-get info :partial_json))))) 233 | ;; Check for reasoning blocks, currently only used by Openrouter 234 | ;; MAYBE: Should this be moved to a dedicated Openrouter backend? 235 | (unless (or (eq (plist-get info :reasoning-block) 'done) 236 | (not (plist-member delta :reasoning))) 237 | (if-let* ((reasoning-chunk (plist-get delta :reasoning)) ;for openrouter 238 | ((not (eq reasoning-chunk :null)))) 239 | (plist-put info :reasoning 240 | (concat (plist-get info :reasoning) reasoning-chunk)) 241 | ;; Done with reasoning if we get non-empty content 242 | (if-let* ((c (plist-get delta :content)) 243 | ((not (or (eq c :null) (string-empty-p c))))) 244 | (if (plist-member info :reasoning) ;Is this a reasoning model? 245 | (plist-put info :reasoning-block t) ;End of streaming reasoning block 246 | (plist-put info :reasoning-block 'done))))))))) ;Not using a reasoning model 247 | (error (goto-char (match-beginning 0)))) 248 | (apply #'concat (nreverse content-strs)))) 249 | 250 | (cl-defmethod gptel--parse-response ((_backend gptel-openai) response info) 251 | "Parse an OpenAI (non-streaming) RESPONSE and return response text. 252 | 253 | Mutate state INFO with response metadata." 254 | (let* ((choice0 (map-nested-elt response '(:choices 0))) 255 | (message (plist-get choice0 :message)) 256 | (content (plist-get message :content))) 257 | (plist-put info :stop-reason 258 | (plist-get choice0 :finish_reason)) 259 | (plist-put info :output-tokens 260 | (map-nested-elt response '(:usage :completion_tokens))) 261 | ;; OpenAI returns either non-blank text content or a tool call, not both. 262 | ;; However OpenAI-compatible APIs like llama.cpp can include both (#819), so 263 | ;; we check for both tool calls and responses independently. 264 | (when-let* ((tool-calls (plist-get message :tool_calls)) 265 | ((not (eq tool-calls :null)))) 266 | (gptel--inject-prompt ; First add the tool call to the prompts list 267 | (plist-get info :backend) (plist-get info :data) message) 268 | (cl-loop ;Then capture the tool call data for running the tool 269 | for tool-call across tool-calls ;replace ":arguments" with ":args" 270 | for call-spec = (copy-sequence (plist-get tool-call :function)) 271 | do (ignore-errors (plist-put call-spec :args 272 | (gptel--json-read-string 273 | (plist-get call-spec :arguments)))) 274 | (plist-put call-spec :arguments nil) 275 | (plist-put call-spec :id (plist-get tool-call :id)) 276 | collect call-spec into tool-use 277 | finally (plist-put info :tool-use tool-use))) 278 | (when (and content (not (or (eq content :null) (string-empty-p content)))) 279 | (when-let* ((reasoning (plist-get message :reasoning)) ;look for reasoning blocks 280 | ((and (stringp reasoning) (not (string-empty-p reasoning))))) 281 | (plist-put info :reasoning reasoning)) 282 | content))) 283 | 284 | (cl-defmethod gptel--request-data ((backend gptel-openai) prompts) 285 | "JSON encode PROMPTS for sending to ChatGPT." 286 | (when gptel--system-message 287 | (push (list :role "system" 288 | :content gptel--system-message) 289 | prompts)) 290 | (let ((prompts-plist 291 | `(:model ,(gptel--model-name gptel-model) 292 | :messages [,@prompts] 293 | :stream ,(or gptel-stream :json-false))) 294 | (reasoning-model-p ; TODO: Embed this capability in the model's properties 295 | (memq gptel-model '(o1 o1-preview o1-mini o3-mini o3 o4-mini)))) 296 | (when (and gptel-temperature (not reasoning-model-p)) 297 | (plist-put prompts-plist :temperature gptel-temperature)) 298 | (when gptel-use-tools 299 | (when (eq gptel-use-tools 'force) 300 | (plist-put prompts-plist :tool_choice "required")) 301 | (when gptel-tools 302 | (plist-put prompts-plist :tools 303 | (gptel--parse-tools backend gptel-tools)) 304 | (unless reasoning-model-p 305 | (plist-put prompts-plist :parallel_tool_calls t)))) 306 | (when gptel-max-tokens 307 | ;; HACK: The OpenAI API has deprecated max_tokens, but we still need it 308 | ;; for OpenAI-compatible APIs like GPT4All (#485) 309 | (plist-put prompts-plist 310 | (if reasoning-model-p :max_completion_tokens :max_tokens) 311 | gptel-max-tokens)) 312 | ;; Merge request params with model and backend params. 313 | (gptel--merge-plists 314 | prompts-plist 315 | (gptel-backend-request-params gptel-backend) 316 | (gptel--model-request-params gptel-model)))) 317 | 318 | ;; NOTE: No `gptel--parse-tools' method required for gptel-openai, since this is 319 | ;; handled by its defgeneric implementation 320 | 321 | (cl-defmethod gptel--parse-tool-results ((_backend gptel-openai) tool-use) 322 | "Return a prompt containing tool call results in TOOL-USE." 323 | ;; (declare (side-effect-free t)) 324 | (mapcar 325 | (lambda (tool-call) 326 | (list 327 | :role "tool" 328 | :tool_call_id (plist-get tool-call :id) 329 | :content (plist-get tool-call :result))) 330 | tool-use)) 331 | 332 | ;; TODO: Remove these functions (#792) 333 | (defun gptel--openai-format-tool-id (tool-id) 334 | "Format TOOL-ID for OpenAI. 335 | 336 | If the ID has the format used by a different backend, use as-is." 337 | (unless tool-id 338 | (setq tool-id (substring 339 | (md5 (format "%s%s" (random) (float-time))) 340 | nil 24))) 341 | (if (or (string-prefix-p "toolu_" tool-id) ;#747 342 | (string-prefix-p "call_" tool-id)) 343 | tool-id 344 | (format "call_%s" tool-id))) 345 | 346 | (defun gptel--openai-unformat-tool-id (tool-id) 347 | (or (and (string-match "call_\\(.+\\)" tool-id) 348 | (match-string 1 tool-id)) 349 | tool-id)) 350 | 351 | ;; NOTE: No `gptel--inject-prompt' method required for gptel-openai, since this 352 | ;; is handled by its defgeneric implementation 353 | 354 | (cl-defmethod gptel--parse-list ((backend gptel-openai) prompt-list) 355 | (if (consp (car prompt-list)) 356 | (let ((full-prompt)) ; Advanced format, list of lists 357 | (dolist (entry prompt-list) 358 | (pcase entry 359 | (`(prompt . ,msg) 360 | (push (list :role "user" :content (or (car-safe msg) msg)) full-prompt)) 361 | (`(response . ,msg) 362 | (push (list :role "assistant" :content (or (car-safe msg) msg)) full-prompt)) 363 | (`(tool . ,call) 364 | (unless (plist-get call :id) 365 | (plist-put call :id (gptel--openai-format-tool-id nil))) 366 | (push 367 | (list 368 | :role "assistant" 369 | :tool_calls 370 | (vector 371 | (list :type "function" 372 | :id (plist-get call :id) 373 | :function `( :name ,(plist-get call :name) 374 | :arguments ,(gptel--json-encode (plist-get call :args)))))) 375 | full-prompt) 376 | (push (car (gptel--parse-tool-results backend (list (cdr entry)))) full-prompt)))) 377 | (nreverse full-prompt)) 378 | (cl-loop for text in prompt-list ; Simple format, list of strings 379 | for role = t then (not role) 380 | if text collect 381 | (list :role (if role "user" "assistant") :content text)))) 382 | 383 | (cl-defmethod gptel--parse-buffer ((backend gptel-openai) &optional max-entries) 384 | (let ((prompts) (prev-pt (point))) 385 | (if (or gptel-mode gptel-track-response) 386 | (while (and (or (not max-entries) (>= max-entries 0)) 387 | (/= prev-pt (point-min)) 388 | (goto-char (previous-single-property-change 389 | (point) 'gptel nil (point-min)))) 390 | (pcase (get-char-property (point) 'gptel) 391 | ('response 392 | (when-let* ((content (gptel--trim-prefixes 393 | (buffer-substring-no-properties (point) prev-pt)))) 394 | (push (list :role "assistant" :content content) prompts))) 395 | (`(tool . ,id) 396 | (save-excursion 397 | (condition-case nil 398 | (let* ((tool-call (read (current-buffer))) 399 | (name (plist-get tool-call :name)) 400 | (arguments (gptel--json-encode (plist-get tool-call :args)))) 401 | (plist-put tool-call :id id) 402 | (plist-put tool-call :result 403 | (string-trim (buffer-substring-no-properties 404 | (point) prev-pt))) 405 | (push (car (gptel--parse-tool-results backend (list tool-call))) 406 | prompts) 407 | (push (list :role "assistant" 408 | :tool_calls 409 | (vector (list :type "function" 410 | :id id 411 | :function `( :name ,name 412 | :arguments ,arguments)))) 413 | prompts)) 414 | ((end-of-file invalid-read-syntax) 415 | (message (format "Could not parse tool-call %s on line %s" 416 | id (line-number-at-pos (point)))))))) 417 | ('ignore) 418 | ('nil 419 | (and max-entries (cl-decf max-entries)) 420 | (if gptel-track-media 421 | (when-let* ((content (gptel--openai-parse-multipart 422 | (gptel--parse-media-links major-mode 423 | (point) prev-pt)))) 424 | (when (> (length content) 0) 425 | (push (list :role "user" :content content) prompts))) 426 | (when-let* ((content (gptel--trim-prefixes (buffer-substring-no-properties 427 | (point) prev-pt)))) 428 | (push (list :role "user" :content content) prompts))))) 429 | (setq prev-pt (point))) 430 | (let ((content (string-trim (buffer-substring-no-properties 431 | (point-min) (point-max))))) 432 | (push (list :role "user" :content content) prompts))) 433 | prompts)) 434 | 435 | ;; TODO This could be a generic function 436 | (defun gptel--openai-parse-multipart (parts) 437 | "Convert a multipart prompt PARTS to the OpenAI API format. 438 | 439 | The input is an alist of the form 440 | ((:text \"some text\") 441 | (:media \"/path/to/media.png\" :mime \"image/png\") 442 | (:text \"More text\")). 443 | 444 | The output is a vector of entries in a backend-appropriate 445 | format." 446 | (cl-loop 447 | for part in parts 448 | for n upfrom 1 449 | with last = (length parts) 450 | for text = (plist-get part :text) 451 | for media = (plist-get part :media) 452 | if text do 453 | (and (or (= n 1) (= n last)) (setq text (gptel--trim-prefixes text))) 454 | and if text 455 | collect `(:type "text" :text ,text) into parts-array end 456 | else if media collect 457 | `(:type "image_url" 458 | :image_url (:url ,(concat "data:" (plist-get part :mime) 459 | ";base64," (gptel--base64-encode media)))) 460 | into parts-array 461 | else if (plist-get part :textfile) collect 462 | `(:type "text" 463 | :text ,(with-temp-buffer 464 | (gptel--insert-file-string (plist-get part :textfile)) 465 | (buffer-string))) 466 | into parts-array end and 467 | if (plist-get part :url) 468 | collect 469 | `(:type "image_url" 470 | :image_url (:url ,(plist-get part :url))) 471 | into parts-array 472 | finally return (vconcat parts-array))) 473 | 474 | ;; TODO: Does this need to be a generic function? 475 | (cl-defmethod gptel--wrap-user-prompt ((_backend gptel-openai) prompts 476 | &optional inject-media) 477 | "Wrap the last user prompt in PROMPTS with the context string. 478 | 479 | If INJECT-MEDIA is non-nil wrap it with base64-encoded media 480 | files in the context." 481 | (if inject-media 482 | ;; Wrap the first user prompt with included media files/contexts 483 | (when-let* ((media-list (gptel-context--collect-media))) 484 | (cl-callf (lambda (current) 485 | (vconcat 486 | (gptel--openai-parse-multipart media-list) 487 | (cl-typecase current 488 | (string `((:type "text" :text ,current))) 489 | (vector current) 490 | (t current)))) 491 | (plist-get (car prompts) :content))) 492 | ;; Wrap the last user prompt with included text contexts 493 | (cl-callf (lambda (current) 494 | (cl-etypecase current 495 | (string (gptel-context--wrap current)) 496 | (vector (if-let* ((wrapped (gptel-context--wrap nil))) 497 | (vconcat `((:type "text" :text ,wrapped)) 498 | current) 499 | current)))) 500 | (plist-get (car (last prompts)) :content)))) 501 | 502 | ;;;###autoload 503 | (cl-defun gptel-make-openai 504 | (name &key curl-args models stream key request-params 505 | (header 506 | (lambda () (when-let* ((key (gptel--get-api-key))) 507 | `(("Authorization" . ,(concat "Bearer " key)))))) 508 | (host "api.openai.com") 509 | (protocol "https") 510 | (endpoint "/v1/chat/completions")) 511 | "Register an OpenAI API-compatible backend for gptel with NAME. 512 | 513 | Keyword arguments: 514 | 515 | CURL-ARGS (optional) is a list of additional Curl arguments. 516 | 517 | HOST (optional) is the API host, typically \"api.openai.com\". 518 | 519 | MODELS is a list of available model names, as symbols. 520 | Additionally, you can specify supported LLM capabilities like 521 | vision or tool-use by appending a plist to the model with more 522 | information, in the form 523 | 524 | (model-name . plist) 525 | 526 | For a list of currently recognized plist keys, see 527 | `gptel--openai-models'. An example of a model specification 528 | including both kinds of specs: 529 | 530 | :models 531 | \\='(gpt-3.5-turbo ;Simple specs 532 | gpt-4-turbo 533 | (gpt-4o-mini ;Full spec 534 | :description 535 | \"Affordable and intelligent small model for lightweight tasks\" 536 | :capabilities (media tool json url) 537 | :mime-types 538 | (\"image/jpeg\" \"image/png\" \"image/gif\" \"image/webp\"))) 539 | 540 | STREAM is a boolean to toggle streaming responses, defaults to 541 | false. 542 | 543 | PROTOCOL (optional) specifies the protocol, https by default. 544 | 545 | ENDPOINT (optional) is the API endpoint for completions, defaults to 546 | \"/v1/chat/completions\". 547 | 548 | HEADER (optional) is for additional headers to send with each 549 | request. It should be an alist or a function that returns an 550 | alist, like: 551 | ((\"Content-Type\" . \"application/json\")) 552 | 553 | KEY (optional) is a variable whose value is the API key, or 554 | function that returns the key. 555 | 556 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 557 | parameters (as plist keys) and values supported by the API. Use 558 | these to set parameters that gptel does not provide user options 559 | for." 560 | (declare (indent 1)) 561 | (let ((backend (gptel--make-openai 562 | :curl-args curl-args 563 | :name name 564 | :host host 565 | :header header 566 | :key key 567 | :models (gptel--process-models models) 568 | :protocol protocol 569 | :endpoint endpoint 570 | :stream stream 571 | :request-params request-params 572 | :url (if protocol 573 | (concat protocol "://" host endpoint) 574 | (concat host endpoint))))) 575 | (prog1 backend 576 | (setf (alist-get name gptel--known-backends 577 | nil nil #'equal) 578 | backend)))) 579 | 580 | ;;; Azure 581 | ;;;###autoload 582 | (cl-defun gptel-make-azure 583 | (name &key curl-args host 584 | (protocol "https") 585 | (header (lambda () `(("api-key" . ,(gptel--get-api-key))))) 586 | (key 'gptel-api-key) 587 | models stream endpoint request-params) 588 | "Register an Azure backend for gptel with NAME. 589 | 590 | Keyword arguments: 591 | 592 | CURL-ARGS (optional) is a list of additional Curl arguments. 593 | 594 | HOST is the API host. 595 | 596 | MODELS is a list of available model names, as symbols. 597 | 598 | STREAM is a boolean to toggle streaming responses, defaults to 599 | false. 600 | 601 | PROTOCOL (optional) specifies the protocol, https by default. 602 | 603 | ENDPOINT is the API endpoint for completions. 604 | 605 | HEADER (optional) is for additional headers to send with each 606 | request. It should be an alist or a function that retuns an 607 | alist, like: 608 | ((\"Content-Type\" . \"application/json\")) 609 | 610 | KEY (optional) is a variable whose value is the API key, or 611 | function that returns the key. 612 | 613 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 614 | parameters (as plist keys) and values supported by the API. Use 615 | these to set parameters that gptel does not provide user options 616 | for. 617 | 618 | Example: 619 | ------- 620 | 621 | (gptel-make-azure 622 | \"Azure-1\" 623 | :protocol \"https\" 624 | :host \"RESOURCE_NAME.openai.azure.com\" 625 | :endpoint 626 | \"/openai/deployments/DEPLOYMENT_NAME/completions?api-version=2023-05-15\" 627 | :stream t 628 | :models \\='(gpt-3.5-turbo gpt-4))" 629 | (declare (indent 1)) 630 | (let ((backend (gptel--make-openai 631 | :curl-args curl-args 632 | :name name 633 | :host host 634 | :header header 635 | :key key 636 | :models (gptel--process-models models) 637 | :protocol protocol 638 | :endpoint endpoint 639 | :stream stream 640 | :request-params request-params 641 | :url (if protocol 642 | (concat protocol "://" host endpoint) 643 | (concat host endpoint))))) 644 | (prog1 backend 645 | (setf (alist-get name gptel--known-backends 646 | nil nil #'equal) 647 | backend)))) 648 | 649 | ;; GPT4All 650 | ;;;###autoload 651 | (defalias 'gptel-make-gpt4all 'gptel-make-openai 652 | "Register a GPT4All backend for gptel with NAME. 653 | 654 | Keyword arguments: 655 | 656 | CURL-ARGS (optional) is a list of additional Curl arguments. 657 | 658 | HOST is where GPT4All runs (with port), typically localhost:4891 659 | 660 | MODELS is a list of available model names, as symbols. 661 | 662 | STREAM is a boolean to toggle streaming responses, defaults to 663 | false. 664 | 665 | PROTOCOL specifies the protocol, https by default. 666 | 667 | ENDPOINT (optional) is the API endpoint for completions, defaults to 668 | \"/api/v1/completions\" 669 | 670 | HEADER (optional) is for additional headers to send with each 671 | request. It should be an alist or a function that retuns an 672 | alist, like: 673 | ((\"Content-Type\" . \"application/json\")) 674 | 675 | KEY (optional) is a variable whose value is the API key, or 676 | function that returns the key. This is typically not required for 677 | local models like GPT4All. 678 | 679 | REQUEST-PARAMS (optional) is a plist of additional HTTP request 680 | parameters (as plist keys) and values supported by the API. Use 681 | these to set parameters that gptel does not provide user options 682 | for. 683 | 684 | Example: 685 | ------- 686 | 687 | (gptel-make-gpt4all 688 | \"GPT4All\" 689 | :protocol \"http\" 690 | :host \"localhost:4891\" 691 | :models \\='(mistral-7b-openorca.Q4_0.gguf))") 692 | 693 | (provide 'gptel-openai) 694 | ;;; gptel-openai.el ends here 695 | 696 | ;; Local Variables: 697 | ;; byte-compile-warnings: (not docstrings) 698 | ;; End: 699 | --------------------------------------------------------------------------------