branch: elpa/gptel
commit b60589fa24b9f3e0ad07b90ce5562e07d0ef1400
Author: kiennq <kien.n.qu...@gmail.com>
Commit: GitHub <nore...@github.com>

    gptel-gh: Add support for Github Copilot Chat (#767)
    
    * gptel-gh.el: (gptel--gh, gptel-make-gh-copilot, gptel--gh-uuid,
    gptel--gh-token-file, gptel--gh-github-token-file,
    gptel--gh-auth-common-headers, gptel--gh-client-id,
    gptel--gh-machine-id, gptel--gh-login, gptel--gh-renew-token,
    gptel--gh-auth, gptel--gh-restore, gptel--gh-save,
    gptel--gh-models): Add support for GitHub Copilot Chat.
    
    * gptel.el (gptel--url-retrieve): Add a general purpose,
    synchronous HTTP fetcher for auxiliary tasks.  This is used for
    authentication in the GitHub Copilot chat backend, but will be
    used in the future to handle fetching a list of updated models for
    other backends, among other tasks.
    
    * README.org (GitHub CopilotChat): Add Github CopilotChat section.
---
 README.org  |  24 +++++
 gptel-gh.el | 307 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 gptel.el    |  13 +++
 3 files changed, 344 insertions(+)

diff --git a/README.org b/README.org
index 79723a2f75..04871bb890 100644
--- a/README.org
+++ b/README.org
@@ -32,6 +32,7 @@ gptel is a simple Large Language Model chat client for Emacs, 
with support for m
 | Github Models      | ✓        | 
[[https://github.com/settings/tokens][Token]]                      |
 | Novita AI          | ✓        | 
[[https://novita.ai/model-api/product/llm-api?utm_source=github_gptel&utm_medium=github_readme&utm_campaign=link][Token]]
                      |
 | xAI                | ✓        | 
[[https://console.x.ai?utm_source=github_gptel&utm_medium=github_readme&utm_campaign=link][API
 key]]                    |
+| Github CopilotChat | ✓        | Github account             |
 #+html: </div>
 
 *General usage*: ([[https://www.youtube.com/watch?v=bsRnh_brggM][YouTube 
Demo]])
@@ -113,6 +114,7 @@ gptel uses Curl if available, but falls back to the 
built-in url-retrieve to wor
       - [[#github-models][Github Models]]
       - [[#novita-ai][Novita AI]]
       - [[#xai][xAI]]
+      - [[#github-copilotchat][Github CopilotChat]]
   - [[#usage][Usage]]
     - [[#in-any-buffer][In any buffer:]]
     - [[#in-a-dedicated-chat-buffer][In a dedicated chat buffer:]]
@@ -895,6 +897,28 @@ The above code makes the backend available to select.  If 
you want it to be the
 
 #+html: </details>
 
+#+html: </details>
+#+html: <details><summary>
+**** Github CopilotChat
+#+html: </summary>
+
+Register a backend with
+#+begin_src emacs-lisp
+(gptel-make-gh-copilot "Copilot")
+#+end_src
+
+You will be informed to login into =Github= as required.
+You can pick this backend from the menu when using gptel (see 
[[#usage][Usage]]).
+
+***** (Optional) Set as the default gptel backend
+
+The above code makes the backend available to select.  If you want it to be 
the default backend for gptel, you can set this as the value of 
=gptel-backend=.  Use this instead of the above.
+#+begin_src emacs-lisp
+;; OPTIONAL configuration
+(setq gptel-model 'claude-3.7-sonnet
+      gptel-backend (gptel-make-gh-copilot "Copilot"))
+#+end_src
+
 ** Usage
 
 gptel provides a few powerful, general purpose and flexible commands.  You can 
dynamically tweak their behavior to the needs of your task with /directives/, 
redirection options and more.  There is a 
[[https://www.youtube.com/watch?v=bsRnh_brggM][video demo]] showing various 
uses of gptel -- but =gptel-send= might be all you need.
diff --git a/gptel-gh.el b/gptel-gh.el
new file mode 100644
index 0000000000..3069ca7f69
--- /dev/null
+++ b/gptel-gh.el
@@ -0,0 +1,307 @@
+;;; gptel-gh.el ---  Github Copilot AI suppport for gptel  -*- 
lexical-binding: t; -*-
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file adds support for Github Copilot API to gptel
+
+;;; Code:
+
+(eval-when-compile
+  (require 'cl-lib))
+(require 'map)
+(require 'gptel)
+(require 'browse-url)
+
+;;; Github Copilot
+(defconst gptel--gh-models
+  '((gpt-4o-2024-11-20
+     :description
+     "Advanced model for complex tasks; cheaper & faster than GPT-Turbo"
+     :capabilities (media tool-use json url)
+     :mime-types ("image/jpeg" "image/png" "image/gif" "image/webp")
+     :context-window 128 :input-cost 2.5 :output-cost 10 :cutoff-date 
"2023-10")
+    (gpt-4.5-preview
+     :description "Largest and most capable GPT model to date"
+     :capabilities (tool-use url)
+     :context-window 128
+     :input-cost 75
+     :output-cost 150
+     :cutoff-date "2023-10")
+    (o1
+     :description "Reasoning model designed to solve hard problems across 
domains"
+     :capabilities (reasoning)
+     :context-window 200
+     :input-cost 15
+     :output-cost 60
+     :cutoff-date "2023-10"
+     :request-params (:stream :json-false))
+    (o3-mini
+     :description "High intelligence at the same cost and latency targets of 
o1-mini"
+     :context-window 200
+     :input-cost 3
+     :output-cost 12
+     :cutoff-date "2023-10"
+     :capabilities (reasoning)
+     :request-params (:stream :json-false))
+    (claude-3.5-sonnet
+     :description "Highest level of intelligence and capability"
+     :capabilities (tool-use cache)
+     :context-window 200
+     :input-cost 3
+     :output-cost 15
+     :cutoff-date "2024-04")
+    (claude-3.7-sonnet
+     :description "Hybrid model capable of standard thinking and extended 
thinking modes"
+     :capabilities (tool-use cache)
+     :context-window 200
+     :input-cost 3
+     :output-cost 15
+     :cutoff-date "2025-02")
+    (claude-3.7-sonnet-thought
+     :description "Claude 3.7 Sonnet Thinking"
+     :capabilities (tool-use cache)
+     :context-window 200
+     :input-cost 3
+     :output-cost 15
+     :cutoff-date "2025-02")
+    (gemini-2.0-flash-001
+     :description "Next gen, high speed, multimodal for a diverse variety of 
tasks"
+     :capabilities (tool-use json)
+     :context-window 1000
+     :input-cost 0.10
+     :output-cost 0.40
+     :cutoff-date "2024-08")))
+
+(cl-defstruct (gptel--gh (:include gptel-openai)
+                         (:copier nil)
+                         (:constructor gptel--make-gh))
+  token github-token sessionid machineid)
+
+(defcustom gptel-gh-github-token-file (expand-file-name 
".cache/copilot-chat/github-token"
+                                                        user-emacs-directory)
+  "File where the GitHub token is stored."
+  :type 'string
+  :group 'gptel)
+
+(defcustom gptel-gh-token-file (expand-file-name ".cache/copilot-chat/token"
+                                                 user-emacs-directory)
+  "File where the chat token is cached."
+  :type 'string
+  :group 'gptel)
+
+(defconst gptel--gh-auth-common-headers
+  `(("editor-plugin-version" . "gptel/*")
+    ("editor-version" . ,(concat "emacs/" emacs-version))))
+
+(defconst gptel--gh-client-id "Iv1.b507a08c87ecfe98")
+
+;; 
https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)
+(defun gptel--gh-uuid ()
+  "Generate a UUID v4-1."
+  (format "%04x%04x-%04x-4%03x-8%03x-%04x%04x%04x"
+          (random #x10000) (random #x10000)
+          (random #x10000)
+          (random #x1000)
+          (random #x1000)
+          (random #x10000) (random #x10000) (random #x10000)))
+
+(defun gptel--gh-machine-id ()
+  "Generate a machine ID."
+  (let ((hex-chars "0123456789abcdef")
+        (length 65)
+        hex)
+    (dotimes (_ length)
+      (setq hex (nconc hex (list (aref hex-chars (random 16))))))
+    (apply #'string hex)))
+
+(defun gptel--gh-restore (file)
+  "Restore saved object from FILE."
+  (when (file-exists-p file)
+    ;; We set the coding system to `utf-8-auto-dos' when reading so that
+    ;; files with CR EOL can still be read properly
+    (let ((coding-system-for-read 'utf-8-auto-dos))
+      (with-temp-buffer
+        (set-buffer-multibyte nil)
+        (insert-file-contents-literally file)
+        (goto-char (point-min))
+        (read (current-buffer))))))
+
+(defun gptel--gh-save (file obj)
+  "Save OBJ to FILE."
+  (let ((print-length nil)
+        (print-level nil)
+        (coding-system-for-write 'utf-8-unix))
+    (make-directory (file-name-directory file) t)
+    (write-region (prin1-to-string obj) nil file nil :silent)
+    obj))
+
+(defun gptel--gh-login()
+  "Manage github login."
+  (pcase-let (((map :device_code :user_code :verification_uri)
+               (gptel--url-retrieve
+                "https://github.com/login/device/code";
+                :method 'post
+                :headers gptel--gh-auth-common-headers
+                :data `( :client_id ,gptel--gh-client-id
+                         :scope "read:user"))))
+    (gui-set-selection 'CLIPBOARD user_code)
+    (read-from-minibuffer
+     (format "Your one-time code %s is copied. \
+Press ENTER to open GitHub in your browser. \
+If your browser does not open automatically, browse to %s."
+             user_code verification_uri))
+    (browse-url verification_uri)
+    (read-from-minibuffer "Press ENTER after authorizing.")
+    (thread-last
+      (plist-get
+       (gptel--url-retrieve
+        "https://github.com/login/oauth/access_token";
+        :method 'post
+        :headers gptel--gh-auth-common-headers
+        :data `( :client_id ,gptel--gh-client-id
+                 :device_code ,device_code
+                 :grant_type "urn:ietf:params:oauth:grant-type:device_code"))
+       :access_token)
+      (gptel--gh-save gptel-gh-github-token-file)
+      (setf (gptel--gh-github-token gptel-backend)))))
+
+(defun gptel--gh-renew-token ()
+  "Renew session token."
+  (let ((token
+         (gptel--url-retrieve
+             "https://api.github.com/copilot_internal/v2/token";
+           :method 'get
+           :headers `(("authorization"
+                       . ,(format "token %s" (gptel--gh-github-token 
gptel-backend)))
+                      ,@gptel--gh-auth-common-headers))))
+    (if (not (plist-get token :token))
+        (progn
+          (setf (gptel--gh-github-token gptel-backend) nil)
+          (user-error "Error: You might not have access to Github Copilot 
Chat!"))
+      (thread-last
+        (gptel--gh-save gptel-gh-token-file token)
+        (setf (gptel--gh-token gptel-backend))))))
+
+(defun gptel--gh-auth ()
+  "Authenticate with GitHub Copilot API.
+
+We first need github authorization (github token).
+Then we need a session token."
+  (unless (gptel--gh-github-token gptel-backend)
+    (let ((token (gptel--gh-restore gptel-gh-github-token-file)))
+      (if token
+          (setf (gptel--gh-github-token gptel-backend) token)
+        (gptel--gh-login))))
+
+  (when (null (gptel--gh-token gptel-backend))
+    ;; try to load token from `gptel-gh-token-file'
+    (setf (gptel--gh-token gptel-backend)
+          (gptel--gh-restore gptel-gh-token-file)))
+
+  (pcase-let (((map :token :expires_at)
+               (gptel--gh-token gptel-backend)))
+    (when (or (null token)
+              (and expires_at
+                   (> (round (float-time (current-time)))
+                      expires_at)))
+      (gptel--gh-renew-token))))
+
+;;;###autoload
+(cl-defun gptel-make-gh-copilot
+    (name &key curl-args request-params
+          (header (lambda ()
+                    (gptel--gh-auth)
+                    `(("openai-intent" . "conversation-panel")
+                      ("authorization" . ,(concat "Bearer "
+                                           (plist-get (gptel--gh-token 
gptel-backend) :token)))
+                      ("x-request-id" . ,(gptel--gh-uuid))
+                      ("vscode-sessionid" . ,(or (gptel--gh-sessionid 
gptel-backend) ""))
+                      ("vscode-machineid" . ,(or (gptel--gh-machineid 
gptel-backend) ""))
+                      ("copilot-vision-request" . "true")
+                      ("copilot-integration-id" . "vscode-chat"))))
+          (host "api.githubcopilot.com")
+          (protocol "https")
+          (endpoint "/chat/completions")
+          (stream t)
+          (models gptel--gh-models))
+  "Register a Github Copilot chat backend for gptel with NAME.
+
+Keyword arguments:
+
+CURL-ARGS (optional) is a list of additional Curl arguments.
+
+HOST (optional) is the API host, typically \"api.githubcopilot.com\".
+
+MODELS is a list of available model names, as symbols.
+Additionally, you can specify supported LLM capabilities like
+vision or tool-use by appending a plist to the model with more
+information, in the form
+
+ (model-name . plist)
+
+For a list of currently recognized plist keys, see
+`gptel--openai-models'.  An example of a model specification
+including both kinds of specs:
+
+:models
+\\='(gpt-3.5-turbo                         ;Simple specs
+  gpt-4-turbo
+  (gpt-4o-mini                          ;Full spec
+   :description
+   \"Affordable and intelligent small model for lightweight tasks\"
+   :capabilities (media tool json url)
+   :mime-types
+   (\"image/jpeg\" \"image/png\" \"image/gif\" \"image/webp\")))
+
+Defaults to a list of models supported by GitHub Copilot.
+
+STREAM is a boolean to toggle streaming responses, defaults to
+false.
+
+PROTOCOL (optional) specifies the protocol, https by default.
+
+ENDPOINT (optional) is the API endpoint for completions, defaults to
+\"/chat/completions\".
+
+HEADER (optional) is for additional headers to send with each
+request.  It should be an alist or a function that returns an
+alist, like:
+ ((\"Content-Type\" . \"application/json\"))
+
+Defaults to headers required by GitHub Copilot.
+
+REQUEST-PARAMS (optional) is a plist of additional HTTP request
+parameters (as plist keys) and values supported by the API.  Use
+these to set parameters that gptel does not provide user options
+for."
+  (declare (indent 1))
+  (let ((backend (gptel--make-gh
+                  :name name
+                  :host host
+                  :header header
+                  :models (gptel--process-models models)
+                  :protocol protocol
+                  :endpoint endpoint
+                  :stream stream
+                  :request-params request-params
+                  :curl-args curl-args
+                  :url (concat protocol "://" host endpoint)
+                  :machineid (gptel--gh-machine-id))))
+    (setf (alist-get name gptel--known-backends nil nil #'equal) backend)
+    backend))
+
+(provide 'gptel-gh)
+;;; gptel-gh.el ends here
diff --git a/gptel.el b/gptel.el
index 69bc000fee..b7f8c9abf6 100644
--- a/gptel.el
+++ b/gptel.el
@@ -912,6 +912,19 @@ Later plists in the sequence take precedence over earlier 
ones."
         (setq rtn (plist-put rtn p v))))
     rtn))
 
+(cl-defun gptel--url-retrieve (url &key method data headers)
+  "Retrieve URL synchronously with METHOD, DATA and HEADERS."
+  (declare (indent 1))
+  (let ((url-request-method (if (eq method'post) "POST" "GET"))
+        (url-request-data (encode-coding-string (gptel--json-encode data) 
'utf-8))
+        (url-mime-accept-string "application/json")
+        (url-request-extra-headers
+         `(("content-type" . "application/json")
+           ,@headers)))
+    (with-current-buffer (url-retrieve-synchronously url 'silent)
+      (goto-char url-http-end-of-headers)
+      (gptel--json-read))))
+
 (defun gptel-auto-scroll ()
   "Scroll window if LLM response continues below viewport.
 

Reply via email to