packages feed

llm-simple-0.2.0.0: test/LLM/ClaudeSpec.hs

{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}

module LLM.ClaudeSpec (spec) where

import Data.Aeson
  ( Value (Array, Number, Object, String),
    eitherDecodeFileStrict',
    object,
    (.=),
  )
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (mapMaybe)
import Data.Scientific (toBoundedInteger, toRealFloat)
import Data.Text (Text)
import Data.Vector qualified as V
import LLM.Core.Types
  ( ChatRequest (..),
    ChatResponse (respContent, respReasoning, respText),
    ContentPart (..),
    PartBody (..),
    ProviderOpaque (..),
    StreamEvent (..),
    ThinkingContent (..),
    ThinkingMode (..),
    ToolCall (..),
    ToolResult (..),
    Turn (..),
    mkToolCall,
    textPart,
    thinkingPart,
    toolCallPart,
    imageUrlPart,
    imageBase64Part,
    cacheEphemeral,
    pattern UserTurn,
  )
import LLM.Core.Usage (Usage (..), mkUsage)
import LLM.Core.Utils (getToolCalls, hasToolCalls)
import LLM.Providers.Claude
  ( claudeBuildBody,
    encodeTurn,
    effortToBudgetTokens,
    parseClaudeResponse,
    parseClaudeStream,
    parseClaudeUsage,
  )
import Test.Hspec
  ( Spec,
    describe,
    expectationFailure,
    it,
    shouldBe,
    shouldContain,
  )

