packages feed

llm-simple-0.2.0.0: src/LLM/Providers/Claude.hs

module LLM.Providers.Claude
  ( claudeGateway,
    claudeGatewayWith,
    claudeProvider,
    claudeProviderWith,
    parseClaudeResponse,
    parseClaudeStream,
    parseClaudeUsage,
    claudeBuildBody,
    encodeTurn,
    effortToBudgetTokens,
  )
where

import Data.Aeson
  ( KeyValue ((.=)),
    Value (Object, String),
    decodeStrict',
    encode,
    object,
    toJSON,
    withObject,
    (.!=),
    (.:),
    (.:?),
  )
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Object, Pair, Parser, parseMaybe)
import Data.ByteString qualified as BS
import Data.Foldable (for_)
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding (decodeUtf8)
import LLM.Core.LLMProvider (LLMProvider (..), toGateway)
import LLM.Core.ProviderUtils (handleStreamResponse, lenientConfig, stripJsonFences)
import LLM.Core.SSE (SSEEvent (sseData, sseEvent), readSSEEvents)
import LLM.Core.Types
  ( CacheHint (..),
    ChatRequest
      ( reqConversation,
        reqMaxTokens,
        reqModel,
        reqSystem,
        reqTemperature,
        reqThinking,
        reqTools
      ),
    ContentPart (..),
    ImageSource (..),
    LLMError (EmptyResponse),
    LLMGateway,
    LLMResult,
    LLMTextResult,
    PartBody (..),
    ProviderOpaque (..),
    StreamEvent (..),
    ThinkingContent (..),
    ThinkingMode (..),
    ToolCall (..),
    ToolDef (toolDescription, toolName, toolParameters),
    ToolResult (trCallId, trContent),
    Turn (..),
    mkChatResponse,
    mkToolCall,
    stripForeignOpaque,
    textPart,
    thinkingPart,
    toolCallPart,
    pattern UserTurn,
  )
import LLM.Core.Usage (Usage (..), emptyUsage)
import Network.HTTP.Client qualified as HC
import Network.HTTP.Req
  ( Option,
    POST (POST),
    ReqBodyJson (ReqBodyJson),
    Url,
    header,
    https,
    jsonResponse,
    req,
    reqBr,
    responseBody,
    responseStatusCode,
    runReq,
    (/:),
  )

claudeProviderName :: Text
claudeProviderName = "claude"

-- | Create a LLMGateway for the Claude provider at api.anthropic.com.
claudeGateway :: Text -> LLMGateway
claudeGateway apiKey = toGateway $ claudeProvider apiKey

-- | Create a Claude-compatible client with a custom base URL (origin).
-- The library appends @/v1/messages@.
claudeGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
claudeGatewayWith baseUrl baseOpts apiKey = toGateway (claudeProviderWith baseUrl baseOpts apiKey)

-- | Create a LLMProvider for the Claude provider at api.anthropic.com.
claudeProvider :: Text -> LLMProvider
claudeProvider = claudeProviderWith (https "api.anthropic.com") mempty

