ihp-openai (empty) → 1.3.0
raw patch · 6 files changed
+1305/−0 lines, 6 filesdep +HsOpenSSLdep +aesondep +base
Dependencies added: HsOpenSSL, aeson, base, bytestring, hspec, http-streams, ihp-openai, io-streams, neat-interpolation, retry, text
Files
- IHP/OpenAI.hs +430/−0
- LICENSE +21/−0
- README.md +117/−0
- Test/IHP/OpenAISpec.hs +653/−0
- changelog.md +5/−0
- ihp-openai.cabal +79/−0
+ IHP/OpenAI.hs view
@@ -0,0 +1,430 @@+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
+ LICENSE view
@@ -0,0 +1,21 @@+The MIT License (MIT)++Copyright (c) 2023 digitally induced GmbH++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,117 @@+# OpenAI++This `ihp-openai` package provides streaming functions to access GPT3 and GPT4 from OpenAI.++The package is designed to work well with IHP AutoRefresh and IHP DataSync. The main function `streamCompletion` uses the OpenAI Chat API. It was created before the Chat API was available, and the later rewritten to work well with GPT-4. That's why it feels a bit like a mix of the completion and the chat api.++API calls are retried up to 10 times. A retry will continue with the already generated output tokens, so that a user will never see that a retry has happend.++## Install++1. Make sure you're running on the latest master version of IHP++2. Open `default.nix` and add `ihp-openai` to your haskell dependencies:++ ```nix+ let+ ihp = ...;+ haskellEnv = import "${ihp}/NixSupport/default.nix" {+ ihp = ihp;+ haskellDeps = p: with p; [+ cabal-install+ base+ wai+ text+ hlint+ p.ihp++ ihp-openai+ ];+ otherDeps = p: with p; [+ # Native dependencies, e.g. imagemagick+ ];+ projectPath = ./.;+ };+ in+ haskellEnv++ ```++## Example++```haskell+module Web.Controller.Questions where++import Web.Controller.Prelude+import Web.View.Questions.Index+import Web.View.Questions.New+import Web.View.Questions.Edit+import Web.View.Questions.Show++import qualified IHP.OpenAI as GPT++instance Controller QuestionsController where+ action QuestionsAction = autoRefresh do+ questions <- query @Question+ |> orderByDesc #createdAt+ |> fetch+ render IndexView { .. }++ action NewQuestionAction = do+ let question = newRecord+ |> set #question "What makes haskell so great?"+ render NewView { .. }++ action CreateQuestionAction = do+ let question = newRecord @Question+ question+ |> fill @'["question"]+ |> validateField #question nonEmpty+ |> ifValid \case+ Left question -> render NewView { .. } + Right question -> do+ question <- question |> createRecord+ setSuccessMessage "Question created"++ fillAnswer question++ redirectTo QuestionsAction++ action DeleteQuestionAction { questionId } = do+ question <- fetch questionId+ deleteRecord question+ setSuccessMessage "Question deleted"+ redirectTo QuestionsAction++fillAnswer :: (?modelContext :: ModelContext) => Question -> IO (Async ())+fillAnswer question = do+ -- Put your OpenAI secret key below:+ let secretKey = "sk-XXXXXXXX"++ -- This should be done with an IHP job worker instead of async+ async do + GPT.streamCompletion secretKey (buildCompletionRequest question) (clearAnswer question) (appendToken question)+ pure ()++buildCompletionRequest :: Question -> GPT.CompletionRequest+buildCompletionRequest Question { question } =+ -- Here you can adjust the parameters of the request+ GPT.newCompletionRequest+ { GPT.maxTokens = 512+ , GPT.prompt = [trimming|+ Question: ${question}+ Answer:+ |] }++-- | Sets the answer field back to an empty string+clearAnswer :: (?modelContext :: ModelContext) => Question -> IO ()+clearAnswer question = do+ sqlExec "UPDATE questions SET answer = '' WHERE id = ?" (Only question.id)+ pure ()++-- | Stores a couple of newly received characters to the database+appendToken :: (?modelContext :: ModelContext) => Question -> Text -> IO ()+appendToken question token = do+ sqlExec "UPDATE questions SET answer = answer || ? WHERE id = ?" (token, question.id)+ pure ()+```
+ Test/IHP/OpenAISpec.hs view
@@ -0,0 +1,653 @@+module Main where++import Test.Hspec+import IHP.OpenAI+import NeatInterpolation (trimming)+import qualified Data.Text.Encoding as Text+import qualified Data.Text as Text+import Data.Aeson++main :: IO ()+main = hspec do+ tests++tests = do+ describe "IHP.OpenAI" do+ describe "parseResponseChunk" do+ it "should parse a simple message response 'What's 1 + 2?'" do+ -- curl https://api.openai.com/v1/chat/completions \+ -- -H "Content-Type: application/json" \+ -- -H "Authorization: Bearer $OPENAI_TOKEN" \+ -- -d '{+ -- "model": "gpt-4-turbo",+ -- "stream": true,+ -- "messages": [+ -- {+ -- "role": "user",+ -- "content": "What 1 + 2?"+ -- }+ -- ]+ -- }'+ let input = [trimming|+ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":"1"},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":" +"},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":" equals"},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":"3"},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI","object":"chat.completion.chunk","created":1715593776,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}++ data: [DONE]+ |]+ let result = ParserState+ { curBuffer = "\n"+ , emptyLineFound = True+ , chunks =+ [ CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just ""+ , toolCalls = Nothing+ , role = Just AssistantRole+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just "1"+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just " +"+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just " "+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just "2"+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just " equals"+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just " "+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just "3"+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Just "."+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9OMbIk2dtKfDVvDUNgi8ARVSC4LmI"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715593776+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ ]+ }+++ let parseLines = foldl (\state line -> (parseResponseChunk state (Text.encodeUtf8 line)).state) emptyParserState (Text.lines input)++ parseLines `shouldBe` result++ it "should parse a line with a function call" do+ let input = Text.encodeUtf8 [trimming|+ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_cx6RG7DZq3WlIDfXXp9PdtmS","type":"function","function":{"name":"get_current_weather","arguments":""}}]},"logprobs":null,"finish_reason":null}]}+ |]+ let chunk = CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just [ FunctionCall { index = 0, id = Just "call_cx6RG7DZq3WlIDfXXp9PdtmS", name = Just "get_current_weather", arguments = "" } ]+ , role = Just AssistantRole+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ let result = ParserResult+ { chunk = Just chunk+ , state = ParserState+ { curBuffer = ""+ , emptyLineFound = False+ , chunks = [chunk]+ }+ }+ parseResponseChunk emptyParserState input `shouldBe` result+++ it "should parse a full tool call with arguments" do+ let input = [trimming|+ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_cx6RG7DZq3WlIDfXXp9PdtmS","type":"function","function":{"name":"get_current_weather","arguments":""}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"location"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Boston"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":","}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" MA"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"unit"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"f"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ahrenheit"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}]}++ data: {"id":"chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o","object":"chat.completion.chunk","created":1715277101,"model":"gpt-4-turbo-2024-04-09","system_fingerprint":"fp_294de9593d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}++ data: [DONE]+ |]+ let result = ParserState+ { curBuffer = "\n"+ , emptyLineFound = True+ , chunks =+ [ CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Just "call_cx6RG7DZq3WlIDfXXp9PdtmS"+ , name = Just "get_current_weather"+ , arguments = ""+ }+ ]+ , role = Just AssistantRole+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "{\""+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "location"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "\":\""+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "Boston"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = ","+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = " MA"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "\",\""+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "unit"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "\":\""+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "f"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "ahrenheit"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Just+ [ FunctionCall+ { index = 0+ , id = Nothing+ , name = Nothing+ , arguments = "\"}"+ }+ ]+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ , CompletionChunk+ { id = "chatcmpl-9N2DdAg2usc3V0VoinTcCwv5rBs3o"+ , choices =+ [ CompletionChunkChoice+ { delta = Delta+ { content = Nothing+ , toolCalls = Nothing+ , role = Nothing+ }+ }+ ]+ , created = 1715277101+ , model = "gpt-4-turbo-2024-04-09"+ , systemFingerprint = Just "fp_294de9593d"+ }+ ]+ }++ let parseLines = foldl (\state line -> (parseResponseChunk state (Text.encodeUtf8 line)).state) emptyParserState (Text.lines input)++ parseLines `shouldBe` result++ describe "ToJSON Tool" do+ it "encode Function call with parameter descriptions" do+ let function = Function+ { name = "fetchUrl"+ , description = Just "Fetches a url"+ , parameters = Just (JsonSchemaObject [ Property { propertyName = "url", type_ = JsonSchemaString, required = True, description = Just "The url to fetch" }])+ }++ encode function `shouldBe` "{\"function\":{\"description\":\"Fetches a url\",\"name\":\"fetchUrl\",\"parameters\":{\"properties\":{\"url\":{\"description\":\"The url to fetch\",\"type\":\"string\"}},\"type\":\"object\"}},\"type\":\"function\"}"++ describe "ToJSON Message" do+ it "encode a message but only set fields that are not null" do+ let message = userMessage ""++ encode message `shouldBe` "{\"content\":\"\",\"role\":\"user\"}"++ describe "ToJSON JsonSchema" do+ it "not render null description's for object properties" do+ let object = JsonSchemaObject [Property { propertyName = "a", type_ = JsonSchemaString, required = True, description = Nothing } ]++ encode object `shouldBe` "{\"properties\":{\"a\":{\"type\":\"string\"}},\"type\":\"object\"}"++ describe "FromJSON CompletionResult" do+ it "should decode a successful response" do+ let response = [trimming|+ {+ "id": "chatcmpl-abc123",+ "object": "chat.completion",+ "created": 1677858242,+ "model": "gpt-3.5-turbo-0613",+ "usage": {+ "prompt_tokens": 13,+ "completion_tokens": 7,+ "total_tokens": 20+ },+ "choices": [+ {+ "message": {+ "role": "assistant",+ "content": "\n\nThis is a test!"+ },+ "logprobs": null,+ "finish_reason": "stop",+ "index": 0+ }+ ]+ }+ |]++ decodeStrictText (response) `shouldBe` (Just CompletionResult { choices = [ Choice { text = "\n\nThis is a test!" } ] })++ it "should decode an error response" do+ let response = [trimming|+ {+ "error": {+ "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",+ "type": "insufficient_quota",+ "param": null,+ "code": "insufficient_quota"+ }+ }+ |]++ decodeStrictText (response) `shouldBe` (Just CompletionError { message = "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors." })
+ changelog.md view
@@ -0,0 +1,5 @@+# Changelog for `ihp-openai`++## v1.3++- This is the initial hackage release
+ ihp-openai.cabal view
@@ -0,0 +1,79 @@+cabal-version: 2.2+name: ihp-openai+version: 1.3.0+synopsis: Call GPT4 from your Haskell apps+description: Streaming functions to access the OpenAI APIs, with Retry and Function Calling+license: MIT+license-file: LICENSE+author: digitally induced GmbH+maintainer: support@digitallyinduced.com+bug-reports: https://github.com/digitallyinduced/ihp/issues+category: AI+build-type: Simple+extra-source-files: README.md, changelog.md++source-repository head+ type: git+ location: https://github.com/digitallyinduced/ihp.git++library+ default-language: Haskell2010+ build-depends:+ base >= 4.17.0 && < 4.20+ , text+ , http-streams+ , retry+ , io-streams+ , bytestring+ , aeson+ , HsOpenSSL+ default-extensions:+ OverloadedStrings+ FlexibleContexts+ , ScopedTypeVariables+ , NamedFieldPuns+ , BangPatterns+ , BlockArguments+ , OverloadedRecordDot+ , BlockArguments+ , DisambiguateRecordFields+ , DuplicateRecordFields+ ghc-options:+ -fstatic-argument-transformation+ -funbox-strict-fields+ -haddock+ -Wredundant-constraints+ -Wunused-imports+ -Wunused-foralls+ -Wmissing-fields+ -Winaccessible-code+ -Wmissed-specialisations+ -fexpose-all-unfoldings+ hs-source-dirs: .+ exposed-modules:+ IHP.OpenAI++test-suite tests+ type: exitcode-stdio-1.0+ main-is: IHP/OpenAISpec.hs+ build-depends:+ base >= 4.17.0 && < 4.20+ , hspec+ , neat-interpolation+ , ihp-openai+ , text+ , aeson+ hs-source-dirs: Test+ default-language: Haskell2010+ default-extensions:+ OverloadedStrings+ FlexibleContexts+ , ScopedTypeVariables+ , NamedFieldPuns+ , BangPatterns+ , BlockArguments+ , OverloadedRecordDot+ , BlockArguments+ , DisambiguateRecordFields+ , DuplicateRecordFields+ , QuasiQuotes