spec :: Spec
spec = describe "Claude" $ do
  describe "parseClaudeResponse" $ do
    it "parses a text response" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-text.json"
      case parseClaudeResponse 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_use response" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-tool-use.json"
      case parseClaudeResponse val of
        Right resp -> do
          resp.respText `shouldBe` "Let me check the weather for you."
          hasToolCalls resp `shouldBe` True
          let [tc] = getToolCalls resp
          tc.tcName `shouldBe` "get_weather"
          tc.tcId `shouldBe` "toolu_01A09q90qw90lq917835lq9"
        Left err -> expectationFailure $ "Parse failed: " <> show err

    it "preserves thinking -> text -> tool_use order and opaque signature" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-thinking-tool-use.json"
      case parseClaudeResponse val of
        Right resp -> do
          resp.respReasoning `shouldBe` Just "I should call the weather tool."
          resp.respText `shouldBe` "Checking the weather."
          case resp.respContent of
            [ ContentPart (ThinkingPart tc) Nothing,
              ContentPart (TextPart "Checking the weather.") Nothing,
              ContentPart (ToolCallPart tool) Nothing
              ] -> do
                tc.thinkingText `shouldBe` Just "I should call the weather tool."
                case tc.thinkingOpaque of
                  Just o -> do
                    o.poProvider `shouldBe` "claude"
                    o.poModel `shouldBe` Just "claude-haiku-4-5-20251001"
                    lookupText "signature" o.poPayload `shouldBe` Just "sig_thinking_abc123"
                  Nothing -> expectationFailure "expected thinking opaque"
                tool.tcId `shouldBe` "toolu_weather_1"
            other -> expectationFailure $ "unexpected parts: " <> show other
        Left err -> expectationFailure $ "Parse failed: " <> show err

  describe "parseClaudeStream" $ do
    it "preserves thinking -> text -> tool_use order and opaque signature" $ do
      sse <- BS.readFile "test/fixtures/claude-thinking-tool-use.sse"
      eventsRef <- newIORef ([] :: [StreamEvent])
      reader <- mkBodyReader sse
      result <- parseClaudeStream "claude-haiku-4-5-20251001" reader $ \ev ->
        modifyIORef' eventsRef (ev :)
      case result of
        Right resp -> do
          resp.respReasoning `shouldBe` Just "I should call the weather tool."
          resp.respText `shouldBe` "Checking the weather."
          case resp.respContent of
            [ ContentPart (ThinkingPart tc) Nothing,
              ContentPart (TextPart "Checking the weather.") Nothing,
              ContentPart (ToolCallPart tool) Nothing
              ] -> do
                tc.thinkingText `shouldBe` Just "I should call the weather tool."
                case tc.thinkingOpaque of
                  Just o -> do
                    o.poProvider `shouldBe` "claude"
                    o.poModel `shouldBe` Just "claude-haiku-4-5-20251001"
                    lookupText "signature" o.poPayload `shouldBe` Just "sig_thinking_abc123"
                  Nothing -> expectationFailure "expected thinking opaque"
                tool.tcId `shouldBe` "toolu_weather_1"
                tool.tcName `shouldBe` "get_weather"
                let encoded =
                      encodeTurn
                        "claude-haiku-4-5-20251001"
                        (AssistantMessage resp.respContent)
                    content = messageContent (head encoded)
                contentTypes content `shouldBe` ["thinking", "text", "tool_use"]
                case content of
                  (Object o : _) ->
                    lookupText "signature" (Object o) `shouldBe` Just "sig_thinking_abc123"
                  _ -> expectationFailure "expected thinking object"
                let withResult =
                      encodeTurn
                        "claude-haiku-4-5-20251001"
                        ( ToolTurn
                            [ ToolResult
                                { trCallId = "toolu_weather_1",
                                  trName = "get_weather",
                                  trContent = "sunny"
                                }
                            ]
                        )
                length withResult `shouldBe` 1
            other -> expectationFailure $ "unexpected parts: " <> show other
          events <- reverse <$> readIORef eventsRef
          events
            `shouldContain` [ StreamReasoningDelta "I should call the weather tool.",
                              StreamDelta "Checking the weather."
                            ]
          case [tc | StreamToolCall tc <- events] of
            [tc] -> tc.tcId `shouldBe` "toolu_weather_1"
            other -> expectationFailure $ "expected one StreamToolCall, got: " <> show other
        Left err -> expectationFailure $ "Stream parse failed: " <> show err

  describe "encodeTurn replay" $ do
    it "replays signed thinking blocks before tool_use for the same model" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-thinking-tool-use.json"
      case parseClaudeResponse val of
        Right resp -> do
          let encoded = encodeTurn "claude-haiku-4-5-20251001" (AssistantMessage resp.respContent)
              content = messageContent (head encoded)
          contentTypes content `shouldBe` ["thinking", "text", "tool_use"]
          case content of
            (Object o : _) ->
              lookupText "signature" (Object o) `shouldBe` Just "sig_thinking_abc123"
            _ -> expectationFailure "expected thinking object"
          let withResult =
                encodeTurn
                  "claude-haiku-4-5-20251001"
                  ( ToolTurn
                      [ ToolResult
                          { trCallId = "toolu_weather_1",
                            trName = "get_weather",
                            trContent = "sunny"
                          }
                      ]
                  )
          length withResult `shouldBe` 1
        Left err -> expectationFailure $ show err

    it "omits foreign thinking opaque and foreign tool meta on encode" $ do
      let foreignThinking =
            thinkingPart
              ThinkingContent
                { thinkingText = Just "gemini thoughts",
                  thinkingOpaque =
                    Just
                      ProviderOpaque
                        { poProvider = "gemini",
                          poModel = Just "gemini-2.5-flash",
                          poPayload = object ["thoughtSignature" .= ("sig" :: Text)]
                        }
                }
          foreignTool =
            let base = mkToolCall "c1" "get_weather" (object [])
             in toolCallPart
                  base
                    { tcProviderMeta =
                        Just
                          ProviderOpaque
                            { poProvider = "gemini",
                              poModel = Just "gemini-2.5-flash",
                              poPayload = object ["thoughtSignature" .= ("sig" :: Text)]
                            }
                    }
          turn =
            AssistantMessage
              [ foreignThinking,
                textPart "hello",
                foreignTool
              ]
          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))
      contentTypes content `shouldBe` ["text", "tool_use"]
      case content of
        [Object textO, Object toolO] -> do
          lookupText "text" (Object textO) `shouldBe` Just "hello"
          lookupText "name" (Object toolO) `shouldBe` Just "get_weather"
          KM.lookup "thoughtSignature" toolO `shouldBe` Nothing
        _ -> expectationFailure "expected text + tool_use only"

  describe "thinking request mapping" $ do
    it "maps effort to budget_tokens and omits temperature when thinking is on" $ do
      let req =
            ChatRequest
              { reqModel = "claude-haiku-4-5-20251001",
                reqConversation = [UserTurn "hi"],
                reqSystem = Nothing,
                reqMaxTokens = 4096,
                reqTemperature = Just 0.5,
                reqTools = [],
                reqThinking = Just ThinkingMode {tmEnabled = True, tmEffort = Just "high"}
              }
          body = claudeBuildBody False req
      nestedText ["thinking", "type"] body `shouldBe` Just "enabled"
      nestedInt ["thinking", "budget_tokens"] body `shouldBe` Just (effortToBudgetTokens "high")
      lookupKey "temperature" body `shouldBe` Nothing

    it "keeps temperature when thinking is off" $ do
      let req =
            ChatRequest
              { reqModel = "claude-haiku-4-5-20251001",
                reqConversation = [UserTurn "hi"],
                reqSystem = Nothing,
                reqMaxTokens = 4096,
                reqTemperature = Just 0.5,
                reqTools = [],
                reqThinking = Nothing
              }
          body = claudeBuildBody False req
      lookupNumber "temperature" body `shouldBe` Just 0.5

  describe "image request encoding" $ do
    it "encodes URL and base64 image parts" $ do
      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="
      let turn =
            UserMessage
              [ imageUrlPart "https://example.com/cat.jpg",
                b64,
                textPart "describe"
              ]
          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))
      contentTypes content `shouldBe` ["image", "image", "text"]
      case content of
        [Object urlO, Object b64O, Object textO] -> do
          nestedText ["source", "type"] (Object urlO) `shouldBe` Just "url"
          nestedText ["source", "url"] (Object urlO)
            `shouldBe` Just "https://example.com/cat.jpg"
          nestedText ["source", "type"] (Object b64O) `shouldBe` Just "base64"
          nestedText ["source", "media_type"] (Object b64O) `shouldBe` Just "image/png"
          nestedText ["source", "data"] (Object b64O) `shouldBe` Just "aGVsbG8="
          lookupText "text" (Object textO) `shouldBe` Just "describe"
        _ -> expectationFailure "expected image/image/text blocks"

  describe "cache_control breakpoints" $ do
    it "places cache_control on the marked block without reordering" $ do
      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="
      let turn =
            UserMessage
              [ imageUrlPart "https://example.com/cat.jpg",
                cacheEphemeral b64,
                cacheEphemeral (textPart "describe")
              ]
          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))
      contentTypes content `shouldBe` ["image", "image", "text"]
      case content of
        [Object urlO, Object b64O, Object textO] -> do
          KM.lookup "cache_control" urlO `shouldBe` Nothing
          nestedText ["cache_control", "type"] (Object b64O) `shouldBe` Just "ephemeral"
          nestedText ["cache_control", "type"] (Object textO) `shouldBe` Just "ephemeral"
          nestedText ["source", "data"] (Object b64O) `shouldBe` Just "aGVsbG8="
          lookupText "text" (Object textO) `shouldBe` Just "describe"
        _ -> expectationFailure "expected image/image/text blocks"

    it "uses a content-block array for a single cached text part" $ do
      let turn = UserMessage [cacheEphemeral (textPart "static context")]
          msg = head (encodeTurn "claude-haiku-4-5-20251001" turn)
      case lookupKey "content" msg of
        Just (Array arr) -> do
          V.length arr `shouldBe` 1
          nestedText ["cache_control", "type"] (V.head arr) `shouldBe` Just "ephemeral"
          lookupText "text" (V.head arr) `shouldBe` Just "static context"
        Just (String _) ->
          expectationFailure "cached single text must not encode as a bare string"
        _ -> expectationFailure "expected content array"

  describe "parseClaudeUsage" $ do
    it "extracts token counts" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-text.json"
      parseClaudeUsage val `shouldBe` Just (mkUsage 25 10)

    it "extracts token counts from tool_use response" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-tool-use.json"
      parseClaudeUsage val `shouldBe` Just (mkUsage 50 35)

    it "normalizes cache read/creation into total input without double-counting" $ do
      let val =
            object
              [ "usage"
                  .= object
                    [ "input_tokens" .= (50 :: Int),
                      "output_tokens" .= (20 :: Int),
                      "cache_read_input_tokens" .= (100_000 :: Int),
                      "cache_creation_input_tokens" .= (1_200 :: Int)
                    ]
              ]
      parseClaudeUsage val
        `shouldBe` Just
          Usage
            { usageInputTokens = 101_250,
              usageOutputTokens = 20,
              usageCacheReadTokens = 100_000,
              usageCacheCreationTokens = 1_200,
              usageTotalCost = 0
            }

    it "treats missing or zero cache fields as zero on recorded fixtures" $ do
      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-conversation-generated.json"
      case val of
        Array arr ->
          case V.toList arr of
            (Object first : _) ->
              case KM.lookup "response" first of
                Just resp ->
                  parseClaudeUsage resp
                    `shouldBe` Just (mkUsage 623 54)
                _ -> expectationFailure "missing response"
            _ -> expectationFailure "empty conversation array"
        _ -> expectationFailure "expected conversation array"

