;;; 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-nix-entry) map)) (defun nano-gpt-models-copy-nix-entry () "Copy the current model as a Nix attribute set to clipboard." (interactive) (when tabulated-list-entries (let* ((id (tabulated-list-get-id)) (model (seq-find (lambda (m) (equal (nano-gpt--aget m 'id) id)) nano-gpt--models-data)) (name (nano-gpt--aget model 'name)) (pricing (nano-gpt--aget model 'pricing)) (input-price (or (nano-gpt--aget pricing 'prompt) 0)) (output-price (or (nano-gpt--aget pricing 'completion) 0)) (nix-str (format " \"%s\" = {\n name = \"%s\";\n cost = [%s %s];\n };" id name (or input-price 0) (or output-price 0)))) (kill-new nix-str) (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