packages feed

taffybar-7.1.0: src/System/Taffybar/Information/OpenAIUsage.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Shared OpenAI Codex subscription usage information.
--
-- This module currently reads the ChatGPT OAuth token file written by Codex and
-- calls ChatGPT's Codex usage endpoint. That endpoint is not part of OpenAI's
-- public API, so callers can override the endpoint, auth path, and user agent.
module System.Taffybar.Information.OpenAIUsage
  ( OpenAIUsageConfig (..),
    defaultOpenAIUsageConfig,
    OpenAIUsageAuth (..),
    OpenAIUsageSnapshot (..),
    OpenAIUsageInfo (..),
    OpenAIUsageAdditionalRateLimit (..),
    OpenAIUsageRateLimit (..),
    OpenAIUsageWindow (..),
    OpenAIUsageCredits (..),
    getOpenAIUsageInfo,
    updateOpenAIUsage,
    getOpenAIUsageChan,
    getOpenAIUsageState,
    forceOpenAIUsageRefresh,
  )
where

import Control.Concurrent (forkIO)
import Control.Concurrent.MVar
import Control.Concurrent.STM (atomically, orElse)
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, try)
import Control.Monad (forever, void)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Network.HTTP.Simple
import System.Directory (doesFileExist, getHomeDirectory)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
import System.Log.Logger (Priority (WARNING), logM)
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)

data OpenAIUsageConfig = OpenAIUsageConfig
  { openAIUsagePollInterval :: Double,
    openAIUsageEndpoint :: String,
    openAIUsageUserAgent :: String,
    openAIUsageAuthPath :: Maybe FilePath
  }

defaultOpenAIUsageConfig :: OpenAIUsageConfig
defaultOpenAIUsageConfig =
  OpenAIUsageConfig
    { openAIUsagePollInterval = 60 * 15,
      openAIUsageEndpoint = "https://chatgpt.com/backend-api/codex/usage",
      openAIUsageUserAgent = "taffybar-openai-usage",
      openAIUsageAuthPath = Nothing
    }

data OpenAIUsageAuth = OpenAIUsageAuth
  { openAIUsageAccessToken :: T.Text,
    openAIUsageAccountId :: T.Text
  }

instance FromJSON OpenAIUsageAuth where
  parseJSON = withObject "OpenAIUsageAuth" $ \root -> do
    tokens <- root .: "tokens"
    OpenAIUsageAuth
      <$> tokens .: "access_token"
      <*> tokens .: "account_id"

data OpenAIUsageSnapshot
  = OpenAIUsageAvailable OpenAIUsageInfo
  | OpenAIUsageUnavailable T.Text
  deriving (Eq, Show)

data OpenAIUsageInfo = OpenAIUsageInfo
  { openAIUsagePlanType :: Maybe T.Text,
    openAIUsageRateLimit :: OpenAIUsageRateLimit,
    openAIUsageAdditionalRateLimits :: [OpenAIUsageAdditionalRateLimit],
    openAIUsageCredits :: Maybe OpenAIUsageCredits,
    openAIUsageReachedType :: Maybe T.Text
  }
  deriving (Eq, Show)

instance FromJSON OpenAIUsageInfo where
  parseJSON = withObject "OpenAIUsageInfo" $ \o ->
    OpenAIUsageInfo
      <$> o .:? "plan_type"
      <*> o .: "rate_limit"
      <*> o .:? "additional_rate_limits" .!= []
      <*> o .:? "credits"
      <*> o .:? "rate_limit_reached_type"

data OpenAIUsageAdditionalRateLimit = OpenAIUsageAdditionalRateLimit
  { openAIUsageAdditionalLimitName :: Maybe T.Text,
    openAIUsageAdditionalRateLimit :: OpenAIUsageRateLimit
  }
  deriving (Eq, Show)

instance FromJSON OpenAIUsageAdditionalRateLimit where
  parseJSON = withObject "OpenAIUsageAdditionalRateLimit" $ \o ->
    OpenAIUsageAdditionalRateLimit
      <$> o .:? "limit_name"
      <*> o .: "rate_limit"

data OpenAIUsageRateLimit = OpenAIUsageRateLimit
  { openAIUsageAllowed :: Bool,
    openAIUsageLimitReached :: Bool,
    openAIUsagePrimaryWindow :: Maybe OpenAIUsageWindow,
    openAIUsageSecondaryWindow :: Maybe OpenAIUsageWindow
  }
  deriving (Eq, Show)

instance FromJSON OpenAIUsageRateLimit where
  parseJSON = withObject "OpenAIUsageRateLimit" $ \o ->
    OpenAIUsageRateLimit
      <$> o .:? "allowed" .!= True
      <*> o .:? "limit_reached" .!= False
      <*> o .:? "primary_window"
      <*> o .:? "secondary_window"

data OpenAIUsageWindow = OpenAIUsageWindow
  { openAIUsageUsedPercent :: Int,
    openAIUsageWindowDurationSeconds :: Maybe Int,
    openAIUsageResetAfterSeconds :: Maybe Int
  }
  deriving (Eq, Show)

instance FromJSON OpenAIUsageWindow where
  parseJSON = withObject "OpenAIUsageWindow" $ \o ->
    OpenAIUsageWindow
      <$> o .: "used_percent"
      <*> o .:? "limit_window_seconds"
      <*> o .:? "reset_after_seconds"

