llm-simple-0.3.0.0: test/LLM/OpenAISpec.hs
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
module LLM.OpenAISpec (spec) where
import Data.Aeson
( Value (Array, Object, String),
eitherDecodeFileStrict',
object,
(.=),
)
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Vector qualified as V
import Data.ByteString qualified as BS
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text qualified as T
import LLM.Core.Types
( ChatResponse (respText),
LLMError (..),
ProviderOpaque (..),
ThinkingContent (..),
ToolCall (tcArguments, tcId, tcName),
Turn (..),
cacheEphemeral,
defaultMessageEncodeOptions,
imageBase64Part,
imageUrlPart,
mkToolCall,
textPart,
thinkingPart,
toolCallPart,
)
import LLM.Core.Usage (Usage (..), mkUsage)
import LLM.Core.Utils (getToolCalls, hasToolCalls, isRetryable)
import LLM.Providers.OpenAI (encodeTurn, parseOpenAIResponse, parseOpenAIStream, parseOpenAIUsage)
import Test.Hspec
( Spec,
describe,
expectationFailure,
it,
shouldBe,
shouldContain,
shouldSatisfy,
)
spec :: Spec
spec = describe "OpenAI" $ do
describe "parseOpenAIResponse" $ do
it "parses a text response" $ do
Right val <- eitherDecodeFileStrict' "test/fixtures/openai-text.json"
case parseOpenAIResponse val of
Right resp -> do
resp.respText `shouldBe` "Hello! How can I help you today?"
hasToolCalls resp `shouldBe` False
Left err -> expectationFailure $ "Parse failed: " <> show err
it "parses a tool_calls response" $ do
Right val <- eitherDecodeFileStrict' "test/fixtures/openai-tool-use.json"
case parseOpenAIResponse val of
Right resp -> do
hasToolCalls resp `shouldBe` True
let [tc] = getToolCalls resp
tc.tcName `shouldBe` "get_weather"
tc.tcId `shouldBe` "call_abc123"
Left err -> expectationFailure $ "Parse failed: " <> show err
it "returns a ParseError when finish_reason is length and tool arguments are cut off" $ do
let resp =
object
[ "choices"
.= [ object
[ "finish_reason" .= ("length" :: Text),
"message"
.= object
[ "role" .= ("assistant" :: Text),
"tool_calls"
.= [ object
[ "id" .= ("call_1" :: Text),
"type" .= ("function" :: Text),
"function"
.= object
[ "name" .= ("get_weather" :: Text),
"arguments" .= ("{\"location\": \"Lon" :: Text)
]
]
]
]
]
]
]
case parseOpenAIResponse resp of
Left (ParseError msg) -> T.unpack msg `shouldContain` "length"
other -> expectationFailure ("expected ParseError, got " <> show other)
describe "parseOpenAIStream errors and truncation" $ do
it "returns a retryable 429 for a mid-stream rate limit error" $ do
reader <- fixtureReader "test/fixtures/openai-midstream-error.sse"
result <- parseOpenAIStream reader (\_ -> pure ())
case result of
Left err@(HttpError 429 body) -> do
T.unpack body `shouldContain` "Rate limit"
isRetryable err `shouldBe` True
other -> expectationFailure ("expected HttpError 429, got " <> show other)
it "returns a ParseError instead of String args when finish_reason is length" $ do
reader <- fixtureReader "test/fixtures/openai-length-tool.sse"
result <- parseOpenAIStream reader (\_ -> pure ())
case result of
Left (ParseError msg) -> T.unpack msg `shouldContain` "length"
other -> expectationFailure ("expected ParseError, got " <> show other)
describe "parseOpenAIStream multiple tool calls" $ do
it "parses every tool call in a chunk, in index order" $ do
reader <- fixtureReader "test/fixtures/openai-multi-tool-chunk.sse"
result <- parseOpenAIStream reader (\_ -> pure ())
case result of
Right resp ->
[(tc.tcId, tc.tcName, tc.tcArguments) | tc <- getToolCalls resp]
`shouldBe` [ ("call_a", "get_weather", object ["location" .= ("London" :: Text)]),
("call_b", "get_time", object ["zone" .= ("CET" :: Text)])
]
Left err -> expectationFailure ("unexpected error: " <> show err)
it "parses Ollama-style complete tool calls without index fields" $ do
reader <- fixtureReader "test/fixtures/ollama-multi-tool-chunk.sse"
result <- parseOpenAIStream reader (\_ -> pure ())
case result of
Right resp ->
[(tc.tcId, tc.tcArguments) | tc <- getToolCalls resp]
`shouldBe` [ ("call_x", object ["location" .= ("London" :: Text)]),
("call_y", object ["location" .= ("Paris" :: Text)])
]
Left err -> expectationFailure ("unexpected error: " <> show err)
describe "assistant message encoding" $ do
it "sends empty content for a turn with only foreign thinking" $ do
let redacted =
ThinkingContent
{ thinkingText = Nothing,
thinkingOpaque =
Just
ProviderOpaque
{ poProvider = "claude",
poModel = Just "claude-haiku-4-5",
poPayload = object ["type" .= ("redacted_thinking" :: Text), "data" .= ("xyz" :: Text)]
}
}
msg = head (encodeTurn defaultMessageEncodeOptions (AssistantMessage [thinkingPart redacted]))
lookupText "content" msg `shouldBe` Just ""
lookupKey "tool_calls" msg `shouldBe` Nothing
it "omits content for a tool-call-only turn" $ do
let turn = AssistantMessage [toolCallPart (mkToolCall "c1" "get_weather" (object []))]
msg = head (encodeTurn defaultMessageEncodeOptions turn)
lookupKey "content" msg `shouldBe` Nothing
lookupKey "tool_calls" msg `shouldSatisfy` (/= Nothing)
describe "image request encoding" $ do
it "encodes URL and base64 data-URL image parts" $ do
Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="
let turn =
UserMessage
[ textPart "What is in this image?",
imageUrlPart "https://example.com/boardwalk.jpg",
b64
]
content = userContent (head (encodeTurn defaultMessageEncodeOptions turn))
contentTypes content `shouldBe` ["text", "image_url", "image_url"]
case content of
[Object textO, Object urlO, Object b64O] -> do
lookupText "text" (Object textO) `shouldBe` Just "What is in this image?"
nestedText ["image_url", "url"] (Object urlO)
`shouldBe` Just "https://example.com/boardwalk.jpg"
nestedText ["image_url", "url"] (Object b64O)
`shouldBe` Just "data:image/png;base64,aGVsbG8="
_ -> expectationFailure "expected text + two image_url parts"
describe "cache hint encoding" $ do
it "omits cache hints from the wire while keeping content identical" $ do
Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="
let plain =
UserMessage
[ textPart "What is in this image?",
imageUrlPart "https://example.com/boardwalk.jpg",
b64
]
hinted =
UserMessage
[ cacheEphemeral (textPart "What is in this image?"),
imageUrlPart "https://example.com/boardwalk.jpg",
cacheEphemeral b64
]
plainMsg = head (encodeTurn defaultMessageEncodeOptions plain)
hintedMsg = head (encodeTurn defaultMessageEncodeOptions hinted)
hintedMsg `shouldBe` plainMsg
case userContent hintedMsg of
parts -> do
contentTypes parts `shouldBe` ["text", "image_url", "image_url"]
mapM_ (\p -> lookupKey "cache_control" p `shouldBe` Nothing) parts
describe "parseOpenAIUsage" $ do
it "extracts token counts" $ do
Right val <- eitherDecodeFileStrict' "test/fixtures/openai-text.json"
parseOpenAIUsage val `shouldBe` Just (mkUsage 15 9)
it "reports cached_tokens without double-counting prompt_tokens" $ do
let val =
object
[ "usage"
.= object
[ "prompt_tokens" .= (2006 :: Int),
"completion_tokens" .= (300 :: Int),
"prompt_tokens_details"
.= object
[ "cached_tokens" .= (1920 :: Int),
"cache_write_tokens" .= (40 :: Int)
]
]
]
parseOpenAIUsage val
`shouldBe` Just
Usage
{ usageInputTokens = 2006,
usageOutputTokens = 300,
usageCacheReadTokens = 1920,
usageCacheCreationTokens = 40,
usageTotalCost = 0
}
it "reads zero cached_tokens from recorded conversation fixtures" $ do
Right val <- eitherDecodeFileStrict' "test/fixtures/openai-conversation-generated.json"
case val of
Array arr ->
case V.toList arr of
(Object first : _) ->
case KM.lookup "response" first of
Just resp ->
case parseOpenAIUsage resp of
Just u -> do
u.usageCacheReadTokens `shouldBe` 0
u.usageInputTokens `shouldSatisfy` (> 0)
Nothing -> expectationFailure "failed to parse usage"
_ -> expectationFailure "missing response"
_ -> expectationFailure "empty conversation array"
_ -> expectationFailure "expected conversation array"
userContent :: Value -> [Value]
userContent (Object o) =
case KM.lookup "content" o of
Just (Array arr) -> V.toList arr
_ -> []
userContent _ = []
contentTypes :: [Value] -> [Text]
contentTypes = mapMaybe typ
where
typ (Object o) = case KM.lookup "type" o of
Just (String t) -> Just t
_ -> Nothing
typ _ = Nothing
lookupText :: Text -> Value -> Maybe Text
lookupText key (Object o) =
case KM.lookup (K.fromText key) o of
Just (String t) -> Just t
_ -> Nothing
lookupText _ _ = Nothing
lookupKey :: Text -> Value -> Maybe Value
lookupKey key (Object o) = KM.lookup (K.fromText key) o
lookupKey _ _ = Nothing
nestedText :: [Text] -> Value -> Maybe Text
nestedText [key] v = lookupText key v
nestedText (key : rest) (Object o) =
KM.lookup (K.fromText key) o >>= nestedText rest
nestedText _ _ = Nothing
-- | Yield the whole fixture once, then EOF.
fixtureReader :: FilePath -> IO (IO BS.ByteString)
fixtureReader path = do
bs <- BS.readFile path
ref <- newIORef (Just bs)
pure $ do
m <- readIORef ref
writeIORef ref Nothing
pure (fromMaybe BS.empty m)