diff --git a/nano-gpt.el b/nano-gpt.el new file mode 100644 index 0000000..94dfd1c --- /dev/null +++ b/nano-gpt.el @@ -0,0 +1,399 @@ +;;; nano-gpt-status.el --- Dashboard for Nano-GPT API -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Nano-GPT Contributors + +;; Author: Nano-GPT Contributors +;; Maintainer: Nano-GPT Contributors +;; URL: https://github.com/nano-gpt/emacs +;; Version: 0.1.0 +;; Package-Requires: ((emacs "28.1") (qrencode "0.1")) +;; Keywords: convenience, api, dashboard + +;; This file is not part of GNU Emacs. + +;; 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 . + +;;; Commentary: + +;; A dashboard for the Nano-GPT API showing models, usage, balance and deposits. + +;; Setup: +;; (require 'nano-gpt-status) +;; M-x nano-gpt-status +;; +;; Configure API key in auth-sources: +;; (add-to-list 'auth-sources '("nano-gpt.gpg" :host "nano-gpt.com")) + +;;; Code: + +(require 'url) +(require 'json) +(require 'auth-source) +(require 'org) +(require 'tabulated-list) +(require 'hl-line) + +(declare-function qrencode-string "qrencode") + +(defgroup nano-gpt nil + "Dashboard for Nano-GPT API." + :group 'convenience + :prefix "nano-gpt-" + :link '(url-link :tag "GitHub" "https://github.com/nano-gpt/emacs")) + +(defcustom nano-gpt-api-base-url "https://nano-gpt.com/api" + "Base URL for Nano-GPT API." + :type 'string + :group 'nano-gpt) + +(defcustom nano-gpt-models-endpoint "/v1/models?detailed=true" + "Endpoint for fetching models." + :type 'string + :group 'nano-gpt) + +(defvar nano-gpt--window-config nil + "Saved window configuration before dashboard was shown.") + +(defun nano-gpt--get-api-key () + "Get API key from auth-source for nano-gpt.com." + (let ((secret (auth-source-search :host "nano-gpt.com" :secret t))) + (when secret + (funcall (plist-get (car secret) :secret))))) + +;; --- Table 1: Fruits --- + +(defvar nano-gpt-fruits-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map "q" #'nano-gpt-quit) + map)) + +(define-derived-mode nano-gpt-fruits-mode tabulated-list-mode "Fruits" + "A table of fruits." + (setq tabulated-list-format [("Fruit" 15 t) ("Color" 10 t) ("Taste" 10 t)]) + (setq tabulated-list-entries + '((1 ["Apple" "Red" "Sweet"]) + (2 ["Banana" "Yellow" "Sweet"]) + (3 ["Lemon" "Yellow" "Sour"]) + (4 ["Grape" "Purple" "Sweet"]))) + (tabulated-list-init-header) + (tabulated-list-print)) + +;; --- Table 2: Models --- + +(defvar nano-gpt--models-data nil + "Cached models data from API.") + +(defun nano-gpt--aget (alist key) + "Safely get value from ALIST for KEY, returns nil if null." + (let ((val (cdr (assq key alist)))) + (unless (eq val :null) val))) + +(defun nano-gpt--format-context-length (num) + "Format NUM as human-readable context length." + (if (numberp num) + (cond + ((>= num 1000000) (format "%.1fM" (/ num 1000000.0))) + ((>= num 1000) (format "%.0fK" (/ num 1000.0))) + (t (number-to-string num))) + "N/A")) + +(defun nano-gpt--format-pricing (prompt completion) + "Format pricing from PROMPT and COMPLETION values." + (if (and (numberp prompt) (numberp completion) + (eq prompt 0) (eq completion 0)) + "Free" + (format "$%.2f/$%.2f" + (or prompt 0) + (or completion 0)))) + +(defun nano-gpt--fetch-models-callback (status) + "Callback for async model fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist)) + (models (cdr (assq 'data json)))) + (setq nano-gpt--models-data models) + (when (get-buffer "*Dashboard:Models*") + (with-current-buffer "*Dashboard:Models*" + (nano-gpt-models-mode))) + (message "Models loaded: %d entries" (length models))))) + +(defun nano-gpt--fetch-models-async () + "Fetch models from Nano-GPT API asynchronously." + (url-retrieve + (concat nano-gpt-api-base-url nano-gpt-models-endpoint) + #'nano-gpt--fetch-models-callback + nil t t)) + +;; --- Usage --- + +(defun nano-gpt--format-usage-time (epoch-ms) + "Format EPOCH-MS as human-readable local time." + (when epoch-ms + (format-time-string "%Y-%m-%d %H:%M" (/ epoch-ms 1000)))) + +(defun nano-gpt--fetch-usage-callback (status) + "Callback for async usage fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json-str (buffer-substring (point) (point-max))) + (json (json-parse-string json-str :object-type 'alist)) + (usage json) + (state (nano-gpt--aget usage 'state)) + (active (nano-gpt--aget usage 'active)) + (limits (nano-gpt--aget usage 'limits)) + (weekly (nano-gpt--aget usage 'weeklyInputTokens)) + (period (nano-gpt--aget usage 'period))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-min)) + (when (re-search-forward "^\\*\\* Usage" nil t) + (delete-region (match-beginning 0) (progn (forward-line) (point))) + (insert "** Usage\n") + (insert (format "- State: %s\n" (or state "N/A"))) + (insert (format "- Active: %s\n\n" (if active "Yes" "No"))) + (insert "*** Weekly Input Tokens\n") + (insert (format "- Used: %s / %s\n" + (nano-gpt--aget weekly 'used) + (nano-gpt--aget limits 'weeklyInputTokens))) + (insert (format "- Remaining: %s\n" (nano-gpt--aget weekly 'remaining))) + (insert (format "- Percent: %s%%\n" + (let ((pct (nano-gpt--aget weekly 'percentUsed))) + (when pct (format "%.2f" (* pct 100)))))) + (insert (format "- Resets at: %s\n\n" + (nano-gpt--format-usage-time (nano-gpt--aget weekly 'resetAt)))) + (insert "*** Billing Period\n") + (insert (format "- Ends: %s\n" + (let ((end (nano-gpt--aget period 'currentPeriodEnd))) + (when end + (format-time-string "%Y-%m-%d %H:%M" + (date-to-time end)))))))))) + (message "Usage loaded")))) + +(defun nano-gpt--fetch-usage-async () + "Fetch usage from Nano-GPT API asynchronously." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key))))) + (url-retrieve + (concat nano-gpt-api-base-url "/subscription/v1/usage") + #'nano-gpt--fetch-usage-callback + nil nil t))))) + +;; --- Balance --- + +(defun nano-gpt--fetch-balance-callback (status) + "Callback for async balance fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist)) + (usd (nano-gpt--aget json 'usd_balance))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-min)) + (when (re-search-forward "^\\*\\* Balance" nil t) + (delete-region (match-beginning 0) (progn (forward-line) (point))) + (insert "** Balance\n") + (insert (format "- USD: $%s\n" (or usd "N/A"))))))) + (message "Balance loaded")))) + +(defun nano-gpt--fetch-balance-async () + "Fetch balance from Nano-GPT API asynchronously." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-method "POST") + (url-request-extra-headers (list (cons "x-api-key" api-key)))) + (url-retrieve + (concat nano-gpt-api-base-url "/check-balance") + #'nano-gpt--fetch-balance-callback + nil nil t))))) + +;; --- Deposit --- + +(defun nano-gpt--create-deposit-callback (status) + "Callback for async deposit creation. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json-str (buffer-substring (point) (point-max))) + (json (json-parse-string json-str :object-type 'alist)) + (tx-id (nano-gpt--aget json 'txId)) + (amount (nano-gpt--aget json 'amount)) + (address (nano-gpt--aget json 'address))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert (format "\n*** Deposit Created\n")) + (insert (format "- txId: %s\n" (or tx-id "N/A"))) + (insert (format "- Amount: %s BTC\n" (or amount "N/A"))) + (when address + (insert (format "- Invoice (raw):\n%s\n" address)) + (insert (format "- Invoice (BOLT-11):\n")) + (insert (qrencode-string address)) + (insert "\n")) + (insert "\n")) + (goto-char (point-min))) + (message "Deposit created: %s" tx-id))))) + +(defun nano-gpt--create-deposit-async (amount) + "Create BTC-LN deposit for AMOUNT." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-method "POST") + (url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key)) + (cons "Content-Type" "application/json"))) + (url-request-data (json-encode (list (cons 'amount amount))))) + (url-retrieve + (concat nano-gpt-api-base-url "/transaction/create/btc-ln") + #'nano-gpt--create-deposit-callback + nil nil t))))) + +(defun nano-gpt--deposit-button-action (_) + "Action for deposit button." + (let* ((satoshis (read-number "Enter amount in satoshis: ")) + (amount (/ satoshis 100000000.0))) + (message "Creating BTC-LN deposit for %s satoshis (%s BTC)..." satoshis amount) + (nano-gpt--create-deposit-async amount))) + +(defun nano-gpt--insert-deposit-button () + "Insert a deposit button in the current buffer." + (insert-text-button "[Deposit BTC-LN]" + 'action (lambda (_) (nano-gpt--deposit-button-action 0.00001)) + 'follow-link t)) + +;; --- Models Mode --- + +(defvar nano-gpt-models-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map "q" #'nano-gpt-quit) + (define-key map "w" #'nano-gpt-models-copy-id) + map)) + +(defun nano-gpt-models-copy-id () + "Copy the ID of the current model to clipboard." + (interactive) + (when tabulated-list-entries + (let* ((entry (tabulated-list-get-entry)) + (id (aref entry 1))) + (kill-new id) + (message "Copied: %s" id)))) + +(define-derived-mode nano-gpt-models-mode tabulated-list-mode "Models" + "A table of Nano-GPT models." + (hl-line-mode 1) + (setq tabulated-list-format + [("Name" 30 t) + ("ID" 40 t) + ("Created" 10 t) + ("Ctx" 6 t :right-align t :pad-right 3) + ("Pricing" 18 t)]) + (setq tabulated-list-sort-key (cons "Created" t)) + (setq tabulated-list-entries + (mapcar + (lambda (model) + (let* ((pricing (nano-gpt--aget model 'pricing)) + (subscription (nano-gpt--aget model 'subscription)) + (has-sub (eq (nano-gpt--aget subscription 'included) t)) + (face (if has-sub '(:foreground "green") nil))) + (list (nano-gpt--aget model 'id) + (vector + (propertize (or (nano-gpt--aget model 'name) "N/A") 'font-lock-face face) + (propertize (or (nano-gpt--aget model 'id) "N/A") 'font-lock-face face) + (propertize (format-time-string "%Y-%m-%d" (nano-gpt--aget model 'created)) 'font-lock-face face) + (propertize (nano-gpt--format-context-length (nano-gpt--aget model 'context_length)) 'font-lock-face face) + (propertize (nano-gpt--format-pricing + (nano-gpt--aget pricing 'prompt) + (nano-gpt--aget pricing 'completion)) 'font-lock-face face))))) + nano-gpt--models-data)) + (tabulated-list-init-header) + (tabulated-list-print)) + +;; --- Quit function --- + +(defun nano-gpt-quit () + "Quit the dashboard, restore previous window configuration, and kill buffers." + (interactive) + (dolist (buf (list (get-buffer "*Dashboard:Fruits*") + (get-buffer "*Dashboard:Models*") + (get-buffer "*Dashboard:Info*"))) + (when buf (kill-buffer buf))) + (setq nano-gpt--models-data nil) + (when nano-gpt--window-config + (set-window-configuration nano-gpt--window-config) + (setq nano-gpt--window-config nil))) + +;; --- Main entry point --- + +;;;###autoload +(defun nano-gpt-status () + "Show a simple dashboard with two tables and an org buffer." + (interactive) + (setq nano-gpt--window-config (current-window-configuration)) + + (let ((fruits-buf (get-buffer-create "*Dashboard:Fruits*")) + (models-buf (get-buffer-create "*Dashboard:Models*")) + (org-buf (get-buffer-create "*Dashboard:Info*"))) + + (with-current-buffer fruits-buf + (nano-gpt-fruits-mode)) + + (with-current-buffer models-buf + (nano-gpt-models-mode)) + + (with-current-buffer org-buf + (let ((inhibit-read-only t)) + (erase-buffer) + (org-mode) + (org-indent-mode 1) + (insert "* Dashboard\n\n") + (insert "Welcome to the *dashboard*!\n\n") + (insert "** Notes\n") + (insert "- Press ~q~ in any table to quit\n") + (insert "- Table 1 :: Fruits of the world\n") + (insert "- Table 2 :: Nano-GPT Models (loading...)\n\n") + (insert "** Usage\n\n") + (insert "** Balance\n\n") + (insert "** Deposit (BTC-LN)\n") + (nano-gpt--insert-deposit-button) + (insert "\n")) + (goto-char (point-min)) + (use-local-map (let ((map (copy-keymap org-mode-map))) + (define-key map "q" #'nano-gpt-quit) + map))) + + (delete-other-windows) + (let* ((top-left (selected-window)) + (top-right (split-window-right)) + (bottom (split-window-below (/ (window-height) 2)))) + (set-window-buffer top-left fruits-buf) + (set-window-buffer top-right models-buf) + (set-window-buffer bottom org-buf)) + + (select-window (get-buffer-window fruits-buf))) + + (nano-gpt--fetch-models-async) + (nano-gpt--fetch-usage-async) + (nano-gpt--fetch-balance-async)) + +(provide 'nano-gpt-status) + +;;; nano-gpt-status.el ends here