| git.druid.rocks | index | druid520 | radium | radium-gptel.el |
radium-gptel.el
;;; radium-gptel.el, any openai-compatible api in a buffer - tools, mcp -*- lexical-binding: t; -*-
;;
;; a *gptel* chat buffer talks to whatever openai-compatible endpoint
;; radium-gptel-provider names (gptel-make-openai rather than a
;; provider-specific backend, since that one plist shape - host,
;; endpoint, models, a key-file - already covers every real provider
;; ive actually used: deepseek, openrouter, a local llama.cpp server,
;; ... - and it means switching providers is a ~/.radium.el edit, not
;; a re-read of this file).
;;
;; radium-gptel-provider is nil here - this file makes no assumption
;; about which provider im on today, only how to wire *some* openai-
;; compatible one up once told. set it (and call
;; radium-gptel-apply-provider) from ~/.radium.el, loaded dead last
;; (see init.el's own comment on why) - a real per-machine setting,
;; same shape item 13's api-key-path precedent already is, not
;; something that belongs tracked in this repo. left unconfigured,
;; gptel-backend stays nil - checked directly against the installed
;; gptel's own source (gptel-request.el's defcustom): nil is gptel's
;; own real default too, not a gap this file leaves open, so C-c ? c
;; with no provider set fails the same way a stock, untouched gptel
;; install would.
;;
;; auth is always a plain api key, read fresh from the provider's own
;; :key-file on every request (that file holds nothing but the raw
;; key) - never in this repo, never in an env var. missing file ->
;; empty key -> a normal 401 from the api, not an elisp error
;; mid-request.
;;
;; tools are on (gptel-use-tools), so the model can actually read/edit/
;; write files and run commands mid-conversation, not just talk. the
;; read/edit/write tools open the real file buffer and (for anything
;; that changes it) pop it into a window, so an edit shows up live in
;; my editor instead of silently landing on disk - and gptel's own
;; :confirm gate means i see and approve the proposed change first.
;; mcp is wired too: set mcp-hub-servers (see mcp.el) to whatever mcp
;; servers i want and their tools merge into gptel-tools automatically
;; once they connect.
(defvar radium-gptel-provider nil
"plist describing which openai-compatible api gptel talks to - :name
:host :endpoint :key-file :models :model, and optionally :stream
\(defaults to t when omitted). nil means \"not configured\": gptel-
backend is left at gptel's own real default, nil, rather than this
file guessing which provider im on today.
set this (and call `radium-gptel-apply-provider' so the change
actually takes) from ~/.radium.el - switching providers should only
ever mean editing that file, never this one. e.g.:
(setq radium-gptel-provider
(list :name \"OpenRouter\" :host \"openrouter.ai\"
:endpoint \"/api/v1/chat/completions\"
:key-file \"~/.openrouter\"
:models \='(anthropic/claude-opus-5.5 openai/gpt-5)
:model \='anthropic/claude-opus-5.5))
(radium-gptel-apply-provider)")
(defun radium-gptel-key-from-file (file)
"return a closure reading FILE (just the raw api key, nothing else)
fresh on every call - never cached, never in this repo, never in an
env var. a missing file becomes an empty key so the api call 401s
normally instead of an elisp file-error mid-request."
(lambda ()
(condition-case nil
(with-temp-buffer
(insert-file-contents (expand-file-name file))
(string-trim (buffer-string)))
(file-missing
(message "err: no %s key file (put the raw api key there)." file)
""))))
(defun radium-gptel-apply-provider ()
"(re)build gptel-backend/gptel-model from `radium-gptel-provider' -
a no-op while its still nil (unconfigured), so requiring this file
never silently assumes any one provider. safe to call again anytime
after changing `radium-gptel-provider', e.g. from ~/.radium.el."
(when radium-gptel-provider
(let ((p radium-gptel-provider))
(setq gptel-backend
(gptel-make-openai (plist-get p :name)
:host (plist-get p :host)
:endpoint (plist-get p :endpoint)
:stream (if (plist-member p :stream) (plist-get p :stream) t)
:key (radium-gptel-key-from-file (plist-get p :key-file))
:models (plist-get p :models))
gptel-model (plist-get p :model)))))
(defun radium-gptel--open (path)
"find-file-noselect on PATH, resolved against default-directory."
(find-file-noselect (expand-file-name path)))
(defun radium-gptel-tool-read-file (path)
(with-current-buffer (radium-gptel--open path)
(buffer-substring-no-properties (point-min) (point-max))))
(defun radium-gptel-tool-list-directory (path)
(mapconcat #'identity
(directory-files (expand-file-name path) nil "\\`[^.]")
"\n"))
(defun radium-gptel-tool-write-file (path content)
"overwrite PATH with CONTENT entirely (for a new file, or a full
rewrite) - pops the buffer into view so the write is visible, not
just a silent disk write."
(let ((buf (radium-gptel--open path)))
(with-current-buffer buf
(erase-buffer)
(insert content)
(save-buffer))
(display-buffer buf)
(format "ok: wrote %s (%d bytes)." (buffer-file-name buf) (length content))))
(defun radium-gptel-tool-edit-region (path old-string new-string)
"replace the one occurrence of OLD-STRING with NEW-STRING in PATH -
same contract as my own edit tool: OLD-STRING must be unique in the
file, or this refuses rather than guess which one i meant."
(let ((buf (radium-gptel--open path)))
(with-current-buffer buf
(goto-char (point-min))
(let ((count 0))
(while (search-forward old-string nil t) (setq count (1+ count)))
(cond
((= count 0) (error "err: old_string not found in %s." path))
((> count 1) (error "err: old_string not unique in %s (%d matches)." path count))
(t
(goto-char (point-min))
(search-forward old-string)
(replace-match new-string t t)
(save-buffer)))))
(display-buffer buf)
(format "ok: edited %s." (buffer-file-name buf))))
(defun radium-gptel-tool-run-shell-command (command)
(with-temp-buffer
(let ((code (call-process-shell-command command nil t)))
(format "exit %d:\n%s" code (buffer-string)))))
(use-package gptel
;; :bind alone would implicitly defer loading gptel until one of
;; those keys is pressed - which would also defer :config, so
;; gptel-backend/gptel-model would silently stay unset until then.
;; :demand forces it to load (and configure the backend) at startup
;; like every other module here, not on first keypress.
:demand t
:config
;; gptel-make-openai's own default :header already does
;; ("Authorization" . "Bearer <key>") via gptel--get-api-key, which
;; is exactly the auth shape every openai-compatible provider ive
;; actually used shares - just supply :key, no per-provider custom
;; header function needed.
(radium-gptel-apply-provider)
(setq gptel-use-tools t)
;; gptel-make-tool only *registers* a tool (into gptel--known-tools,
;; discoverable in gptel-menu) - it does NOT add it to gptel-tools,
;; the list actually sent with a request. building gptel-tools
;; straight from these calls' return values is what actually turns
;; tool use on.
(setq gptel-tools
(list
(gptel-make-tool
:name "read_file" :category "radium"
:function #'radium-gptel-tool-read-file
:description "read a file's full contents, resolved against the project's default-directory"
:args (list '(:name "path" :type string :description "path to the file, relative or absolute")))
(gptel-make-tool
:name "list_directory" :category "radium"
:function #'radium-gptel-tool-list-directory
:description "list a directory's entries (dotfiles excluded)"
:args (list '(:name "path" :type string :description "path to the directory")))
(gptel-make-tool
:name "write_file" :category "radium" :confirm t
:function #'radium-gptel-tool-write-file
:description "overwrite a file with entirely new content - for a new file or a full rewrite, not a small edit (use edit_region for that)"
:args (list '(:name "path" :type string :description "path to the file")
'(:name "content" :type string :description "the file's entire new content")))
(gptel-make-tool
:name "edit_region" :category "radium" :confirm t
:function #'radium-gptel-tool-edit-region
:description "replace one exact, unique occurrence of old_string with new_string in a file - refuses if old_string isnt found or isnt unique"
:args (list '(:name "path" :type string :description "path to the file")
'(:name "old_string" :type string :description "the exact text to replace, must be unique in the file")
'(:name "new_string" :type string :description "the text to replace it with")))
(gptel-make-tool
:name "run_shell_command" :category "radium" :confirm t
:function #'radium-gptel-tool-run-shell-command
:description "run a shell command and return its exit code + combined output"
:args (list '(:name "command" :type string :description "the shell command line to run")))))
:bind (("C-c ? c" . gptel)
("C-c ? s" . gptel-send)
("C-c ? m" . gptel-menu)
("C-c ? r" . gptel-rewrite)))
;; mcp: set mcp-hub-servers (in this file, or a personal override
;; loaded after radium) to whatever servers i want - see mcp.el's own
;; docs for the (NAME . (:command ... :args ...)) / (NAME . (:url ...))
;; shape. mcp-hub-servers only exists once mcp.el itself has loaded,
;; so the package always loads (its just sitting there otherwise);
;; nothing actually starts unless ive configured a server.
(use-package mcp
:demand t
:config
(require 'mcp-hub)
(when mcp-hub-servers
(mcp-hub-start-all-server
(lambda () (setq gptel-tools (append gptel-tools (mcp-hub-get-all-tool)))))))
(provide 'radium-gptel)