packages feed

louter-0.1.0.0: src/Louter/Protocol/AnthropicStreaming.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Anthropic Streaming Protocol Handler
-- Converts OpenAI SSE stream to Anthropic SSE format with stateful tool call buffering
module Louter.Protocol.AnthropicStreaming
  ( -- * State Types
    AnthropicStreamState(..)
  , AnthropicToolCallState(..)

    -- * Main Streaming Function
  , convertOpenAIToAnthropic

    -- * Internal Functions (exported for testing)
  , streamAnthropicDeltas
  , processOpenAILineToAnthropicStateful
  , processAnthropicChoice
  , processAnthropicToolCalls
  , processAnthropicSingleToolCall
  , emitAnthropicToolCalls
  ) where

import Control.Applicative ((<|>))
import Control.Monad (foldM, forM_, unless, when)
import Data.Aeson (Value(..), Object, encode, eitherDecode, object, (.=))
import qualified Data.Aeson.KeyMap as HM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Char8 as BS8
import Data.ByteString.Builder (Builder, byteString)
import qualified Data.HashMap.Strict as HMS
import Data.List (sortBy)
import Data.Ord (comparing)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector as V
import qualified Network.HTTP.Client as HTTP
import System.IO (hPutStrLn, stderr, hFlush)

-- ============================================================================
-- State Types
-- ============================================================================

-- | Tool call state for Anthropic streaming
data AnthropicToolCallState = AnthropicToolCallState
  { anthropicToolCallId :: Maybe Text
  , anthropicToolCallName :: Maybe Text
  , anthropicToolCallArgs :: Text  -- Accumulated arguments string
  } deriving (Show)

-- | Anthropic streaming state
data AnthropicStreamState = AnthropicStreamState
  { anthropicToolCalls :: HMS.HashMap Int AnthropicToolCallState
  , anthropicContentBlockStarted :: Bool
  , anthropicCurrentIndex :: Int
  } deriving (Show)

-- ============================================================================
-- Main Streaming Function
-- ============================================================================

-- | Convert OpenAI SSE stream to Anthropic SSE format
convertOpenAIToAnthropic :: (Builder -> IO ()) -> IO () -> HTTP.BodyReader -> IO ()
convertOpenAIToAnthropic write flush bodyReader = do
  -- Send message_start event
  write (byteString $ BS8.pack "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-haiku-20240307\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n")
  flush

  -- Stream with stateful tool call tracking
  let initialState = AnthropicStreamState HMS.empty False 0
  streamAnthropicDeltas write flush bodyReader initialState

-- ============================================================================
-- Streaming Loop
-- ============================================================================

