intelli-monad-0.1.3.0: src/IntelliMonad/Types.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module IntelliMonad.Types where
import qualified Codec.Picture as P
import Control.Monad (when)
import Control.Monad.IO.Class
import Control.Monad.Trans.State (StateT)
import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode, Value)
import Data.IORef (IORef, newIORef, readIORef, writeIORef, modifyIORef')
import Data.Map (Map)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as A
import qualified Data.Aeson.KeyMap as A
import qualified Data.Aeson.KeyMap as HM
import qualified Data.Aeson.Text as A
import Data.List (nub)
import Data.ByteString (ByteString, fromStrict, toStrict)
import Data.Coerce
import Data.Kind (Type)
import qualified Data.Map as M
import Data.Proxy
import Data.Text (Text)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Time
import qualified Data.Vector as V
import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import GHC.Generics
import qualified Louter.Client as Louter
import qualified Louter.Types.Request as Louter
import qualified Louter.Types.Response as Louter
import qualified Louter.Types.Streaming as Louter
import IntelliMonad.Config (readConfig)
import qualified IntelliMonad.Config as Config
import qualified Data.ByteString as BS
import qualified Data.Text.IO as T
import System.Environment (lookupEnv)
import Data.Aeson.Encode.Pretty (encodePretty)
data User = User | System | Assistant | Tool deriving (Eq, Show, Ord, Generic)
instance ToJSON User
instance FromJSON User
userToText :: User -> Text
userToText = \case
User -> "user"
System -> "system"
Assistant -> "assistant"
Tool -> "tool"
textToUser :: Text -> User
textToUser = \case
"user" -> User
"system" -> System
"assistant" -> Assistant
"tool" -> Tool
v -> error $ T.unpack $ "Undefined role:" <> v
instance Show (P.Image P.PixelRGB8) where
show _ = "Image: ..."
data Message
= Message
{unText :: Text}
| Image
{ imageType :: Text,
imageData :: Text
}
| ToolCall
{ toolId :: Text,
toolName :: Text,
toolArguments :: Text
}
| ToolReturn
{ toolId :: Text,
toolName :: Text,
toolContent :: Text
}
deriving (Eq, Show, Ord, Generic)
data FinishReason
= Stop
| Length
| ToolCalls
| FunctionCall
| ContentFilter
| Null
deriving (Eq, Show)
finishReasonToText :: FinishReason -> Text
finishReasonToText = \case
Stop -> "stop"
Length -> "length"
ToolCalls -> "tool_calls"
FunctionCall -> "function_call"
ContentFilter -> "content_filter"
Null -> "null"
textToFinishReason :: Text -> FinishReason
textToFinishReason = \case
"stop" -> Stop
"length" -> Length
"tool_calls" -> ToolCalls
"function_call" -> FunctionCall
"content_filter" -> ContentFilter
"null" -> Null
_ -> Null
instance ToJSON Message
instance FromJSON Message
newtype Model = Model Text deriving (Eq, Show)
class HasFunctionObject r where
getFunctionName :: String
getFunctionDescription :: String
getFieldDescription :: String -> String
-- | Constructor schema for sum types
data ConstructorSchema = ConstructorSchema
{ csName :: Text -- ^ Constructor name
, csPayload :: Schema -- ^ Payload schema
, csIsNullary :: Bool -- ^ True for zero-field constructors
} deriving (Show, Eq)
data Schema
= Maybe' Schema
| String'
| Number'
| Integer'
| Object' [(String, String, Schema)]
| Array' Schema
| Boolean'
| Null'
-- Sum type schemas
| Enum' [Text] -- ^ String enum for nullary constructors
| OneOfUntagged [ConstructorSchema] -- ^ Untagged union (distinguishable shapes)
| OneOfTagged [ConstructorSchema] -- ^ Tagged union with @tag/@value
deriving (Show, Eq)
class GSchema s f where
gschema :: forall a. f a -> Schema
class JSONSchema r where
schema :: Schema
default schema :: (HasFunctionObject r, Generic r, GSchema r (Rep r)) => Schema
schema = gschema @r (from (undefined :: r))
class ChatCompletion b where
toRequest :: Louter.ChatRequest -> b -> Louter.ChatRequest
fromResponse :: Text -> Louter.ChatResponse -> (b, FinishReason)
toPV :: (ToJSON a) => a -> PersistValue
toPV = toPersistValue . toStrict . encode
fromPV :: (FromJSON a) => PersistValue -> Either Text a
fromPV json = do
json' <- fmap fromStrict $ fromPersistValue json
case eitherDecode json' of
Right v -> return v
Left err -> Left $ "Decoding JSON fails : " <> T.pack err
instance PersistField Louter.ChatRequest where
toPersistValue = toPV
fromPersistValue = fromPV
instance PersistFieldSql Louter.ChatRequest where
sqlType _ = sqlType (Proxy @ByteString)
instance PersistField Louter.ChatResponse where
toPersistValue = toPV
fromPersistValue = fromPV
instance PersistFieldSql Louter.ChatResponse where
sqlType _ = sqlType (Proxy @ByteString)
instance PersistField User where
toPersistValue = toPV
fromPersistValue = fromPV
instance PersistFieldSql User where
sqlType _ = sqlType (Proxy @ByteString)
instance PersistField Message where
toPersistValue = toPV
fromPersistValue = fromPV
instance PersistFieldSql Message where
sqlType _ = sqlType (Proxy @ByteString)
share
[mkPersist sqlSettings, mkMigrate "migrateAll"]
[persistLowerCase|
Content
user User
message Message
sessionName Text
created UTCTime default=CURRENT_TIME
deriving Show
deriving Eq
deriving Ord
deriving ToJSON
deriving FromJSON
deriving Generic
Context
request Louter.ChatRequest
response Louter.ChatResponse Maybe
header [Content]
body [Content]
footer [Content]
totalTokens Int
sessionName Text
created UTCTime default=CURRENT_TIME
deriving Show
deriving Eq
KeyValue
namespace Text
key Text
value Text
KeyName namespace key
deriving Show
deriving Eq
deriving Ord
|]
-- Manual Ord instance for Context
-- We compare only the fields that have Ord, ignoring request and response
instance Ord Context where
compare c1 c2 =
compare (contextHeader c1, contextBody c1, contextFooter c1,
contextTotalTokens c1, contextSessionName c1, contextCreated c1)
(contextHeader c2, contextBody c2, contextFooter c2,
contextTotalTokens c2, contextSessionName c2, contextCreated c2)
data ToolProxy = forall t. (Tool t, A.FromJSON t, A.ToJSON t, A.FromJSON (Output t), A.ToJSON (Output t), HasFunctionObject t, JSONSchema t) => ToolProxy (Proxy t)
class CustomInstruction a where
customHeader :: a -> Contents
customFooter :: a -> Contents
data CustomInstructionProxy = forall t. (CustomInstruction t) => CustomInstructionProxy t
class Hook a where
preHook :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => a -> Prompt m ()
postHook :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => a -> Prompt m ()
data HookProxy = forall t. (Hook t) => HookProxy t
data PersistProxy = forall t. (PersistentBackend t) => PersistProxy t
data PromptEnv = PromptEnv
{ tools :: [ToolProxy]
-- ^ The list of function calling
, customInstructions :: [CustomInstructionProxy]
-- ^ This system sends a prompt that includes headers, bodies and footers. Then the message that LLM outputs is added to bodies. customInstructions generates headers and footers.
, context :: Context
-- ^ The request settings like model and prompt logs
, backend :: PersistProxy
-- ^ The backend for prompt logging
, hooks :: [HookProxy]
-- ^ The hook functions before or after calling LLM
, timeoutSeconds :: Maybe Int
-- ^ The timeout in seconds to wait for results. Given to Louter.
}
type Contents = [Content]
type Prompt = StateT PromptEnv
-- data TypedPrompt tools task output =
type SessionName = Text
class Tool a where
data Output a :: Type
toolFunctionName :: Text
default toolFunctionName :: (HasFunctionObject a) => Text
toolFunctionName = T.pack $ getFunctionName @a
toolExec :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => a -> Prompt m (Output a)
toolHeader :: Contents
toolHeader = []
toolFooter :: Contents
toolFooter = []
toAeson :: Schema -> A.Value
toAeson = \case
Maybe' s -> toAeson s
String' -> A.Object [("type", "string")]
Number' -> A.Object [("type", "number")]
Integer' -> A.Object [("type", "integer")]
Object' properties ->
let notMaybes' :: [A.Value]
notMaybes' =
concat $
map
( \(name, desc, schema) ->
case schema of
Maybe' _ -> []
_ -> [A.String $ T.pack name]
)
properties
in A.Object
[ ("type", "object"),
( "properties",
A.Object $
A.fromList $
map
( \(name, desc, schema) ->
(A.fromString name, append (toAeson schema) (A.Object [("description", A.String $ T.pack desc)]))
)
properties
),
("required", A.Array (V.fromList notMaybes'))
]
Array' s ->
A.Object
[ ("type", "array"),
("items", toAeson s)
]
Boolean' -> A.Object [("type", "boolean")]
Null' -> A.Object [("type", "null")]
-- Sum type schemas
Enum' constructors ->
A.Object
[ ("type", "string"),
("enum", A.Array $ V.fromList $ map A.String constructors)
]
OneOfUntagged constructors ->
A.Object
[ ("oneOf", A.Array $ V.fromList $ map constructorToUntaggedSchema constructors)
]
OneOfTagged constructors ->
A.Object
[ ("oneOf", A.Array $ V.fromList $ map constructorToTaggedSchema constructors)
]
-- | Convert a constructor schema to an untagged JSON schema
constructorToUntaggedSchema :: ConstructorSchema -> A.Value
constructorToUntaggedSchema (ConstructorSchema name payload _) =
-- For untagged, just emit the payload schema
-- The constructor name is used for documentation but not in the schema itself
toAeson payload
-- | Convert a constructor schema to a tagged JSON schema with @tag/@value
constructorToTaggedSchema :: ConstructorSchema -> A.Value
constructorToTaggedSchema (ConstructorSchema name payload isNullary) =
if isNullary
then
-- Nullary constructor: {"@tag": "ConstructorName"}
A.Object
[ ("type", "object"),
("properties", A.Object
[ ("@tag", A.Object [("type", "string"), ("const", A.String name)])
]),
("required", A.Array $ V.fromList [A.String "@tag"]),
("additionalProperties", A.Bool False)
]
else
case payload of
Object' _ ->
-- Object payload: flat tagged format {"@tag": "Ctor", "field1": ..., "field2": ...}
case toAeson payload of
A.Object payloadObj ->
let tagProp = ("@tag", A.Object [("type", "string"), ("const", A.String name)])
-- Extract properties from payload
props = case HM.lookup "properties" payloadObj of
Just (A.Object p) -> p
_ -> HM.empty
-- Extract required fields
req = case HM.lookup "required" payloadObj of
Just (A.Array r) -> V.toList r
_ -> []
-- Merge @tag with payload properties
allProps = HM.insert "@tag" (A.Object [("type", "string"), ("const", A.String name)]) props
allRequired = A.String "@tag" : req
in A.Object
[ ("type", "object"),
("properties", A.Object allProps),
("required", A.Array $ V.fromList allRequired)
]
_ -> A.Null -- Shouldn't happen
_ ->
-- Non-object payload: nested format {"@tag": "Ctor", "@value": ...}
A.Object
[ ("type", "object"),
("properties", A.Object
[ ("@tag", A.Object [("type", "string"), ("const", A.String name)]),
("@value", toAeson payload)
]),
("required", A.Array $ V.fromList [A.String "@tag", A.String "@value"]),
("additionalProperties", A.Bool False)
]
instance Semigroup Schema where
(<>) (Object' a) (Object' b) = Object' (a <> b)
(<>) (Array' a) (Array' b) = Array' (a <> b)
(<>) _ _ = error "Can not concat json value."
append :: A.Value -> A.Value -> A.Value
append (A.Object a) (A.Object b) = A.Object (a <> b)
append (A.Array a) (A.Array b) = A.Array (a <> b)
append _ _ = error "Can not concat json value."
instance {-# OVERLAPS #-} JSONSchema String where
schema = String'
instance JSONSchema Text where
schema = String'
instance (JSONSchema a) => JSONSchema (Maybe a) where
schema = Maybe' (schema @a)
instance JSONSchema Integer where
schema = Integer'
instance JSONSchema Int where
schema = Integer'
instance JSONSchema Double where
schema = Number'
instance JSONSchema Bool where
schema = Boolean'
instance (JSONSchema a) => JSONSchema [a] where
schema = Array' (schema @a)
instance JSONSchema () where
schema = Null'
-- | Helper functions for sum type schema generation
-- | Internal wrapper to track constructor schemas during generic traversal
-- This allows us to distinguish between a single constructor and a sum type
data SchemaOrConstructors
= SingleSchema Schema -- ^ Not a sum type, just a regular schema
| Constructors [ConstructorSchema] -- ^ Sum type with multiple constructors
deriving (Show, Eq)
-- | Extract constructors from a schema, wrapping non-sum schemas as single-constructor lists
extractConstructors :: Schema -> [ConstructorSchema]
extractConstructors (Enum' names) = map (\n -> ConstructorSchema n Null' True) names
extractConstructors (OneOfUntagged cs) = cs
extractConstructors (OneOfTagged cs) = cs
extractConstructors other = [ConstructorSchema "" other (isNullarySchema other)]
-- | Normalize constructor name (e.g., "Red" -> "red")
normalizeConstructorName :: Text -> Text
normalizeConstructorName = T.toLower
-- | Check if a schema represents a nullary constructor
isNullarySchema :: Schema -> Bool
isNullarySchema Null' = True
isNullarySchema _ = False
-- | Check if all constructors are nullary (enum pattern)
isEnum :: [ConstructorSchema] -> Bool
isEnum = all csIsNullary
-- | Extract schema shape for distinguishability check
-- Two schemas are distinguishable if they have different shapes
schemaShape :: Schema -> Text
schemaShape String' = "string"
schemaShape Number' = "number"
schemaShape Integer' = "integer"
schemaShape Boolean' = "boolean"
schemaShape Null' = "null"
schemaShape (Array' _) = "array"
schemaShape (Object' fields) = "object:" <> T.intercalate "," (map (\(n,_,_) -> T.pack n) fields)
schemaShape (Maybe' s) = "maybe:" <> schemaShape s
schemaShape (Enum' _) = "enum"
schemaShape (OneOfUntagged _) = "oneof-untagged"
schemaShape (OneOfTagged _) = "oneof-tagged"
-- | Check if constructor shapes are mutually exclusive (distinguishable)
areShapesDistinguishable :: [ConstructorSchema] -> Bool
areShapesDistinguishable constructors =
let shapes = map (schemaShape . csPayload) constructors
uniqueShapes = nub shapes
in length shapes == length uniqueShapes
-- | Choose appropriate sum type encoding based on constructor analysis
chooseSumEncoding :: [ConstructorSchema] -> Schema
chooseSumEncoding constructors
| null constructors = Null' -- Shouldn't happen, but handle gracefully
| isEnum constructors = Enum' (map csName constructors)
| areShapesDistinguishable constructors = OneOfUntagged constructors
| otherwise = OneOfTagged constructors
instance (HasFunctionObject s) => GSchema s U1 where
gschema _ = Null'
instance (HasFunctionObject s, JSONSchema c) => GSchema s (K1 i c) where
gschema _ = schema @c
instance (HasFunctionObject s, GSchema s a, GSchema s b) => GSchema s (a :*: b) where
gschema _ = gschema @s @a undefined <> gschema @s @b undefined
-- | Sum type instance - collects all constructors from both branches
instance (HasFunctionObject s, GSchema s a, GSchema s b) => GSchema s (a :+: b) where
gschema _ =
let leftSchema = gschema @s @a undefined
rightSchema = gschema @s @b undefined
leftConstructors = extractConstructors leftSchema
rightConstructors = extractConstructors rightSchema
allConstructors = leftConstructors ++ rightConstructors
in chooseSumEncoding allConstructors
-- | Datatype - unwraps single-constructor types to their payload
instance (HasFunctionObject s, GSchema s f) => GSchema s (M1 D c f) where
gschema _ =
let innerSchema = gschema @s @f undefined
in case innerSchema of
-- Single constructor case: unwrap to just the payload
Enum' [_] -> Null' -- Single nullary constructor is just Null
OneOfUntagged [ConstructorSchema _ payload _] -> payload
-- Multiple constructors: keep as-is
_ -> innerSchema
-- | Constructor Metadata - captures constructor name and wraps payload
instance (HasFunctionObject s, GSchema s f, Constructor c) => GSchema s (M1 C c f) where
gschema proxy =
let name = T.pack $ conName (undefined :: M1 C c f p)
normalizedName = normalizeConstructorName name
payload = gschema @s @f undefined
isNullary = isNullarySchema payload
-- Return a single-constructor "sum type" that will be collected by :+:
-- or used directly if there's only one constructor
in case payload of
Null' -> Enum' [normalizedName] -- Nullary constructor
_ -> OneOfUntagged [ConstructorSchema normalizedName payload isNullary]
-- | Selector Metadata
instance (HasFunctionObject s, GSchema s f, Selector c) => GSchema s (M1 S c f) where
gschema a =
let name = selName a
desc = getFieldDescription @s name
in Object' [(name, desc, (gschema @s @f undefined))]
toolAdd :: forall a. (Tool a, HasFunctionObject a, JSONSchema a) => Louter.ChatRequest -> Louter.ChatRequest
toolAdd req =
let prevTools = case toTools req of
[] -> []
v -> v
newTools = prevTools ++ [newTool @a Proxy]
in fromTools req newTools
defaultUTCTime :: UTCTime
defaultUTCTime = UTCTime (coerce (0 :: Integer)) 0
data ReplCommand
= Quit
| Clear
| ShowContents
| ShowUsage
| ShowRequest
| ShowContext
| ShowSession
| Edit
| EditRequest
| EditContents
| EditHeader
| EditFooter
| ListSessions
| SetModel Text
| SetTimeout Int
| CopySession
{ sessionNameFrom :: Text,
sessionNameTo :: Text
}
| DeleteSession
{ sessionName :: Text
}
| SwitchSession
{ sessionName :: Text
}
| ReadImage Text
| UserInput Text
| Help
| Repl
{ sessionName :: Text
}
| ListKeys
| GetKey
{ nameSpace :: Maybe Text,
keyName :: Text
}
| SetKey
{ nameSpace :: Maybe Text,
keyName :: Text,
value :: Text
}
| DeleteKey
{ nameSpace :: Maybe Text,
keyName :: Text
}
deriving (Eq, Show)
class PersistentBackend p where
type Conn p
config :: p
setup :: (MonadIO m, MonadFail m) => p -> m (Maybe (Conn p))
initialize :: (MonadIO m, MonadFail m) => Conn p -> Context -> m ()
load :: (MonadIO m, MonadFail m) => Conn p -> SessionName -> m (Maybe Context)
loadByKey :: (MonadIO m, MonadFail m) => Conn p -> (Key Context) -> m (Maybe Context)
save :: (MonadIO m, MonadFail m) => Conn p -> Context -> m (Maybe (Key Context))
saveContents :: (MonadIO m, MonadFail m) => Conn p -> [Content] -> m ()
listSessions :: (MonadIO m, MonadFail m) => Conn p -> m [Text]
deleteSession :: (MonadIO m, MonadFail m) => Conn p -> SessionName -> m ()
listKeys :: (MonadIO m, MonadFail m) => Conn p -> m [Unique KeyValue]
getKey :: (MonadIO m, MonadFail m) => Conn p -> Unique KeyValue -> m (Maybe Text)
setKey :: (MonadIO m, MonadFail m) => Conn p -> Unique KeyValue -> Text -> m ()
deleteKey :: (MonadIO m, MonadFail m) => Conn p -> Unique KeyValue -> m ()
-- FIXME: use mempty here, instead of providing a model name.
defaultRequest :: Louter.ChatRequest
defaultRequest =
Louter.ChatRequest
{ Louter.reqModel = ""
, Louter.reqMessages = []
, Louter.reqTools = []
, Louter.reqToolChoice = Louter.ToolChoiceAuto
, Louter.reqTemperature = Nothing
, Louter.reqMaxTokens = Nothing
, Louter.reqStream = False
}
newTool :: forall a. (HasFunctionObject a, JSONSchema a) => Proxy a -> Louter.Tool
newTool (Proxy :: Proxy a) =
Louter.Tool
{ Louter.toolName = T.pack $ getFunctionName @a
, Louter.toolDescription = Just (T.pack $ getFunctionDescription @a)
, Louter.toolParameters = toAeson (schema @a)
}
toTools :: Louter.ChatRequest -> [Louter.Tool]
toTools req = Louter.reqTools req
fromTools :: Louter.ChatRequest -> [Louter.Tool] -> Louter.ChatRequest
fromTools req tools = req { Louter.reqTools = tools }
fromModel_ :: Text -> Louter.ChatRequest
fromModel_ model =
(defaultRequest :: Louter.ChatRequest)
{ Louter.reqModel = model
}
-- | Read the JSON object and convert it to a Map
toMap :: Text -> Map Text Value
toMap json =
case A.decodeStrictText json of
Just v -> v
Nothing -> error $ T.unpack $ "Decoding JSON fails"
fromMap :: Map Text Value -> Text
fromMap txt = TL.toStrict $ A.encodeToLazyText txt
instance ChatCompletion Contents where
toRequest orgRequest contents =
let messages = flip map contents $ \case
Content user (Message message) _ _ ->
Louter.Message
{ Louter.msgRole = case user of
User -> Louter.RoleUser
System -> Louter.RoleSystem
Assistant -> Louter.RoleAssistant
Tool -> Louter.RoleTool
, Louter.msgContent = [Louter.TextPart message]
}
Content user (Image type' img) _ _ ->
Louter.Message
{ Louter.msgRole = case user of
User -> Louter.RoleUser
System -> Louter.RoleSystem
Assistant -> Louter.RoleAssistant
Tool -> Louter.RoleTool
, Louter.msgContent = [Louter.ImagePart type' img]
}
Content user (ToolCall id' name' args') _ _ ->
-- Tool calls need to be handled differently - for now, convert to text
Louter.Message
{ Louter.msgRole = Louter.RoleAssistant
, Louter.msgContent = [Louter.TextPart $ "Tool call: " <> name' <> " with args: " <> args']
}
Content user (ToolReturn id' name' ret') _ _ ->
Louter.Message
{ Louter.msgRole = Louter.RoleTool
, Louter.msgContent = [Louter.TextPart ret']
}
in orgRequest { Louter.reqMessages = messages }
fromResponse sessionName response =
let choice = head (Louter.respChoices response)
message = Louter.choiceMessage choice
toolCalls = Louter.choiceToolCalls choice
finishReason = case Louter.choiceFinishReason choice of
Just Louter.FinishStop -> Stop
Just Louter.FinishLength -> Length
Just Louter.FinishToolCalls -> ToolCalls
Just Louter.FinishContentFilter -> ContentFilter
Nothing -> Null
-- If there are tool calls, convert them to Content
contents = if null toolCalls
then [Content Assistant (Message message) sessionName defaultUTCTime]
else map (\tc -> Content Assistant
(ToolCall (Louter.rtcId tc)
(Louter.functionName $ Louter.rtcFunction tc)
(Louter.functionArguments $ Louter.rtcFunction tc))
sessionName
defaultUTCTime) toolCalls
in (contents, finishReason)
updateRequest :: Louter.ChatRequest -> Contents -> Louter.ChatRequest
updateRequest = toRequest
addTools :: [ToolProxy] -> Louter.ChatRequest -> Louter.ChatRequest
addTools [] req' = req'
addTools (tool : tools') req' =
case tool of
(ToolProxy (_ :: Proxy a)) ->
addTools tools' (toolAdd @a req')
fromModel :: Text -> Louter.ChatRequest
fromModel = fromModel_
runRequest :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> Maybe Int -> a -> IO ((a, FinishReason), Louter.ChatResponse)
runRequest sessionName defaultReq timeout request = do
config <- readConfig
-- Determine backend type (default to OpenAI if not specified)
let backendType = case Config.backend config of
Just bt -> bt
Nothing -> Config.OpenAI
let louterBackend = case backendType of
Config.OpenAI -> Louter.BackendOpenAI
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
Config.Anthropic -> Louter.BackendAnthropic
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
Config.Gemini -> Louter.BackendGemini
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
client <- Louter.newClientWithTimeout timeout louterBackend
let req = toRequest defaultReq request
openai_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_DEBUG"
when openai_debug $ do
liftIO $ do
T.putStrLn "========== Request ==========="
BS.putStr $ BS.toStrict $ encodePretty req
T.putStrLn ""
openai_http_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_HTTP_DEBUG"
when openai_http_debug $ do
liftIO $ do
T.putStrLn "========== Backend ==========="
print louterBackend
T.putStrLn "========== Request ==========="
BS.putStr $ BS.toStrict $ encodePretty req
T.putStrLn ""
result <- Louter.chatCompletion client req
case result of
Left err -> error $ T.unpack $ "Louter error: " <> err
Right res -> do
when openai_http_debug $ do
T.putStrLn "========== Response =========="
BS.putStr $ BS.toStrict $ encodePretty res
T.putStrLn ""
return $ (fromResponse sessionName res, res)
runRequestStreaming :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> Maybe Int -> a -> (Text -> IO ()) -> IO ((a, FinishReason), Louter.ChatResponse)
runRequestStreaming sessionName defaultReq timeout request contentCallback = do
config <- readConfig
-- Determine backend type (default to OpenAI if not specified)
let backendType = case Config.backend config of
Just bt -> bt
Nothing -> Config.OpenAI
let louterBackend = case backendType of
Config.OpenAI -> Louter.BackendOpenAI
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
Config.Anthropic -> Louter.BackendAnthropic
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
Config.Gemini -> Louter.BackendGemini
{ Louter.backendApiKey = Config.apiKey config
, Louter.backendBaseUrl = Just (Config.endpoint config)
, Louter.backendRequiresAuth = not (T.null (Config.apiKey config))
}
client <- Louter.newClientWithTimeout timeout louterBackend
let req = toRequest defaultReq request
openai_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_DEBUG"
when openai_debug $ do
liftIO $ do
T.putStrLn "========== Request ==========="
BS.putStr $ BS.toStrict $ encodePretty req
T.putStrLn ""
openai_http_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_HTTP_DEBUG"
when openai_http_debug $ do
liftIO $ do
T.putStrLn "========== Backend ==========="
print louterBackend
T.putStrLn "========== Request ==========="
BS.putStr $ BS.toStrict $ encodePretty req
T.putStrLn ""
-- Accumulate response while streaming
contentRef <- newIORef []
finishReasonRef <- newIORef "stop"
-- Stream with callback
Louter.streamChatWithCallback client req $ \event -> case event of
Louter.StreamContent txt -> do
contentCallback txt -- Call user callback for incremental output
modifyIORef' contentRef (++ [txt])
Louter.StreamFinish reason -> do
writeIORef finishReasonRef reason
Louter.StreamError err -> do
error $ T.unpack $ "Louter streaming error: " <> err
_ -> pure () -- Ignore reasoning and tool calls for now
-- Build response from accumulated content
fullContent <- T.concat <$> readIORef contentRef
finishReasonText <- readIORef finishReasonRef
-- Convert text finish reason to FinishReason type
let finishReason = case finishReasonText of
"stop" -> Louter.FinishStop
"length" -> Louter.FinishLength
"tool_calls" -> Louter.FinishToolCalls
"content_filter" -> Louter.FinishContentFilter
_ -> Louter.FinishStop
-- Create a minimal ChatResponse for compatibility
let response = Louter.ChatResponse
{ Louter.respId = "stream-1"
, Louter.respModel = Louter.reqModel req
, Louter.respChoices =
[ Louter.Choice
{ Louter.choiceIndex = 0
, Louter.choiceMessage = fullContent
, Louter.choiceToolCalls = []
, Louter.choiceFinishReason = Just finishReason
}
]
, Louter.respUsage = Nothing
}
when openai_http_debug $ do
T.putStrLn "==== Response (Accumulated) ===="
BS.putStr $ BS.toStrict $ encodePretty response
T.putStrLn ""
return $ (fromResponse sessionName response, response)