Skip to content
Open
14 changes: 12 additions & 2 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -1089,8 +1089,18 @@ However private endpoints can be configure like so -
:host "api.business.githubcopilot.com")
#+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]]).
You will be informed to login into =GitHub= as required. You can pick this backend from the menu when using gptel (see [[#usage][Usage]]).

The GitHub OAuth token is stored by default in a cleartext file based on the path stored in =gptel-gh-github-token-file=. It is possible to customize the variables =gptel-gh-github-token-load-function= and =gptel-gh-github-token-save-function= to change this behavior. If the token is e.g. stored in a password manager, then the following example can be used to so that the token is read as a password:

#+begin_src emacs-lisp
(setq
;; Prompt user to provide the token as a password
gptel-gh-github-token-load-function (lambda (github-username) (read-passwd (format "Copilot token for '%s': " github-username)))

;; Display a message when a new token has been stored, so that it can be copied from the message history
gptel-gh-github-token-save-function (lambda (github-username token) (message "New token for '%s' saved: %s" github-username token))
#+end_src

***** (Optional) Set as the default gptel backend

Expand Down
165 changes: 106 additions & 59 deletions gptel-gh.el
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,22 @@
(cl-defstruct (gptel--gh (:include gptel-openai)
(:copier nil)
(:constructor gptel--make-gh))
token github-token sessionid machineid)
token github-token sessionid machineid github-username)

(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
(defcustom gptel-gh-github-token-load-function 'gptel--gh-restore-github-token-from-file
"Function to load the current github token. Default behavior is file-based based on `gptel-gh-github-token-file'."
:type 'function
:group 'gptel)

(defcustom gptel-gh-github-token-save-function 'gptel--gh-save-github-token-from-file
"Function to save the new github token. Default behavior is file-based based on `gptel-gh-github-token-file'."
:type 'function
:group 'gptel)

(defconst gptel--gh-auth-common-headers
Expand All @@ -163,6 +167,33 @@

(defconst gptel--gh-client-id "Iv1.b507a08c87ecfe98")

(defun gptel--gh-get-backends-by-username (github-username)
(seq-filter (lambda (b) (and (gptel--gh-p b)
(string= (gptel--gh-github-username b)
github-username)))
(mapcar #'cdr gptel--known-backends)))

(defun gptel--gh-load-github-token (github-username)
"Function that ensures that the GitHub OAuth token cache is used and is set."
(if (gptel--gh-github-token gptel-backend)
(gptel--gh-github-token gptel-backend)
(let ((token (funcall gptel-gh-github-token-load-function github-username)))
(if (string= token "")
;; Empty string should be interpreted as no data. Return nil so that a
;; proper login is performed.
nil
;; Iterate over the known backends for the same username and set the GitHub token
(dolist (b (gptel--gh-get-backends-by-username github-username) token)
(setf (gptel--gh-github-token b) token))))))

(defun gptel--gh-save-github-token (github-username token)
"Function that updates the GitHub OAuth token cache and calls the save function."
;; Update the token for all connected backends
(dolist (b (gptel--gh-get-backends-by-username github-username))
(setf (gptel--gh-github-token b) token))
(funcall gptel-gh-github-token-save-function github-username
(setf (gptel--gh-github-token gptel-backend) token)))

;; https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)
(defun gptel--gh-uuid ()
"Generate a UUID v4-1."
Expand All @@ -182,7 +213,41 @@
(setq hex (nconc hex (list (aref hex-chars (random 16))))))
(apply #'string hex)))

(defun gptel--gh-restore (file)
(defun gptel--gh-validate-github-username (github-username)
"Ensures that the given GITHUB-USERNAME conforms to the rules from GitHub:

'Username may only contain alphanumeric characters or single hyphens,
and cannot begin or end with a hyphen.'

An empty string value is considered to be a general account to be used.
"
(cond
;; Ensure that the username is a string
((not (stringp github-username)) (user-error "Provided GitHub username is not string"))

;; Length of zero is a default value
((= (length github-username) 0) t)

;; We have some characters, ensure that they conform to the rules
((string-match-p "\\`[0-9A-Za-z]\\(?:[0-9A-Za-z]\\|-[0-9A-Za-z]\\)*\\'" github-username) t)
(t (user-error "Provided GitHub username '%s' doesn't conform to GitHub's username rules"
github-username))))

(defun gptel--gh-generate-github-token-filename (github-username)
(gptel--gh-validate-github-username github-username)
(if (= (length github-username) 0)
gptel-gh-github-token-file
(concat gptel-gh-github-token-file "_" github-username)))

(defun gptel--gh-restore-github-token-from-file (github-username)
"Restore GitHub token from the file gptel-gh-github-token-file."
(gptel--gh-restore-from-file (gptel--gh-generate-github-token-filename github-username)))

(defun gptel--gh-save-github-token-from-file (github-username token)
"Save GitHub token to the file gptel-gh-github-token-file."
(gptel--gh-save-to-file (gptel--gh-generate-github-token-filename github-username) token))

(defun gptel--gh-restore-from-file (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
Expand All @@ -194,7 +259,7 @@
(goto-char (point-min))
(read (current-buffer))))))

(defun gptel--gh-save (file obj)
(defun gptel--gh-save-to-file (file obj)
"Save OBJ to FILE."
(let ((print-length nil)
(print-level nil)
Expand All @@ -203,32 +268,23 @@
(write-region (prin1-to-string obj) nil file nil :silent)
obj))

(defun gptel-gh-login ()
(defun gptel-gh-login (github-username)
"Login to GitHub Copilot API.

This will prompt you to authorize in a browser and store the token.

In SSH sessions, the URL and code will be displayed for manual entry
instead of attempting to open a browser automatically."
(interactive)
;; Determine which GitHub backend to use
(let ((gh-backend
(cond
;; If current backend is GitHub, use it
((and (boundp 'gptel-backend)
gptel-backend
(gptel--gh-p gptel-backend))
gptel-backend)
;; Otherwise, find any GitHub backend
((cl-find-if (lambda (b) (gptel--gh-p b))
(mapcar #'cdr gptel--known-backends)))
;; No GitHub backend found
(t (user-error "No GitHub Copilot backend found. \
Please set one up with `gptel-make-gh-copilot' first"))))
(message "Logging in using '%s'" github-username)
(let ((gh-backends (gptel--gh-get-backends-by-username github-username))
;; Detect SSH sessions
(in-ssh-session (or (getenv "SSH_CLIENT")
(getenv "SSH_CONNECTION")
(getenv "SSH_TTY"))))
;; It shall only be possible to login when there exists a corresponding backend
(if (= (length gh-backends) 0)
(user-error "No GitHub CoPilot backend found for username '%s'" github-username))
(pcase-let (((map :device_code :user_code :verification_uri)
(gptel--url-retrieve
"https://github.com/login/device/code"
Expand All @@ -252,26 +308,21 @@ 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. "))
;; Use gh-backend for token storage
(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 gh-backend))))
;; Check gh-backend for success
(if (and (gptel--gh-github-token gh-backend)
(not (string-empty-p
(gptel--gh-github-token gh-backend))))
(message "Successfully logged in to GitHub Copilot.")
(user-error "Error: You might not have access to GitHub Copilot Chat!"))))
(read-from-minibuffer "Press ENTER after authorizing."))
(let ((github-token
(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)))
(if (or (null github-token) (string-empty-p github-token))
(user-error "Error: You might not have access to GitHub Copilot Chat!"))
(message "Successfully logged in to GitHub Copilot")
(gptel--gh-save-github-token github-username github-token)))))

(defun gptel--gh-renew-token ()
"Renew session token."
Expand All @@ -283,45 +334,36 @@ If your browser does not open automatically, browse to %s."
. ,(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))))))
(user-error "Error: You might not have access to GitHub Copilot Chat!")
(setf (gptel--gh-token gptel-backend) token))))

(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)))
(let* ((github-username (gptel--gh-github-username gptel-backend))
(token (gptel--gh-load-github-token github-username)))
(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)))
(gptel-gh-login github-username))))

(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)))
(> (round (float-time)) expires_at)))
(gptel--gh-renew-token))))

;;;###autoload
(cl-defun gptel-make-gh-copilot
(name &key curl-args request-params
(name &key (github-username "") curl-args request-params
(header (lambda ()
(gptel--gh-auth)
`(("openai-intent" . "conversation-panel")
("authorization" . ,(concat "Bearer "
(plist-get (gptel--gh-token gptel-backend) :token)))
(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) ""))
Expand All @@ -338,6 +380,9 @@ Then we need a session token."

Keyword arguments:

GITHUB-USERNAME (optional) is an indicator of which GitHub account to associate
the backend with. This enables backends to be logged in as a separate user.

CURL-ARGS (optional) is a list of additional Curl arguments.

HOST (optional) is the API host, typically \"api.githubcopilot.com\".
Expand Down Expand Up @@ -385,6 +430,7 @@ 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))
(gptel--gh-validate-github-username github-username)
(let ((backend (gptel--make-gh
:name name
:host host
Expand All @@ -395,6 +441,7 @@ for."
:stream stream
:request-params request-params
:curl-args curl-args
:github-username github-username
:url (concat protocol "://" host endpoint)
:machineid (gptel--gh-machine-id))))
(setf (alist-get name gptel--known-backends nil nil #'equal) backend)
Expand Down