-- | Stream Anthropic deltas with tool call state management
streamAnthropicDeltas :: (Builder -> IO ()) -> IO () -> HTTP.BodyReader -> AnthropicStreamState -> IO ()
streamAnthropicDeltas write flush bodyReader initialState = loop BS.empty initialState
  where
    loop acc state = do
      chunk <- HTTP.brRead bodyReader
      if BS.null chunk
        then finalize state
        else do
          let combined = acc <> chunk
              lines' = BS.split (fromIntegral $ fromEnum '\n') combined
          case lines' of
            [] -> loop BS.empty state
            [incomplete] -> loop incomplete state
            _ -> do
              let (completeLines, rest) = (init lines', last lines')
              newState <- foldM (processOpenAILineToAnthropicStateful write flush) state completeLines
              loop rest newState

    finalize state = do
      -- Close any open content blocks
      when (anthropicContentBlockStarted state) $ do
        write (byteString $ BS8.pack $ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":" ++ show (anthropicCurrentIndex state) ++ "}\n\n")
        flush

      -- Send message_delta with stop_reason
      write (byteString $ BS8.pack "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":10}}\n\n")
      flush

      -- Send message_stop
      write (byteString $ BS8.pack "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
      flush

-- ============================================================================
-- SSE Line Processing
-- ============================================================================

-- | Process a single OpenAI SSE line and convert to Anthropic format (stateful)
processOpenAILineToAnthropicStateful :: (Builder -> IO ()) -> IO () -> AnthropicStreamState -> BS.ByteString -> IO AnthropicStreamState
processOpenAILineToAnthropicStateful write flush state line
  | BS.isPrefixOf "data: " line = do
      let jsonText = TE.decodeUtf8 $ BS.drop 6 line
      if jsonText == "[DONE]"
        then pure state  -- Don't send [DONE] in Anthropic format
        else case eitherDecode (BL.fromStrict $ TE.encodeUtf8 jsonText) of
          Right (Object openAIChunk) -> do
            -- Extract choices
            case HM.lookup "choices" openAIChunk of
              Just (Array choices) | not (V.null choices) -> do
                case V.head choices of
                  Object choice -> processAnthropicChoice write flush state choice openAIChunk
                  _ -> pure state
              _ -> pure state
          _ -> pure state
  | otherwise = pure state

-- ============================================================================
-- Choice Processing
-- ============================================================================

-- | Process a single choice and update Anthropic state
processAnthropicChoice :: (Builder -> IO ()) -> IO () -> AnthropicStreamState -> Object -> Object -> IO AnthropicStreamState
processAnthropicChoice write flush state choice _openAIChunk = do
  let finishReason = case HM.lookup "finish_reason" choice of
        Just (String reason) -> Just reason
        _ -> Nothing

  case HM.lookup "delta" choice of
    Just (Object delta) -> do
      -- Check for text content
      let hasContent = HM.member "content" delta
      let hasToolCalls = HM.member "tool_calls" delta

      newState <- if hasContent
        then do
          -- Start text content block if not started
          unless (anthropicContentBlockStarted state) $ do
            let idx = anthropicCurrentIndex state
            let startEvent = object
                  [ "type" .= ("content_block_start" :: Text)
                  , "index" .= idx
                  , "content_block" .= object
                      [ "type" .= ("text" :: Text)
                      , "text" .= ("" :: Text)
                      ]
                  ]
            write (byteString $ BS8.pack "event: content_block_start\ndata: " <> BL.toStrict (encode startEvent) <> BS8.pack "\n\n")
            flush

          -- Send content delta
          case HM.lookup "content" delta of
            Just (String content) -> do
              let idx = anthropicCurrentIndex state
              let deltaEvent = object
                    [ "type" .= ("content_block_delta" :: Text)
                    , "index" .= idx
                    , "delta" .= object
                        [ "type" .= ("text_delta" :: Text)
                        , "text" .= content
                        ]
                    ]
              write (byteString $ BS8.pack "event: content_block_delta\ndata: " <> BL.toStrict (encode deltaEvent) <> BS8.pack "\n\n")
              flush
            _ -> pure ()

          pure $ state { anthropicContentBlockStarted = True }

        else if hasToolCalls
          then processAnthropicToolCalls write flush state delta finishReason
          else pure state

      -- Handle finish_reason
      finalState <- case finishReason of
        Just "tool_calls" -> do
          -- Close text block if open before emitting tool calls
          stateAfterText <- if anthropicContentBlockStarted newState
            then do
              let idx = anthropicCurrentIndex newState
              write (byteString $ BS8.pack $ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":" ++ show idx ++ "}\n\n")
              flush
              -- Increment index after closing text block
              pure newState { anthropicContentBlockStarted = False, anthropicCurrentIndex = anthropicCurrentIndex newState + 1 }
            else pure newState
          -- Emit buffered tool calls
          emitAnthropicToolCalls write flush stateAfterText

        Just _ ->
          -- Close text content block if open
          if anthropicContentBlockStarted newState
            then do
              let idx = anthropicCurrentIndex newState
              write (byteString $ BS8.pack $ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":" ++ show idx ++ "}\n\n")
              flush
              -- Increment index after closing text block
              pure newState { anthropicContentBlockStarted = False, anthropicCurrentIndex = anthropicCurrentIndex newState + 1 }
            else pure newState

        Nothing -> pure newState

      pure finalState

    _ -> pure state

-- ============================================================================
-- Tool Call Processing
-- ============================================================================

-- | Process tool calls delta and buffer them
processAnthropicToolCalls :: (Builder -> IO ()) -> IO () -> AnthropicStreamState -> Object -> Maybe Text -> IO AnthropicStreamState
processAnthropicToolCalls _write _flush state delta _finishReason = do
  case HM.lookup "tool_calls" delta of
    Just (Array toolCallsArray) -> do
      foldM (processAnthropicSingleToolCall _write _flush) state (V.toList toolCallsArray)
    _ -> pure state

-- | Process a single tool call fragment
processAnthropicSingleToolCall :: (Builder -> IO ()) -> IO () -> AnthropicStreamState -> Value -> IO AnthropicStreamState
processAnthropicSingleToolCall _write _flush state (Object tcDelta) = do
  let tcIndex = case HM.lookup "index" tcDelta of
        Just (Number n) -> floor n :: Int
        _ -> 0

  let tcId = case HM.lookup "id" tcDelta of
        Just (String i) -> Just i
        _ -> Nothing

  let tcFunc = HM.lookup "function" tcDelta

  let tcName = case tcFunc of
        Just (Object f) -> case HM.lookup "name" f of
          Just (String n) -> Just n
          _ -> Nothing
        _ -> Nothing

  let tcArgs = case tcFunc of
        Just (Object f) -> case HM.lookup "arguments" f of
          Just (String a) -> a
          _ -> ""
        _ -> ""

  -- Update state
  let currentTc = HMS.lookupDefault (AnthropicToolCallState Nothing Nothing "") tcIndex (anthropicToolCalls state)
  let updatedTc = AnthropicToolCallState
        { anthropicToolCallId = tcId <|> anthropicToolCallId currentTc
        , anthropicToolCallName = tcName <|> anthropicToolCallName currentTc
        , anthropicToolCallArgs = anthropicToolCallArgs currentTc <> tcArgs
        }
  let newToolCalls = HMS.insert tcIndex updatedTc (anthropicToolCalls state)

  pure $ state { anthropicToolCalls = newToolCalls }

processAnthropicSingleToolCall _ _ state _ = pure state

-- | Emit buffered tool calls as Anthropic tool_use blocks
emitAnthropicToolCalls :: (Builder -> IO ()) -> IO () -> AnthropicStreamState -> IO AnthropicStreamState
emitAnthropicToolCalls write flush state = do
  let toolCallsList = HMS.toList (anthropicToolCalls state)
  let sortedToolCalls = sortBy (comparing fst) toolCallsList

  forM_ sortedToolCalls $ \(tcIndex, tcState) -> do
    case (anthropicToolCallId tcState, anthropicToolCallName tcState) of
      (Just toolId, Just toolName) -> do
        -- Log tool call for debugging
        hPutStrLn stderr $ "[Anthropic] Emitting tool_use: id=" <> T.unpack toolId
                <> ", name=" <> T.unpack toolName
                <> ", args=" <> T.unpack (anthropicToolCallArgs tcState)
        hFlush stderr

        -- Parse arguments as JSON
        let argsJson = case eitherDecode (BL.fromStrict $ TE.encodeUtf8 (anthropicToolCallArgs tcState)) of
              Right val -> val
              Left _ -> object []

        -- Calculate content block index (after text blocks)
        let blockIndex = anthropicCurrentIndex state + tcIndex

        -- Send content_block_start
        -- IMPORTANT: tool_use content_block must include empty input object
        let startEvent = object
              [ "type" .= ("content_block_start" :: Text)
              , "index" .= blockIndex
              , "content_block" .= object
                  [ "type" .= ("tool_use" :: Text)
                  , "id" .= toolId
                  , "name" .= toolName
                  , "input" .= object []  -- Empty input object required by Anthropic spec
                  ]
              ]
        write (byteString $ BS8.pack "event: content_block_start\ndata: " <> BL.toStrict (encode startEvent) <> BS8.pack "\n\n")
        flush

        -- Send input_json_delta
        -- partial_json should be the raw JSON string, not re-encoded
        let deltaEvent = object
              [ "type" .= ("content_block_delta" :: Text)
              , "index" .= blockIndex
              , "delta" .= object
                  [ "type" .= ("input_json_delta" :: Text)
                  , "partial_json" .= anthropicToolCallArgs tcState
                  ]
              ]
        write (byteString $ BS8.pack "event: content_block_delta\ndata: " <> BL.toStrict (encode deltaEvent) <> BS8.pack "\n\n")
        flush

        -- Send content_block_stop
        let stopEvent = object
              [ "type" .= ("content_block_stop" :: Text)
              , "index" .= blockIndex
              ]
        write (byteString $ BS8.pack "event: content_block_stop\ndata: " <> BL.toStrict (encode stopEvent) <> BS8.pack "\n\n")
        flush

      _ -> pure ()

  pure $ state { anthropicCurrentIndex = anthropicCurrentIndex state + length sortedToolCalls }