data OpenAIUsageCredits = OpenAIUsageCredits
  { openAIUsageHasCredits :: Bool,
    openAIUsageCreditsUnlimited :: Bool,
    openAIUsageCreditsBalance :: Maybe T.Text
  }
  deriving (Eq, Show)

instance FromJSON OpenAIUsageCredits where
  parseJSON = withObject "OpenAIUsageCredits" $ \o ->
    OpenAIUsageCredits
      <$> o .:? "has_credits" .!= False
      <*> o .:? "unlimited" .!= False
      <*> o .:? "balance"

getOpenAIUsageInfo :: OpenAIUsageConfig -> IO OpenAIUsageInfo
getOpenAIUsageInfo config = do
  auth <- readOpenAIUsageAuth config
  fetchOpenAIUsage config auth

updateOpenAIUsage :: OpenAIUsageConfig -> IO OpenAIUsageSnapshot
updateOpenAIUsage config = do
  result <- try $ getOpenAIUsageInfo config
  case result of
    Right info -> return $ OpenAIUsageAvailable info
    Left (err :: SomeException) -> do
      let message = T.pack $ show err
      logM logName WARNING $ "OpenAI usage update failed: " <> show err
      return $ OpenAIUsageUnavailable message

readOpenAIUsageAuth :: OpenAIUsageConfig -> IO OpenAIUsageAuth
readOpenAIUsageAuth config = do
  path <- maybe defaultCodexAuthPath return (openAIUsageAuthPath config)
  bytes <- LBS.readFile path
  either fail return $ eitherDecode bytes

defaultCodexAuthPath :: IO FilePath
defaultCodexAuthPath = do
  codexHome <- lookupEnv "CODEX_HOME"
  case codexHome of
    Just dir -> return $ dir </> "auth.json"
    Nothing -> do
      home <- getHomeDirectory
      let path = home </> ".codex" </> "auth.json"
      exists <- doesFileExist path
      if exists
        then return path
        else fail "No Codex auth file found"

fetchOpenAIUsage :: OpenAIUsageConfig -> OpenAIUsageAuth -> IO OpenAIUsageInfo
fetchOpenAIUsage config auth = do
  request0 <- parseRequest $ openAIUsageEndpoint config
  let request =
        setRequestHeader "Authorization" ["Bearer " <> TE.encodeUtf8 (openAIUsageAccessToken auth)] $
          setRequestHeader "ChatGPT-Account-Id" [TE.encodeUtf8 (openAIUsageAccountId auth)] $
            setRequestHeader "Accept" ["application/json"] $
              setRequestHeader "User-Agent" [TE.encodeUtf8 (T.pack (openAIUsageUserAgent config))] request0
  response <- httpLBS request
  let statusCode = getResponseStatusCode response
      body = getResponseBody response
  if statusCode >= 200 && statusCode < 300
    then either fail return $ eitherDecode body
    else fail $ "OpenAI usage endpoint returned HTTP " <> show statusCode

newtype OpenAIUsageChanVar
  = OpenAIUsageChanVar
      ( TChan OpenAIUsageSnapshot,
        MVar OpenAIUsageSnapshot,
        TChan ()
      )

getOpenAIUsageChan :: OpenAIUsageConfig -> TaffyIO (TChan OpenAIUsageSnapshot)
getOpenAIUsageChan config = do
  OpenAIUsageChanVar (chan, _, _) <- setupOpenAIUsageChanVar config
  return chan

getOpenAIUsageState :: OpenAIUsageConfig -> TaffyIO OpenAIUsageSnapshot
getOpenAIUsageState config = do
  OpenAIUsageChanVar (_, var, _) <- setupOpenAIUsageChanVar config
  liftIO $ readMVar var

forceOpenAIUsageRefresh :: OpenAIUsageConfig -> TaffyIO ()
forceOpenAIUsageRefresh config = do
  OpenAIUsageChanVar (_, _, refreshChan) <- setupOpenAIUsageChanVar config
  liftIO $ atomically $ writeTChan refreshChan ()

setupOpenAIUsageChanVar :: OpenAIUsageConfig -> TaffyIO OpenAIUsageChanVar
setupOpenAIUsageChanVar config = getStateDefault $ do
  chan <- liftIO newBroadcastTChanIO
  refreshChan <- liftIO newTChanIO
  initial <- liftIO $ updateOpenAIUsage config
  var <- liftIO $ newMVar initial
  wakeupChan <- getWakeupChannelForDelay (openAIUsagePollInterval config)
  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan
  void $
    liftIO $
      forkIO $
        forever $ do
          atomically $
            void (readTChan refreshChan)
              `orElse` void (readTChan ourWakeupChan)
          refreshOpenAIUsageState config chan var
  return $ OpenAIUsageChanVar (chan, var, refreshChan)

refreshOpenAIUsageState ::
  OpenAIUsageConfig ->
  TChan OpenAIUsageSnapshot ->
  MVar OpenAIUsageSnapshot ->
  IO ()
refreshOpenAIUsageState config chan var = do
  snapshot <- updateOpenAIUsage config
  void $ swapMVar var snapshot
  atomically $ writeTChan chan snapshot

logName :: String
logName = "System.Taffybar.Information.OpenAIUsage"