packages feed

genai-lib (empty) → 1.3

raw patch · 14 files changed

+720/−0 lines, 14 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, formatting, genai-lib, heredoc, hslogger, http-client, optparse-applicative, prettyprinter, servant, servant-client, servant-client-core, string-conv, text

Files

+ .gitignore view
@@ -0,0 +1,8 @@+# stack uses this directory for build artifacts+/.stack-work/++# cabal stuff+dist-newstyle++# Made by 'hasktags --ctags .'+tags
+ CHANGELOG.md view
@@ -0,0 +1,3 @@+1.3 (2025-03-27)++  * Initial release
+ LICENSE view
@@ -0,0 +1,15 @@+Copyright (c) 2024, Dino Morelli <dino@ui3.info>++Permission to use, copy, modify, and/or distribute this software+for any purpose with or without fee is hereby granted, provided+that the above copyright notice and this permission notice appear+in all copies.++THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL+WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE+AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL+DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA+OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR+PERFORMANCE OF THIS SOFTWARE.
+ README.md view
@@ -0,0 +1,25 @@+# genai-lib+++## Synopsis++Library and command line tool for interacting with various generative AI LLMs+++## Description++Library and command line tool for performing completions and chats with various+generative AI LLMs (Large Language Models). Works today with Ollama and some+work has been done to support OpenAI in the future.++This project is very much in-progress and incomplete.+++## Getting source++Source code is available from codeberg at the [genai-lib](https://codeberg.org/dinofp/genai-lib.git) project page.+++## Contact++Dino Morelli <dino@ui3.info>
+ genai-lib.cabal view
@@ -0,0 +1,100 @@+cabal-version: 2.2++name: genai-lib+version: 1.3+synopsis: Library and command line tool for interacting with various generative+  AI LLMs+description: Library and command line tool for performing completions and chats+  with various generative AI LLMs (Large Language Models).+author: Dino Morelli+maintainer: dino@ui3.info+copyright: 2024 Dino Morelli+category: Unclassified+license: ISC+license-file: LICENSE+build-type: Simple+extra-source-files:+  .gitignore+  README.md+  stack.yaml+  stack.yaml.lock+extra-doc-files:+  CHANGELOG.md++source-repository head+  type: git+  location: https://codeberg.org/dinofp/genai-lib++common lang+  default-language: Haskell2010+  default-extensions:+    BangPatterns+    DeriveFoldable+    DeriveFunctor+    DeriveGeneric+    DeriveLift+    DeriveTraversable+    EmptyCase+    FlexibleContexts+    FlexibleInstances+    GeneralizedNewtypeDeriving+    ImportQualifiedPost+    InstanceSigs+    KindSignatures+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    NamedFieldPuns+    NumericUnderscores+    OverloadedStrings+    ScopedTypeVariables+    StandaloneDeriving+    TupleSections+  ghc-options:+    -fwarn-tabs+    -Wall+    -Wcompat+    -Wincomplete-record-updates+    -Wincomplete-uni-patterns+    -Wpartial-fields+    -Wredundant-constraints+  build-depends:+      aeson >= 2.1.2 && < 2.2+    , base >= 3 && < 5+    , formatting >= 7.2.0 && < 7.3+    , text >= 1.2.5 && < 3++library+  import: lang+  exposed-modules:+    GenAILib.Common+    GenAILib.HTTP+    GenAILib.Ollama+    GenAILib.System.Log+  hs-source-dirs:+    src/lib+  build-depends:+      bytestring >= 0.11.4 && < 0.12+    , hslogger >= 1.3.1 && < 1.4+    , http-client >= 0.7.16 && < 0.8+    , servant >= 0.20.1 && < 0.21+    , servant-client >= 0.20 && < 0.21+    , servant-client-core >= 0.20 && < 0.21+    , string-conv >= 0.2.0 && < 0.3++executable genai+  import: lang+  main-is: Main.hs+  hs-source-dirs:+    src/app+  other-modules:+    GenAI.Common+    GenAI.Opts+    Paths_genai_lib+  autogen-modules:+    Paths_genai_lib+  build-depends:+      heredoc >= 0.2.0 && < 0.3+    , genai-lib+    , optparse-applicative >= 0.17.1 && < 0.19+    , prettyprinter >= 1.7.1 && < 1.8
+ src/app/GenAI/Common.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE OverloadedRecordDot #-}++module GenAI.Common+  where++import Data.Aeson (Value (Object))+import Data.Aeson.Types (emptyObject)+import Data.Text.Lazy qualified as TL+import GenAILib.Common (LLMOptions (..), Model, Prompt (..), RawOutput)+import GenAILib.Ollama (Host, OllamaRequest (..), Stream, System)+import GenAILib.System.Log (Priority (DEBUG))+++data CLIOptions = CLIOptions+  { host :: Host+  , system :: Maybe System+  , llmOptions :: LLMOptions+  , stream :: Stream+  , rawOutput :: RawOutput+  , verbose :: Verbose+  , model :: Model+  }+++newtype Verbose = Verbose Bool+  deriving Show+++verbosityToPriority :: Verbose -> Maybe Priority+verbosityToPriority (Verbose True) = Just DEBUG+verbosityToPriority (Verbose False) = Nothing+++mkLLMRequest :: CLIOptions -> TL.Text -> OllamaRequest+mkLLMRequest opts promptText = OllamaRequest opts.model opts.system+  (Prompt promptText) opts.stream (wrapMaybe opts.llmOptions.v)+  where+    wrapMaybe o@(Object _) = if o == emptyObject then Nothing else Just o+    wrapMaybe _ = Nothing
+ src/app/GenAI/Opts.hs view
@@ -0,0 +1,154 @@+{-# LANGUAGE DuplicateRecordFields, OverloadedRecordDot, QuasiQuotes #-}++module GenAI.Opts+  ( parseOpts+  )+  where++import Data.Text.Lazy (pack)+import Data.Version (showVersion)+import Formatting ((%+), format, formatToString, string)+import Formatting.ShortFormatters (s)+import GenAILib.Common (Model (..), RawOutput (..), convertOptions)+import GenAILib.Ollama (Host (..), Stream (..),+  System (..), defaultHost, hostFromString)+import Options.Applicative+import Paths_genai_lib (version)+import Prettyprinter (pretty)+import System.Environment (getProgName)+import Text.Heredoc (here)++import GenAI.Common (CLIOptions (..), Verbose (..))+++readHost :: ReadM Host+readHost = eitherReader (\hostPortStr ->+  maybe+    (Left $ formatToString+      ("Unable to parse" %+ string %+ "into host and port parts") hostPortStr)+    Right+    $ hostFromString hostPortStr+  )+++{- HLINT ignore "Functor law" -}+parser :: Parser CLIOptions+parser = CLIOptions+  <$> option readHost+        (  long "host"+        <> short 'H'+        <> metavar "HOST:PORT"+        <> help "Host and port where ollama serve is running"+        <> showDefault+        <> value defaultHost+        )+  -- NOTE: The inner <$> is for Maybe, the outer <$> is for Parser+  <*> ( (System . pack <$>) <$> optional ( strOption+        (  long "system"+        <> short 's'+        <> metavar "STR"+        <> help "System parameter"+        )+      ))+  <*> ( convertOptions <$> many ( strOption+        (  long "option"+        <> short 'o'+        <> metavar "KEY:VALUE"+        <> help "Options for the LLM, repeat for each option pair. See LLM OPTIONS below"+        )+      ))+  <*> ( Stream <$> switch+        (  long "stream"+        <> help "Response will be returned as a stream of objects"+        )+      )+  <*> ( RawOutput <$> switch+        (  long "raw-output"+        <> short 'r'+        <> help "Output the entire JSON response from the LLM"+        )+      )+  <*> ( Verbose <$> switch+        (  long "verbose"+        <> short 'v'+        <> help "Enable verbose output. Note: All logging goes to stderr"+        )+      )+  <*> ( Model . pack <$> argument str+        (  metavar "MODEL_ID"+        <> help "Model identifier, see available models with `ollama list`"+        )+      )+++versionHelper :: String -> Parser (a -> a)+versionHelper progName =+  infoOption (formatToString (s %+ s) progName (showVersion version)) $ mconcat+  [ long "version"+  , help "Show version information"+  , hidden+  ]+++parseOpts :: IO CLIOptions+parseOpts = do+  pn <- getProgName+  execParser $ info (parser <**> helper <**> versionHelper pn)+    (  header (formatToString (s %+ "- Command line tool for interacting with an ollama server") pn)+    <> footer'+    )+++footer' :: InfoMod a+footer' = footerDoc . Just . pretty . format content . showVersion $ version+  where content = [here|OVERVIEW++This software expects the prompt string on STDIN and will respond with the LLM+response on STDOUT++example usage++    $ echo "Why is the sky blue?" | genai some-fancy-model++WHY WAS THIS DONE?++ollama ships with command-line ability to submit prompts and do completions:++    $ ollama MODEL [PROMPT] ...++but as far as I can see there's no easy way to use this method to change+options like the temperature and seed. This software allows greater control+over the options.++    $ echo "Tell me a story" | genai some-other-model -o temperature:0.2++LLM OPTIONS++Some settings are communicated to the LLM as a map of options. Use them like this++    -o temperature:0.2++Some useful options++    option name   description                                                   default+    -------------------------------------------------------------------------------------+    seed          Sets the random number seed to use for generation. Setting    0+                  this to a specific number will make the model generate the+                  same text for the same prompt.+    temperature   The temperature of the model. Increasing the temperature      0.8+                  will make the model answer more creatively.+                  Range 0.0 to 1.0+    top_k         Reduces the probability of generating nonsense. A higher      40+                  value (e.g. 100) will give more diverse answers, while a+                  lower value (e.g. 10) will be more conservative.+                  Range 0 to 100+    top_p         Works together with top-k. A higher value (e.g., 0.95) will   0.9+                  lead to more diverse text, while a lower value (e.g., 0.5)+                  will generate more focused and conservative text.+                  Range unknown, guessing 0.0 to 1.0++For the complete list of LLM options, see++https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values++Version|] %+ s %+ " Dino Morelli <dino@ui3.info>"
+ src/app/Main.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE OverloadedRecordDot #-}++import Data.Text.Lazy.IO qualified as TL+import GenAILib.HTTP (display, doCompletion)+import GenAILib.System.Log (infoM, initLogging, lname)+import System.IO (stdin)++import GenAI.Common (CLIOptions (host, rawOutput, verbose), mkLLMRequest,+  verbosityToPriority)+import GenAI.Opts (parseOpts)+++main :: IO ()+main = do+  opts <- parseOpts+  initLogging . verbosityToPriority . verbose $ opts+  infoM lname "Logging configured"+  infoM lname "Args parsed"++  promptText <- TL.hGetContents stdin+  let or' = mkLLMRequest opts promptText+  doCompletion opts.host or' >>= display opts.rawOutput
+ src/lib/GenAILib/Common.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DuplicateRecordFields #-}++module GenAILib.Common+  where++import Control.Arrow ((&&&))+import Control.Monad (when)+import Data.Aeson (Key, ToJSON, Value (Number, String), object, toJSON)+import Data.Aeson.Key (fromString)+import Data.Aeson.Types (Pair)+import Data.Maybe (mapMaybe)+import Data.String.Conv (toS)+import Data.Text.Lazy qualified as TL+import GHC.Generics (Generic)+++newtype Model = Model TL.Text+  deriving Generic++instance ToJSON Model+++newtype Prompt = Prompt TL.Text+  deriving Generic++instance ToJSON Prompt+++splitAtColon :: String -> Maybe (String, String)+splitAtColon combinedStr = do+  let (leftSide, rightSide) = takeWhile (/= ':') &&& dropWhile (/= ':') $ combinedStr+  when (null rightSide) Nothing+  pure (leftSide, tail rightSide)+++newtype RawOutput = RawOutput Bool+++newtype LLMOptions = LLMOptions { v :: Value }+  deriving Generic++instance ToJSON LLMOptions+++-- For debugging the LLM options parsing, unused most of the time+debugOptsJSON :: LLMOptions -> Value+debugOptsJSON = toJSON++convertOptions :: [String] -> LLMOptions+convertOptions = LLMOptions . object . pairs'++pairs' :: [String] -> [Pair]+pairs' = map convertTypes . mapMaybe splitAtColon++convertTypes :: (String, String) -> (Key, Value)+-- Turns out the only non-number option we're interested in for Ollama is "stop"+convertTypes (keystr@"stop", valstr) = (fromString keystr, String . toS $ valstr)+convertTypes (keystr, valstr) = (fromString keystr, Number . read $ valstr)
+ src/lib/GenAILib/HTTP.hs view
@@ -0,0 +1,97 @@+{-# LANGUAGE DataKinds, TypeOperators #-}++module GenAILib.HTTP+  ( display+  , doChat+  , doCompletion+  )+  where++import Data.Aeson (FromJSON, ToJSON, Value (Object, String), decode, encode)+import Data.Aeson.KeyMap qualified as A+import Data.ByteString.Lazy qualified as BL+import Data.Maybe (fromMaybe)+import Data.Proxy (Proxy (..))+import Data.String.Conv (toS)+import Data.Text.IO qualified as TS+import Formatting ((%+), formatToString, string)+import Network.HTTP.Client (defaultManagerSettings, managerResponseTimeout,+  newManager, responseTimeoutMicro)+import Servant.API ((:<|>) (..), (:>), JSON, Post, ReqBody)+import Servant.Client (BaseUrl (..), Scheme (Http), ClientM,+  mkClientEnv, runClientM)+import Servant.Client.Core (clientIn)+import System.Exit (exitFailure)++import GenAILib.Common (RawOutput (..))+import GenAILib.Ollama (Host (..))+import GenAILib.System.Log (debugM, emergencyM, errorM, lname)+++defaultHttpTimeout :: Int+defaultHttpTimeout = 1_200_000_000  -- In microseconds, this is 2 minutes+++{- NOTE: The reason we've chosen Aeson's Value type for the response is we want+   to do one of these things with these responses:++   1. Display only the LLM response text (in the JSON Value's 'response' or 'messages.content' field)+   2. Pretty-print the entire JSON response ( | jq . )+   3. Dump the entire JSON response as-is ( | jq -r|--raw-output . )++   We don't need to model the entire Ollama response JSON document to achieve these goals.+-}+type API+  =    "api" :> "generate" :> ReqBody '[JSON] Value :> Post '[JSON] Value+  :<|> "api" :> "chat" :> ReqBody '[JSON] Value :> Post '[JSON] Value++-- A Proxy to our API+api :: Proxy API+api = Proxy++clientM :: Proxy ClientM+clientM = Proxy++completion :: Value -> ClientM Value+chat :: Value -> ClientM Value+(completion :<|> chat) = api `clientIn` clientM+++doCompletion :: ToJSON j => Host -> j -> IO Value+doCompletion = doRequest completion++doChat :: ToJSON j => Host -> j -> IO Value+doChat = doRequest chat++doRequest :: (FromJSON f, ToJSON t) => (f -> ClientM Value) -> Host -> t -> IO Value+doRequest client' (Host hostName port) bodyData = do+  debugM lname . toS . encode $ bodyData+  let+    bodyDoc = fromMaybe (error "Something went wrong with JSON-ifying the POST data")+      . decode . encode $ bodyData+  manager' <- newManager $ defaultManagerSettings+    { managerResponseTimeout = responseTimeoutMicro defaultHttpTimeout }+  eres <- runClientM (client' bodyDoc)+    (mkClientEnv manager' (BaseUrl Http (toS hostName) port ""))+  case eres of+    Left err -> logAndExit $ show err+    Right v@(Object _) -> pure v+    Right somethingElse -> logAndExit $ show somethingElse+++logAndExit :: String -> IO a+logAndExit msg = do+  errorM lname $ formatToString ("Can't continue:" %+ string) msg+  exitFailure+++display :: RawOutput -> Value -> IO ()++display (RawOutput True) v = BL.putStr $ encode v <> "\n"++display (RawOutput False) (Object keyMap) = case A.lookup "response" keyMap of+  Just (String t) -> TS.putStrLn t+  Just somethingElse -> emergencyM lname $ show somethingElse+  Nothing -> emergencyM lname $ show keyMap++display _ somethingElse = emergencyM lname $ show somethingElse
+ src/lib/GenAILib/Ollama.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE DuplicateRecordFields #-}++module GenAILib.Ollama+  ( Host (..)+  , OllamaRequest (..)+  , Stream (..)+  , System (..)+  , defaultHost+  , hostFromString+  )+  where++import Data.Aeson (ToJSON, Value, defaultOptions, genericToEncoding,+  genericToJSON, omitNothingFields, toEncoding, toJSON)+import Data.Aeson qualified as Aeson+import Data.String.Conv (toS)+import Data.Text.Lazy qualified as TL+import Formatting ((%), formatToString, int, text)+import GHC.Generics (Generic)+import Text.Read (readMaybe)++import GenAILib.Common (Model, Prompt, splitAtColon)+++newtype Stream = Stream Bool+  deriving Generic++instance ToJSON Stream+++{- ^ This data structure is used to construct the POST body for an Ollama REST call+-}+data OllamaRequest = OllamaRequest+  { model :: Model+  , system :: Maybe System+  , prompt :: Prompt+  , stream :: Stream+  , options :: Maybe Value+  }+  deriving Generic++customOptions :: Aeson.Options+customOptions = defaultOptions { omitNothingFields = True }++instance ToJSON OllamaRequest where+  toJSON     = genericToJSON customOptions+  toEncoding = genericToEncoding customOptions+++data Host = Host TL.Text Int++-- I don't normally do this because it harms debugging when we want to see the+-- actual Haskell data structure serialized BUT this plays nicer with+-- optparse-applicative's showDefault+instance Show Host where+  show (Host hostName port) = formatToString (text % ":" % int) hostName port+++defaultHost :: Host+defaultHost = Host "localhost" 11434+++hostFromString :: String -> Maybe Host+hostFromString combinedStr = do+  (hostName, portStr) <- splitAtColon combinedStr+  Host (toS hostName) <$> readMaybe portStr+++newtype System = System TL.Text+  deriving Generic++instance ToJSON System
+ src/lib/GenAILib/System/Log.hs view
@@ -0,0 +1,47 @@+module GenAILib.System.Log+  ( initLogging, lname+  , logTest++  -- Re-exported from System.Log.Logger+  , Priority (..), debugM, infoM, noticeM, warningM, errorM+  , criticalM, alertM, emergencyM+  )+  where++import Data.Maybe (fromMaybe)+import System.IO (stderr)+import System.Log.Formatter (simpleLogFormatter)+import System.Log.Handler (setFormatter)+import System.Log.Handler.Simple (streamHandler)+import System.Log.Logger+++lname :: String+lname = "normal-output"+++initLogging :: Maybe Priority -> IO ()+initLogging mLogPriority = do+  let logPriority = fromMaybe NOTICE mLogPriority++  -- Remove the root logger's default handler that writes every+  -- message to stderr!+  updateGlobalLogger rootLoggerName removeHandler++  errH <- flip setFormatter (simpleLogFormatter "[$time : $prio] $msg")+    <$> streamHandler stderr DEBUG+  updateGlobalLogger lname $ setHandlers [errH]+  updateGlobalLogger lname $ setLevel logPriority+++-- Test function to generate every kind of log message+logTest :: IO ()+logTest = do+  debugM     lname "log test message DEBUG 1 of 8"+  infoM      lname "log test message INFO 2 of 8"+  noticeM    lname "log test message NOTICE 3 of 8"+  warningM   lname "log test message WARNING 4 of 8"+  errorM     lname "log test message ERROR 5 of 8"+  criticalM  lname "log test message CRITICAL 6 of 8"+  alertM     lname "log test message ALERT 7 of 8"+  emergencyM lname "log test message EMERGENCY 8 of 8"
+ stack.yaml view
@@ -0,0 +1,68 @@+# This file was automatically generated by 'stack init'+#+# Some commonly used options have been documented as comments in this file.+# For advanced use and comprehensive documentation of the format, please see:+# https://docs.haskellstack.org/en/stable/yaml_configuration/++# A 'specific' Stackage snapshot or a compiler version.+# A snapshot resolver dictates the compiler version and the set of packages+# to be used for project dependencies. For example:+#+# snapshot: lts-22.28+# snapshot: nightly-2024-07-05+# snapshot: ghc-9.6.6+#+# The location of a snapshot can be provided as a file or url. Stack assumes+# a snapshot provided as a file might change, whereas a url resource does not.+#+# snapshot: ./custom-snapshot.yaml+# snapshot: https://example.com/snapshots/2024-01-01.yaml+# snapshot:+#   url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/34.yaml+snapshot: lts-22.6++# User packages to be built.+# Various formats can be used as shown in the example below.+#+# packages:+# - some-directory+# - https://example.com/foo/bar/baz-0.0.2.tar.gz+#   subdirs:+#   - auto-update+#   - wai+packages:+- .+# Dependency packages to be pulled from upstream that are not in the snapshot.+# These entries can reference officially published versions as well as+# forks / in-progress versions pinned to a git hash. For example:+#+# extra-deps:+# - acme-missiles-0.3+# - git: https://github.com/commercialhaskell/stack.git+#   commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a+#+# extra-deps: []++# Override default flag values for project packages and extra-deps+# flags: {}++# Extra package databases containing global packages+# extra-package-dbs: []++# Control whether we use the GHC we find on the path+# system-ghc: true+#+# Require a specific version of Stack, using version ranges+# require-stack-version: -any # Default+# require-stack-version: ">=3.1"+#+# Override the architecture used by Stack, especially useful on Windows+# arch: i386+# arch: x86_64+#+# Extra directories used by Stack for building+# extra-include-dirs: [/path/to/dir]+# extra-lib-dirs: [/path/to/dir]+#+# Allow a newer minor version of GHC than the snapshot specifies+# compiler-check: newer-minor
+ stack.yaml.lock view
@@ -0,0 +1,12 @@+# This file was autogenerated by Stack.+# You should not edit this file by hand.+# For more information, please see the documentation at:+#   https://docs.haskellstack.org/en/stable/lock_files++packages: []+snapshots:+- completed:+    sha256: 1b4c2669e26fa828451830ed4725e4d406acc25a1fa24fcc039465dd13d7a575+    size: 714100+    url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/6.yaml+  original: lts-22.6