-- | Claude-compatible provider with a custom base URL (origin).
-- The library appends @/v1/messages@.
claudeProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
claudeProviderWith baseUrl baseOpts apiKey =
  LLMProvider
    { providerName = claudeProviderName,
      buildBody = claudeBuildBody,
      sendRequest = sendRequest,
      sendStreamRequest = \body callback ->
        runReq lenientConfig $ do
          let url = baseUrl /: "v1" /: "messages"
              opts = baseOpts <> claudeAuthOpts apiKey
              modelHint = fromMaybe "" (parseMaybe (withObject "body" (.: "model")) body)
          reqBr POST url (ReqBodyJson body) opts $ \resp ->
            handleStreamResponse resp (\br -> parseClaudeStream modelHint (HC.brRead br) callback),
      parseResponse = pure . parseClaudeResponse,
      buildObjectBody = \r schema ->
        let schemaText = TL.toStrict . decodeUtf8 $ encode schema
            instruction = "Respond with a raw JSON object matching this schema. No markdown, no explanation, no code fences:\n" <> schemaText
            conv' = r.reqConversation <> [UserTurn instruction]
         in claudeBuildBody False (r {reqConversation = conv'}),
      sendObjectRequest = sendRequest,
      parseObjectResponse = parseClaudeObjectResponse
    }
  where
    sendRequest body =
      runReq lenientConfig $ do
        let url = baseUrl /: "v1" /: "messages"
            opts = baseOpts <> claudeAuthOpts apiKey
        resp <- req POST url (ReqBodyJson body) jsonResponse opts
        pure (responseStatusCode resp, responseBody resp)

claudeAuthOpts :: Text -> Option scheme
claudeAuthOpts apiKey =
  header "x-api-key" (encodeUtf8 apiKey)
    <> header "anthropic-version" "2023-06-01"

-- | Map catalog effort labels to Claude extended-thinking @budget_tokens@.
effortToBudgetTokens :: Text -> Int
effortToBudgetTokens = \case
  "low" -> 1024
  "medium" -> 4096
  "high" -> 10000
  "max" -> 16000
  other ->
    case reads (T.unpack other) of
      [(n, "")] | n >= 1024 -> n
      _ -> 10000

claudeBuildBody :: Bool -> ChatRequest -> Value
claudeBuildBody stream r =
  object $
    [ "model" .= r.reqModel,
      "max_tokens" .= r.reqMaxTokens,
      "messages" .= concatMap (encodeTurn r.reqModel) r.reqConversation
    ]
      ++ ["system" .= sys | Just sys <- [r.reqSystem]]
      ++ temperaturePairs r
      ++ thinkingPairs r
      ++ ["tools" .= map encodeToolDef r.reqTools | not (null r.reqTools)]
      ++ ["stream" .= True | stream]

-- | Anthropic rejects non-default temperature while thinking is active.
-- Omit temperature whenever thinking is enabled.
temperaturePairs :: ChatRequest -> [Pair]
temperaturePairs r =
  case r.reqThinking of
    Just tm | tm.tmEnabled -> []
    _ -> ["temperature" .= t | Just t <- [r.reqTemperature]]

thinkingPairs :: ChatRequest -> [Pair]
thinkingPairs r =
  case r.reqThinking of
    Just tm
      | tm.tmEnabled ->
          let budget = maybe 10000 effortToBudgetTokens tm.tmEffort
           in [ "thinking"
                  .= object
                    [ "type" .= ("enabled" :: Text),
                      "budget_tokens" .= budget
                    ]
              ]
    Just tm
      | not tm.tmEnabled ->
          ["thinking" .= object ["type" .= ("disabled" :: Text)]]
    _ -> []

encodeTurn :: Text -> Turn -> [Value]
encodeTurn _ (UserMessage parts) =
  [ object
      [ "role" .= ("user" :: Text),
        "content" .= encodeUserContent parts
      ]
  ]
encodeTurn currentModel (AssistantMessage parts) =
  [ object
      [ "role" .= ("assistant" :: Text),
        "content" .= mapMaybe (encodeAssistantPart currentModel . stripForeignOpaque claudeProviderName) parts
      ]
  ]
encodeTurn _ (ToolTurn results) =
  [ object
      [ "role" .= ("user" :: Text),
        "content" .= map encodeToolResult results
      ]
  ]

encodeUserContent :: [ContentPart] -> Value
-- Bare string only when a single unannotated text part (fixture-compatible).
encodeUserContent [ContentPart (TextPart t) Nothing] = String t
encodeUserContent parts = toJSON (mapMaybe encodeUserPart parts)
  where
    encodeUserPart (ContentPart (TextPart t) hint) =
      Just $ withCacheControl hint $ object ["type" .= ("text" :: Text), "text" .= t]
    encodeUserPart (ContentPart (ImagePart src) hint) =
      Just $ withCacheControl hint $ encodeImageBlock src
    encodeUserPart _ = Nothing

encodeImageBlock :: ImageSource -> Value
encodeImageBlock (ImageUrl url) =
  object
    [ "type" .= ("image" :: Text),
      "source"
        .= object
          [ "type" .= ("url" :: Text),
            "url" .= url
          ]
    ]
encodeImageBlock (ImageBase64 mediaType data_) =
  object
    [ "type" .= ("image" :: Text),
      "source"
        .= object
          [ "type" .= ("base64" :: Text),
            "media_type" .= mediaType,
            "data" .= data_
          ]
    ]

-- | Attach Anthropic @cache_control@ for the default ephemeral TTL.
withCacheControl :: Maybe CacheHint -> Value -> Value
withCacheControl (Just CacheEphemeral) (Object o) =
  Object $ KM.insert "cache_control" (object ["type" .= ("ephemeral" :: Text)]) o
withCacheControl _ v = v

encodeAssistantPart :: Text -> ContentPart -> Maybe Value
encodeAssistantPart currentModel (ContentPart (ThinkingPart tc) hint) =
  case opaqueForClaude currentModel tc.thinkingOpaque of
    Just payload -> Just $ withCacheControl hint payload
    Nothing ->
      -- Without a Claude signature we must not invent a thinking block.
      Nothing
encodeAssistantPart _ (ContentPart (TextPart t) hint)
  | T.null t = Nothing
  | otherwise =
      Just $
        withCacheControl hint $
          object ["type" .= ("text" :: Text), "text" .= t]
encodeAssistantPart _ (ContentPart (ToolCallPart tc) hint) =
  Just $ withCacheControl hint $ encodeToolUseBlock tc
encodeAssistantPart _ (ContentPart (ImagePart _) _) = Nothing

opaqueForClaude :: Text -> Maybe ProviderOpaque -> Maybe Value
opaqueForClaude _ (Just o)
  | o.poProvider == claudeProviderName = Just o.poPayload
opaqueForClaude _ _ = Nothing

encodeToolDef :: ToolDef -> Value
encodeToolDef td =
  object
    [ "name" .= td.toolName,
      "description" .= td.toolDescription,
      "input_schema" .= td.toolParameters
    ]

encodeToolUseBlock :: ToolCall -> Value
encodeToolUseBlock tc =
  object
    [ "type" .= ("tool_use" :: Text),
      "id" .= tc.tcId,
      "name" .= tc.tcName,
      "input" .= tc.tcArguments
    ]

encodeToolResult :: ToolResult -> Value
encodeToolResult tr =
  object
    [ "type" .= ("tool_result" :: Text),
      "tool_use_id" .= tr.trCallId,
      "content" .= tr.trContent
    ]

parseClaudeResponse :: Value -> LLMTextResult
parseClaudeResponse v = case parseMaybe (go modelVer) v of
  Nothing -> Left EmptyResponse
  Just parts ->
    case parts of
      [] -> Left EmptyResponse
      _ -> Right (mkChatResponse parts (parseClaudeUsage v))
  where
    modelVer = parseMaybe (withObject "ClaudeResponse" (.: "model")) v

    go :: Maybe Text -> Value -> Parser [ContentPart]
    go mv = withObject "ClaudeResponse" $ \o -> do
      content <- o .: "content" :: Parser [Value]
      mapM (parseBlock mv) content

parseBlock :: Maybe Text -> Value -> Parser ContentPart
parseBlock mv = withObject "content_block" $ \o -> do
  typ <- o .: "type" :: Parser Text
  case typ of
    "text" -> textPart <$> o .: "text"
    "thinking" -> do
      thinkingTxt <- o .:? "thinking" :: Parser (Maybe Text)
      signature <- o .:? "signature" :: Parser (Maybe Text)
      let payload =
            object $
              ["type" .= ("thinking" :: Text)]
                ++ ["thinking" .= t | Just t <- [thinkingTxt]]
                ++ ["signature" .= s | Just s <- [signature]]
          opaque =
            ProviderOpaque
              { poProvider = claudeProviderName,
                poModel = mv,
                poPayload = payload
              }
          mText = case thinkingTxt of
            Just t | not (T.null t) -> Just t
            _ -> Nothing
      pure $ thinkingPart (ThinkingContent mText (Just opaque))
    "redacted_thinking" -> do
      data_ <- o .: "data" :: Parser Text
      let payload =
            object
              [ "type" .= ("redacted_thinking" :: Text),
                "data" .= data_
              ]
          opaque =
            ProviderOpaque
              { poProvider = claudeProviderName,
                poModel = mv,
                poPayload = payload
              }
      pure $ thinkingPart (ThinkingContent Nothing (Just opaque))
    "tool_use" -> do
      cid <- o .: "id"
      name <- o .: "name"
      args <- o .: "input"
      pure $ toolCallPart (mkToolCall cid name args)
    _ -> fail $ "Unknown content block type: " <> T.unpack typ

parseClaudeUsage :: Value -> Maybe Usage
parseClaudeUsage = parseMaybe $ withObject "ClaudeResponse" $ \o -> do
  u <- o .: "usage"
  withObject "usage" parseClaudeUsageObject u

parseClaudeUsageObject :: Object -> Parser Usage
parseClaudeUsageObject uo = do
  -- Anthropic: input_tokens is uncached-only; total = uncached + read + creation.
  uncached <- uo .: "input_tokens"
  output <- uo .: "output_tokens"
  cacheRead <- uo .:? "cache_read_input_tokens" .!= 0
  cacheCreate <- uo .:? "cache_creation_input_tokens" .!= 0
  pure
    Usage
      { usageInputTokens = uncached + cacheRead + cacheCreate,
        usageOutputTokens = output,
        usageCacheReadTokens = cacheRead,
        usageCacheCreationTokens = cacheCreate,
        usageTotalCost = 0
      }

parseClaudeObjectResponse :: Value -> IO (LLMResult (Value, Maybe Usage))
parseClaudeObjectResponse v = case parseMaybe go v of
  Nothing -> pure $ Left EmptyResponse
  Just text -> case decodeStrict' (encodeUtf8 (stripJsonFences text)) of
    Nothing -> pure $ Left EmptyResponse
    Just obj -> pure $ Right (obj, parseClaudeUsage v)
  where
    go :: Value -> Parser Text
    go = withObject "ClaudeResponse" $ \o -> do
      content <- o .: "content" :: Parser [Value]
      texts <- mapMaybeM textOf content
      case texts of
        (t : _) -> pure t
        _ -> fail "No content"
    textOf = parseMaybe $ withObject "content_block" $ \o -> do
      typ <- o .: "type" :: Parser Text
      case typ of
        "text" -> o .: "text"
        _ -> fail "not text"
    mapMaybeM f xs = pure (mapMaybe f xs)

-- Streaming ------------------------------------------------------------------

data StreamBlock
  = StreamText Text
  | StreamThinking Text (Maybe Text) -- text, signature
  | StreamRedacted Text -- data
  | StreamTool Text Text Value -- id, name, args

parseClaudeStream :: Text -> IO BS.ByteString -> (StreamEvent -> IO ()) -> IO LLMTextResult
parseClaudeStream modelHint readChunk callback = do
  blocksRef <- newIORef ([] :: [StreamBlock])
  usageRef <- newIORef emptyUsage
  toolAccRef <- newIORef (Nothing :: Maybe (Text, Text, Text))
  thinkingAccRef <- newIORef (Nothing :: Maybe (Text, Maybe Text)) -- text, signature
  redactedRef <- newIORef (Nothing :: Maybe Text)
  modelRef <- newIORef modelHint
  readSSEEvents readChunk $ \sse -> do
    case sse.sseEvent of
      Just "message_start" ->
        case decodeStrict' (encodeUtf8 sse.sseData) of
          Just v -> do
            case parseMaybe parseMessageStartUsage v of
              Just startUsage -> modifyIORef' usageRef $ \u ->
                u
                  { usageInputTokens = startUsage.usageInputTokens,
                    usageCacheReadTokens = startUsage.usageCacheReadTokens,
                    usageCacheCreationTokens = startUsage.usageCacheCreationTokens
                  }
              Nothing -> pure ()
            for_
              (parseMaybe parseMessageStartModel v)
              (writeIORef modelRef)
          Nothing -> pure ()
      Just "content_block_start" ->
        case decodeStrict' (encodeUtf8 sse.sseData) of
          Just v -> case parseMaybe parseContentBlockStart v of
            Just (StartTool cid name) -> writeIORef toolAccRef (Just (cid, name, ""))
            Just StartThinking -> writeIORef thinkingAccRef (Just ("", Nothing))
            Just (StartRedacted data_) -> writeIORef redactedRef (Just data_)
            Just StartText -> pure ()
            Nothing -> pure ()
          Nothing -> pure ()
      Just "content_block_delta" ->
        case decodeStrict' (encodeUtf8 sse.sseData) of
          Just v -> do
            case parseMaybe parseTextDelta v of
              Just txt -> do
                modifyIORef' blocksRef (StreamText txt :)
                callback (StreamDelta txt)
              Nothing -> pure ()
            case parseMaybe parseThinkingDelta v of
              Just txt -> do
                modifyIORef' thinkingAccRef $ fmap (\(acc, sig) -> (acc <> txt, sig))
                callback (StreamReasoningDelta txt)
              Nothing -> pure ()
            case parseMaybe parseSignatureDelta v of
              Just sig ->
                modifyIORef' thinkingAccRef $ fmap (\(acc, _) -> (acc, Just sig))
              Nothing -> pure ()
            case parseMaybe parseInputJsonDelta v of
              Just fragment ->
                modifyIORef' toolAccRef $ fmap (\(cid, name, acc) -> (cid, name, acc <> fragment))
              Nothing -> pure ()
          Nothing -> pure ()
      Just "content_block_stop" -> do
        mThinking <- readIORef thinkingAccRef
        case mThinking of
          Just (txt, mSig) -> do
            modifyIORef' blocksRef (StreamThinking txt mSig :)
            writeIORef thinkingAccRef Nothing
          Nothing -> pure ()
        mRedacted <- readIORef redactedRef
        case mRedacted of
          Just data_ -> do
            modifyIORef' blocksRef (StreamRedacted data_ :)
            writeIORef redactedRef Nothing
          Nothing -> pure ()
        mTool <- readIORef toolAccRef
        case mTool of
          Just (cid, name, jsonStr) -> do
            let args = case decodeStrict' (encodeUtf8 jsonStr) of
                  Just a -> a
                  Nothing -> String jsonStr
                tc = mkToolCall cid name args
            modifyIORef' blocksRef (StreamTool cid name args :)
            callback (StreamToolCall tc)
            writeIORef toolAccRef Nothing
          Nothing -> pure ()
      Just "message_delta" ->
        case decodeStrict' (encodeUtf8 sse.sseData) of
          Just v -> case parseMaybe parseMessageDeltaUsage v of
            Just outputToks -> modifyIORef' usageRef $ \u -> u {usageOutputTokens = outputToks}
            Nothing -> pure ()
          Nothing -> pure ()
      _ -> pure ()
  rawBlocks <- reverse <$> readIORef blocksRef
  usage <- readIORef usageRef
  model <- readIORef modelRef
  let parts = map (streamBlockToPart model) rawBlocks
  if null parts
    then pure $ Left EmptyResponse
    else pure $ Right (mkChatResponse parts (Just usage))

streamBlockToPart :: Text -> StreamBlock -> ContentPart
streamBlockToPart _ (StreamText t) = textPart t
streamBlockToPart model (StreamThinking txt mSig) =
  let payload =
        object $
          ["type" .= ("thinking" :: Text)]
            ++ ["thinking" .= txt | not (T.null txt)]
            ++ ["signature" .= s | Just s <- [mSig]]
      opaque =
        ProviderOpaque
          { poProvider = claudeProviderName,
            poModel = if T.null model then Nothing else Just model,
            poPayload = payload
          }
      mText = if T.null txt then Nothing else Just txt
   in thinkingPart (ThinkingContent mText (Just opaque))
streamBlockToPart model (StreamRedacted data_) =
  let payload =
        object
          [ "type" .= ("redacted_thinking" :: Text),
            "data" .= data_
          ]
      opaque =
        ProviderOpaque
          { poProvider = claudeProviderName,
            poModel = if T.null model then Nothing else Just model,
            poPayload = payload
          }
   in thinkingPart (ThinkingContent Nothing (Just opaque))
streamBlockToPart _model (StreamTool cid name args) =
  toolCallPart (mkToolCall cid name args)

data BlockStart
  = StartText
  | StartThinking
  | StartRedacted Text
  | StartTool Text Text

parseMessageStartUsage :: Value -> Parser Usage
parseMessageStartUsage = withObject "message_start" $ \o -> do
  msg <- o .: "message"
  withObject "message" (\mo -> do u <- mo .: "usage"; withObject "usage" parseClaudeUsageObject u) msg

parseMessageStartModel :: Value -> Parser Text
parseMessageStartModel = withObject "message_start" $ \o -> do
  msg <- o .: "message"
  withObject "message" (.: "model") msg

parseMessageDeltaUsage :: Value -> Parser Int
parseMessageDeltaUsage = withObject "message_delta" $ \o -> do
  u <- o .: "usage"
  withObject "usage" (.: "output_tokens") u

parseContentBlockStart :: Value -> Parser BlockStart
parseContentBlockStart = withObject "content_block_start" $ \o -> do
  cb <- o .: "content_block"
  withObject
    "content_block"
    ( \cbo -> do
        typ <- cbo .: "type" :: Parser Text
        case typ of
          "tool_use" -> StartTool <$> cbo .: "id" <*> cbo .: "name"
          "thinking" -> pure StartThinking
          "redacted_thinking" -> StartRedacted <$> (cbo .:? "data" .!= "")
          "text" -> pure StartText
          _ -> fail "unknown block start"
    )
    cb

parseTextDelta :: Value -> Parser Text
parseTextDelta = withObject "delta_event" $ \o -> do
  d <- o .: "delta"
  withObject
    "delta"
    ( \d' -> do
        typ <- d' .: "type" :: Parser Text
        case typ of
          "text_delta" -> d' .: "text"
          _ -> fail "not text_delta"
    )
    d

parseThinkingDelta :: Value -> Parser Text
parseThinkingDelta = withObject "delta_event" $ \o -> do
  d <- o .: "delta"
  withObject
    "delta"
    ( \d' -> do
        typ <- d' .: "type" :: Parser Text
        case typ of
          "thinking_delta" -> d' .: "thinking"
          _ -> fail "not thinking_delta"
    )
    d

parseSignatureDelta :: Value -> Parser Text
parseSignatureDelta = withObject "delta_event" $ \o -> do
  d <- o .: "delta"
  withObject
    "delta"
    ( \d' -> do
        typ <- d' .: "type" :: Parser Text
        case typ of
          "signature_delta" -> d' .: "signature"
          _ -> fail "not signature_delta"
    )
    d

parseInputJsonDelta :: Value -> Parser Text
parseInputJsonDelta = withObject "delta_event" $ \o -> do
  d <- o .: "delta"
  withObject
    "delta"
    ( \d' -> do
        typ <- d' .: "type" :: Parser Text
        case typ of
          "input_json_delta" -> d' .: "partial_json"
          _ -> fail "not input_json_delta"
    )
    d