-- | Yield the full SSE payload once, then empty chunks (EOF).
mkBodyReader :: BS.ByteString -> IO (IO BS.ByteString)
mkBodyReader bs = do
  ref <- newIORef (Just bs)
  pure $ do
    m <- readIORef ref
    case m of
      Just chunk -> do
        writeIORef ref Nothing
        pure chunk
      Nothing -> pure BS.empty

messageContent :: Value -> [Value]
messageContent (Object o) =
  case KM.lookup "content" o of
    Just (Array a) -> V.toList a
    _ -> []
messageContent _ = []

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) =
  KM.lookup (K.fromText key) o >>= \case
    String t -> Just t
    _ -> Nothing
lookupText _ _ = Nothing

lookupNumber :: Text -> Value -> Maybe Double
lookupNumber key (Object o) =
  KM.lookup (K.fromText key) o >>= \case
    Number sci -> Just (toRealFloat sci)
    _ -> Nothing
lookupNumber _ _ = 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

nestedInt :: [Text] -> Value -> Maybe Int
nestedInt [key] (Object o) =
  KM.lookup (K.fromText key) o >>= \case
    Number sci -> toBoundedInteger sci
    _ -> Nothing
nestedInt (key : rest) (Object o) =
  KM.lookup (K.fromText key) o >>= nestedInt rest
nestedInt _ _ = Nothing