ihp-openai-1.3.0: IHP/OpenAI.hs
module IHP.OpenAI where
import Data.Text (Text)
import Data.ByteString (ByteString)
import Control.Exception (SomeException)
import Data.IORef
import qualified System.IO.Streams as Streams
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as ByteString
import Network.Http.Client
import Data.Aeson
import OpenSSL
import qualified OpenSSL.Session as SSL
import qualified Data.Text as Text
import qualified Control.Retry as Retry
import qualified Control.Exception as Exception
import Control.Applicative ((<|>))
import qualified Data.Aeson.Key as Key
import qualified Data.Maybe as Maybe
data CompletionRequest = CompletionRequest
{ messages :: ![Message]
, model :: !Text
, maxTokens :: !(Maybe Int)
, temperature :: !(Maybe Double)
, presencePenalty :: !(Maybe Double)
, frequencePenalty :: !(Maybe Double)
, stream :: !Bool
, responseFormat :: !(Maybe ResponseFormat)
, tools :: ![Tool]
} deriving (Eq, Show)
data Message = Message
{ role :: !Role
, content :: !Text
, name :: !(Maybe Text)
, toolCallId :: !(Maybe Text)
, toolCalls :: ![ToolCall]
} deriving (Eq, Show)
data Role
= UserRole
| SystemRole
| AssistantRole
| ToolRole
deriving (Eq, Show)
data ResponseFormat
= Text
| JsonObject
deriving (Eq, Show)
data Tool
= Function { description :: !(Maybe Text), name :: !Text, parameters :: !(Maybe JsonSchema) }
deriving (Eq, Show)
data JsonSchema
= JsonSchemaObject ![Property]
| JsonSchemaString
| JsonSchemaInteger
| JsonSchemaNumber
| JsonSchemaArray !JsonSchema
| JsonSchemaEnum ![Text]
deriving (Eq, Show)
data Property
= Property { propertyName :: !Text, type_ :: !JsonSchema, required :: !Bool, description :: !(Maybe Text) }
deriving (Eq, Show)
instance ToJSON CompletionRequest where
toJSON CompletionRequest { model, messages, maxTokens, temperature, presencePenalty, frequencePenalty, stream, responseFormat, tools } =
object
[ "model" .= model
, "messages" .= messages
, "max_tokens" .= maxTokens
, "stream" .= stream
, "temperature" .= temperature
, "presence_penalty" .= presencePenalty
, "frequency_penalty" .= frequencePenalty
, "response_format" .= responseFormat
, "tools" .= emptyListToNothing tools
]
instance ToJSON Role where
toJSON UserRole = toJSON ("user" :: Text)
toJSON SystemRole = toJSON ("system" :: Text)
toJSON AssistantRole = toJSON ("assistant" :: Text)
toJSON ToolRole = toJSON ("tool" :: Text)
instance ToJSON Message where
toJSON Message { role, content, name, toolCallId, toolCalls } = object $ Maybe.catMaybes
[ Just ("role" .= role)
, Just ("content" .= content)
, ("name" .=) <$> name
, ("tool_call_id" .=) <$> toolCallId
, if null toolCalls then Nothing else Just ("tool_calls" .= toolCalls)
]
instance ToJSON ResponseFormat where
toJSON Text = object [ "type" .= ("text" :: Text) ]
toJSON JsonObject = object [ "type" .= ("json_object" :: Text) ]
instance ToJSON Tool where
toJSON Function { description, name, parameters } =
object
[ "type" .= ("function" :: Text)
, "function" .= (object
[ "name" .= name
, "description" .= description
, "parameters" .= parameters
])
]
instance ToJSON JsonSchema where
toJSON (JsonSchemaObject properties) =
object
[ "type" .= ("object" :: Text)
, "properties" .= (object (concat (map (\property -> [ (Key.fromText property.propertyName) .= ((toJSON property.type_) `mergeObj` (object $ Maybe.catMaybes [ ("description" .=) <$> property.description ])) ]) properties)))
]
where
mergeObj (Object first) (Object second) = Object (first <> second)
mergeObj _ _ = error "JsonSchema.mergeObj failed with invalid type"
toJSON JsonSchemaString =
object [ "type" .= ("string" :: Text) ]
toJSON JsonSchemaInteger =
object [ "type" .= ("integer" :: Text) ]
toJSON JsonSchemaNumber =
object [ "type" .= ("number" :: Text) ]
toJSON (JsonSchemaArray items) =
object
[ "type" .= ("array" :: Text)
, "items" .= items
]
toJSON (JsonSchemaEnum values) =
object
[ "type" .= ("string" :: Text)
, "enum" .= values
]
userMessage :: Text -> Message
userMessage content = Message { role = UserRole, content, name = Nothing, toolCallId = Nothing, toolCalls = [] }
systemMessage :: Text -> Message
systemMessage content = Message { role = SystemRole, content, name = Nothing, toolCallId = Nothing, toolCalls = [] }
assistantMessage :: Text -> Message
assistantMessage content = Message { role = AssistantRole, content, name = Nothing, toolCallId = Nothing, toolCalls = [] }
toolMessage :: Text -> Message
toolMessage content = Message { role = ToolRole, content, name = Nothing, toolCallId = Nothing, toolCalls = [] }
newCompletionRequest :: CompletionRequest
newCompletionRequest = CompletionRequest
{ messages = []
, maxTokens = Nothing
, temperature = Nothing
, presencePenalty = Nothing
, frequencePenalty = Nothing
, model = "gpt-3.5-turbo"
, stream = False
, responseFormat = Nothing
, tools = []
}
data CompletionResult
= CompletionResult
{ choices :: [Choice]
}
| CompletionError
{ message :: !Text
}
deriving (Eq, Show)
instance FromJSON CompletionResult where
parseJSON = withObject "CompletionResult" \v -> do
let result = CompletionResult <$> v .: "choices"
let error = do
errorObj <- v .: "error"
message <- errorObj .: "message"
pure CompletionError { message }
result <|> error
-- [{"text": "Introdu", "index": 0, "logprobs": null, "finish_reason": null}]
data Choice = Choice
{ text :: !Text
}
deriving (Eq, Show)
instance FromJSON Choice where
parseJSON = withObject "Choice" $ \v -> do
deltaOrMessage <- (v .: "message") <|> (v .: "delta")
content <- deltaOrMessage .: "content"
pure Choice { text = content }
streamCompletion :: ByteString -> CompletionRequest -> IO () -> (CompletionChunk -> IO ()) -> IO [CompletionChunk]
streamCompletion secretKey completionRequest' onStart callback = do
let completionRequest = enableStream completionRequest'
completionRequestRef <- newIORef completionRequest
result <- Retry.retrying retryPolicyDefault shouldRetry (action completionRequestRef)
case result of
Left (e :: SomeException) -> Exception.throwIO e
Right (Left e) -> error (Text.unpack e)
Right (Right r) -> pure r
where
shouldRetry retryStatus (Left e) = pure True
shouldRetry retryStatus (Right (Left _)) = pure True
shouldRetry retryStatus (Right (Right r)) = pure False
action completionRequestRef retryStatus = do
completionRequest <- readIORef completionRequestRef
let onStart' = if retryStatus.rsIterNumber == 0 then onStart else pure ()
Exception.try (streamCompletionWithoutRetry secretKey completionRequest onStart' (wrappedCallback completionRequestRef))
wrappedCallback completionRequestRef completionChunk = do
let text = mconcat $ Maybe.mapMaybe (\choiceDelta -> choiceDelta.delta.content) completionChunk.choices
modifyIORef' completionRequestRef (\completionRequest -> completionRequest
{ messages = completionRequest.messages <> [assistantMessage text]
, maxTokens = case completionRequest.maxTokens of
Just maxTokens -> Just $ maxTokens - (length (Text.words text))
Nothing -> Nothing
}
)
callback completionChunk
retryPolicyDefault = Retry.constantDelay 50000 <> Retry.limitRetries 10
streamCompletionWithoutRetry :: ByteString -> CompletionRequest -> IO () -> (CompletionChunk -> IO ()) -> IO (Either Text [CompletionChunk])
streamCompletionWithoutRetry secretKey completionRequest' onStart callback = do
let completionRequest = enableStream completionRequest'
modifyContextSSL (\context -> do
SSL.contextSetVerificationMode context SSL.VerifyNone
pure context
)
withOpenSSL do
withConnection (establishConnection "https://api.openai.com/v1/chat/completions") \connection -> do
let q = buildRequest1 do
http POST "/v1/chat/completions"
setContentType "application/json"
Network.Http.Client.setHeader "Authorization" ("Bearer " <> secretKey)
sendRequest connection q (jsonBody completionRequest)
onStart
receiveResponse connection handler
where
handler :: Response -> Streams.InputStream ByteString -> IO (Either Text [CompletionChunk])
handler response stream = do
let status = getStatusCode response
if status == 200
then do
{-
parse stream line by line as event stream format according to API spec:
https://platform.openai.com/docs/api-reference/chat/create#chat/create-stream
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
-}
state <- Streams.lines stream >>= Streams.foldM (parseResponseChunk' callback) emptyParserState
return (Right state.chunks)
else do
x :: ByteString <- Streams.fold mappend mempty stream
return (Left $ "an error happend: " <> Text.pack (show x))
parseResponseChunk' :: (CompletionChunk -> IO ()) -> ParserState -> ByteString -> IO ParserState
parseResponseChunk' callback state input =
case parseResponseChunk state input of
ParserResult { chunk = Just chunk, state } -> do
callback chunk
pure state
ParserResult { state } -> pure state
data ParserState = ParserState
{ curBuffer :: !ByteString
, emptyLineFound :: !Bool
, chunks :: ![CompletionChunk]
} deriving (Eq, Show)
data ParserResult = ParserResult
{ chunk :: !(Maybe CompletionChunk)
, state :: ParserState
} deriving (Eq, Show)
emptyParserState :: ParserState
emptyParserState = ParserState { curBuffer = "", emptyLineFound = False, chunks = [] }
parseResponseChunk :: ParserState -> ByteString -> ParserResult
parseResponseChunk ParserState { curBuffer, emptyLineFound, chunks } input
-- input line is empty, but previous was not, append newline to buffer
| ByteString.null input && not emptyLineFound = ParserResult { chunk = Nothing, state = ParserState { curBuffer = curBuffer <> "\n", emptyLineFound = True, chunks } }
-- input line is empty, previous line was already empty: message ended, clear buffer
| ByteString.null input && emptyLineFound = ParserResult { chunk = Nothing, state = ParserState { curBuffer = "", emptyLineFound = True, chunks } }
-- lines starting with : are comments, ignore
| ":" `ByteString.isPrefixOf` input = ParserResult { chunk = Nothing, state = ParserState { curBuffer = curBuffer, emptyLineFound = False, chunks } }
-- try to parse line together with buffer otherwise
| otherwise = case ByteString.stripPrefix "data: " (ByteString.strip (curBuffer <> input)) of
-- the stream terminated by a data: [DONE] message
Just "[DONE]" ->
ParserResult { chunk = Nothing, state = ParserState { curBuffer, emptyLineFound, chunks } }
Just json ->
case eitherDecodeStrict json of
Right (completionChunk :: CompletionChunk) ->
ParserResult
{ chunk = Just completionChunk
, state = ParserState { curBuffer = "", emptyLineFound = False, chunks = chunks <> [completionChunk] }
}
Left err -> error (show err <> " while parsing " <> show input)
--ParserResult
-- { chunk = Nothing
-- , state = ParserState { curBuffer = curBuffer <> json, emptyLineFound = False, chunks = chunks } }
Nothing ->
ParserResult
{ chunk = Nothing
, state = ParserState { curBuffer = curBuffer <> input, emptyLineFound = False, chunks = chunks } }
fetchCompletion :: ByteString -> CompletionRequest -> IO Text
fetchCompletion secretKey completionRequest = do
result <- Retry.retrying retryPolicyDefault shouldRetry action
case result of
Left (e :: SomeException) -> Exception.throwIO e
Right result ->
case result of
CompletionResult { choices } -> pure (mconcat $ map (.text) choices)
CompletionError { message } -> error (Text.unpack message)
where
shouldRetry retryStatus (Left _) = pure True
shouldRetry retryStatus (Right _) = pure False
action retryStatus = Exception.try (fetchCompletionWithoutRetry secretKey completionRequest)
retryPolicyDefault = Retry.constantDelay 50000 <> Retry.limitRetries 10
fetchCompletionWithoutRetry :: ByteString -> CompletionRequest -> IO CompletionResult
fetchCompletionWithoutRetry secretKey completionRequest = do
modifyContextSSL (\context -> do
SSL.contextSetVerificationMode context SSL.VerifyNone
pure context
)
withOpenSSL do
withConnection (establishConnection "https://api.openai.com/v1/chat/completions") \connection -> do
let q = buildRequest1 do
http POST "/v1/chat/completions"
setContentType "application/json"
Network.Http.Client.setHeader "Authorization" ("Bearer " <> secretKey)
sendRequest connection q (jsonBody completionRequest)
receiveResponse connection jsonHandler
enableStream :: CompletionRequest -> CompletionRequest
enableStream completionRequest = completionRequest { stream = True }
data CompletionChunk = CompletionChunk
{ id :: !Text
, choices :: [CompletionChunkChoice]
, created :: Int
, model :: !Text
, systemFingerprint :: !(Maybe Text)
} deriving (Eq, Show)
instance FromJSON CompletionChunk where
parseJSON = withObject "CompletionChunk" $ \v -> CompletionChunk
<$> v .: "id"
<*> v .: "choices"
<*> v .: "created"
<*> v .: "model"
<*> v .: "system_fingerprint"
data CompletionChunkChoice
= CompletionChunkChoice { delta :: !Delta }
deriving (Eq, Show)
instance FromJSON CompletionChunkChoice where
parseJSON = withObject "CompletionChunkChoice" $ \v -> CompletionChunkChoice
<$> v .: "delta"
data Delta
= Delta
{ content :: !(Maybe Text)
, toolCalls :: !(Maybe [ToolCall])
, role :: !(Maybe Role)
} deriving (Eq, Show)
instance FromJSON Delta where
parseJSON = withObject "Delta" $ \v -> Delta
<$> v .:? "content"
<*> v .:? "tool_calls"
<*> v .:? "role"
instance FromJSON Role where
parseJSON (String "user") = pure UserRole
parseJSON (String "system") = pure SystemRole
parseJSON (String "assistant") = pure AssistantRole
parseJSON (String "ToolRole") = pure ToolRole
parseJSON otherwise = fail ("Failed to parse role" <> show otherwise)
data ToolCall
= FunctionCall
{ index :: !Int
, id :: !(Maybe Text)
, name :: !(Maybe Text)
, arguments :: !Text
} deriving (Eq, Show)
instance FromJSON ToolCall where
parseJSON = withObject "ToolCall" $ \v -> do
index <- v .: "index"
id <- v .:? "id"
function <- v .: "function"
name <- function .:? "name"
arguments <- function .: "arguments"
pure FunctionCall { index, id, name, arguments }
instance ToJSON ToolCall where
toJSON FunctionCall { index, id, name, arguments } =
object
[ "index" .= index
, "id" .= id
, "type" .= ("function" :: Text)
, "function" .= object [ "name" .= name, "arguments" .= arguments ]
]
-- [{"text": "Introdu", "index": 0, "logprobs": null, "finish_reason": null}]
emptyListToNothing :: [value] -> Maybe [value]
emptyListToNothing [] = Nothing
emptyListToNothing values = Just values