packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- | Gemini Streaming Protocol Handler
-- Converts OpenAI SSE stream to Gemini newline-delimited JSON format
module Louter.Protocol.GeminiStreaming
  ( -- * State Types
    ToolCallState(..)

    -- * Main Streaming Functions
  , convertOpenAIToGeminiStream
  , streamGeminiDeltas

    -- * Internal Functions (exported for testing)
  , processOpenAILineToGeminiStateful
  , processToolCallChunk
  , openAIChunkToGemini
  ) where

import Control.Monad (foldM)
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 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

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

-- | State for tracking tool call arguments during streaming
data ToolCallState = ToolCallState
  { toolCallId :: Maybe Text
  , toolCallName :: Maybe Text
  , toolCallArgs :: Text  -- Accumulated arguments string
  } deriving (Show)

-- ============================================================================
-- Main Streaming Functions
-- ============================================================================

-- | Convert OpenAI SSE stream to Gemini newline-delimited JSON format
convertOpenAIToGeminiStream :: (Builder -> IO ()) -> IO () -> HTTP.BodyReader -> IO ()
convertOpenAIToGeminiStream write flush bodyReader = do
  streamGeminiDeltas write flush bodyReader

-- | Stream Gemini deltas from OpenAI response
streamGeminiDeltas :: (Builder -> IO ()) -> IO () -> HTTP.BodyReader -> IO ()
streamGeminiDeltas write flush bodyReader = loop BS.empty (ToolCallState Nothing Nothing "")
  where
    loop acc toolState = do
      chunk <- HTTP.brRead bodyReader
      if BS.null chunk
        then pure ()
        else do
          let combined = acc <> chunk
              lines' = BS.split (fromIntegral $ fromEnum '\n') combined
          case lines' of
            [] -> loop BS.empty toolState
            [incomplete] -> loop incomplete toolState
            _ -> do
              let (completeLines, rest) = (init lines', last lines')
              newToolState <- foldM (processOpenAILineToGeminiStateful write flush) toolState completeLines
              loop rest newToolState

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

-- | Process a single OpenAI SSE line with state tracking for tool calls
processOpenAILineToGeminiStateful :: (Builder -> IO ()) -> IO () -> ToolCallState -> BS.ByteString -> IO ToolCallState
processOpenAILineToGeminiStateful write flush toolState line
  | BS.isPrefixOf "data: " line = do
      let jsonText = TE.decodeUtf8 $ BS.drop 6 line
      if jsonText == "[DONE]"
        then pure toolState  -- Gemini doesn't send [DONE]
        else case eitherDecode (BL.fromStrict $ TE.encodeUtf8 jsonText) of
          Right (Object openAIChunk) -> do
            -- Check if this chunk contains tool_calls
            let hasToolCalls = case HM.lookup "choices" openAIChunk of
                  Just (Array choices) | not (V.null choices) ->
                    case V.head choices of
                      Object choice -> case HM.lookup "delta" choice of
                        Just (Object delta) -> HM.member "tool_calls" delta
                        _ -> False
                      _ -> False
                  _ -> False

            -- Check if this is a finish_reason = "tool_calls" chunk with buffered state
            let finishReason = case HM.lookup "choices" openAIChunk of
                  Just (Array choices) | not (V.null choices) ->
                    case V.head choices of
                      Object choice -> HM.lookup "finish_reason" choice
                      _ -> Nothing
                  _ -> Nothing
                hasBufferedToolCall = toolCallName toolState /= Nothing

            if hasToolCalls
              then do
                -- Process tool call and update state
                (newState, maybeGeminiChunk) <- processToolCallChunk toolState openAIChunk
                case maybeGeminiChunk of
                  Just geminiChunk -> do
                    write (byteString $ BS8.pack "data: " <> BL.toStrict (encode geminiChunk) <> BS8.pack "\n\n")
                    flush
                  Nothing -> pure ()
                pure newState
              else if finishReason == Just (String "tool_calls") && hasBufferedToolCall
                then do
                  -- Emit buffered tool call
                  (newState, maybeGeminiChunk) <- processToolCallChunk toolState openAIChunk
                  case maybeGeminiChunk of
                    Just geminiChunk -> do
                      write (byteString $ BS8.pack "data: " <> BL.toStrict (encode geminiChunk) <> BS8.pack "\n\n")
                      flush
                    Nothing -> pure ()
                  pure newState
                else do
                  -- Regular text/reasoning chunk
                  let geminiChunk = openAIChunkToGemini openAIChunk
                  write (byteString $ BS8.pack "data: " <> BL.toStrict (encode geminiChunk) <> BS8.pack "\n\n")
                  flush
                  pure toolState
          _ -> pure toolState
  | otherwise = pure toolState

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

-- | Process tool call chunk, accumulating arguments until complete
processToolCallChunk :: ToolCallState -> HM.KeyMap Value -> IO (ToolCallState, Maybe Value)
processToolCallChunk state openAIChunk = do
  let choices = case HM.lookup "choices" openAIChunk of
        Just (Array cs) | not (V.null cs) -> V.head cs
        _ -> Object HM.empty
      delta = case choices of
        Object choice -> case HM.lookup "delta" choice of
          Just (Object d) -> d
          _ -> HM.empty
        _ -> HM.empty
      toolCalls = case HM.lookup "tool_calls" delta of
        Just (Array tcs) | not (V.null tcs) -> Just $ V.head tcs
        _ -> Nothing
      finishReason = case choices of
        Object choice -> HM.lookup "finish_reason" choice
        _ -> Nothing

  -- Check if we should emit based on finish_reason, even without new tool_calls
  case finishReason of
    Just (String "tool_calls") | toolCallName state /= Nothing -> do
      -- Arguments are complete, emit the buffered function call
      let parsedArgs = case eitherDecode (BL.fromStrict $ TE.encodeUtf8 (toolCallArgs state)) of
            Right val -> val
            Left _ -> object []
          geminiChunk = object
            [ "candidates" .= [object
                [ "content" .= object
                    [ "parts" .= [object $
                        [ "functionCall" .= object
                            ([ "name" .= n | Just n <- [toolCallName state] ] ++
                             [ "args" .= parsedArgs ])
                        ] ++ [ "id" .= i | Just i <- [toolCallId state] ]]
                    , "role" .= ("model" :: Text)
                    ]
                , "finishReason" .= ("tool_calls" :: Text)
                ]]
            , "usageMetadata" .= object
                [ "promptTokenCount" .= (0 :: Int)
                , "candidatesTokenCount" .= (0 :: Int)
                , "totalTokenCount" .= (0 :: Int)
                ]
            ]
      -- Reset state for next tool call
      pure (ToolCallState Nothing Nothing "", Just geminiChunk)
    _ -> do
      -- Process new tool_calls delta if present
      case toolCalls of
        Just (Object tc) -> do
          let tcId = case HM.lookup "id" tc of
                Just (String i) -> Just i
                _ -> Nothing
              tcFunc = case HM.lookup "function" tc of
                Just (Object f) -> f
                _ -> HM.empty
              funcName = case HM.lookup "name" tcFunc of
                Just (String n) -> Just n
                _ -> Nothing
              funcArgs = case HM.lookup "arguments" tcFunc of
                Just (String args) -> args
                _ -> ""

          -- Update state with new information
          let newId = case tcId of Just i -> Just i; Nothing -> toolCallId state
              newName = case funcName of Just n -> Just n; Nothing -> toolCallName state
              newArgs = toolCallArgs state <> funcArgs

          -- Still accumulating, don't emit yet
          pure (ToolCallState newId newName newArgs, Nothing)
        _ -> pure (state, Nothing)

-- ============================================================================
-- Chunk Conversion
-- ============================================================================

-- | Convert OpenAI chunk to Gemini chunk
openAIChunkToGemini :: HM.KeyMap Value -> Value
openAIChunkToGemini openAIChunk =
  let candidates = case HM.lookup "choices" openAIChunk of
        Just (Array choices) | not (V.null choices) ->
          V.toList $ V.map convertChoice choices
        _ -> []
  in object
      [ "candidates" .= candidates
      , "usageMetadata" .= object
          [ "promptTokenCount" .= (0 :: Int)
          , "candidatesTokenCount" .= (0 :: Int)
          , "totalTokenCount" .= (0 :: Int)
          ]
      ]
  where
    convertChoice (Object choice) =
      let delta = case HM.lookup "delta" choice of
            Just (Object d) -> d
            _ -> HM.empty
          finishReason = HM.lookup "finish_reason" choice
          -- Extract text from either content or reasoning
          textParts = case (HM.lookup "content" delta, HM.lookup "reasoning" delta) of
            (Just (String txt), _) -> [object ["text" .= txt]]
            (_, Just (String txt)) -> [object ["text" .= txt]]
            _ -> []
          -- Extract tool calls and convert to Gemini functionCall format
          toolCallParts = case HM.lookup "tool_calls" delta of
            Just (Array toolCalls) -> V.toList $ V.map convertToolCall toolCalls
            _ -> []
          parts = textParts ++ toolCallParts
      in object $
          [ "content" .= object
              [ "parts" .= parts
              , "role" .= ("model" :: Text)
              ]
          ] ++ (case finishReason of
                  Just r -> ["finishReason" .= r]
                  Nothing -> [])
    convertChoice _ = object []

    -- Convert OpenAI tool_call to Gemini functionCall part
    convertToolCall (Object tc) =
      let tcId = HM.lookup "id" tc
          tcFunc = case HM.lookup "function" tc of
            Just (Object f) -> f
            _ -> HM.empty
          funcName = HM.lookup "name" tcFunc
          funcArgs = HM.lookup "arguments" tcFunc
      in object $
          [ "functionCall" .= object
              ([ "name" .= fname | Just fname <- [funcName] ] ++
               [ "args" .= parseArgs args | Just args <- [funcArgs] ])
          ] ++ [ "id" .= tid | Just tid <- [tcId] ]
    convertToolCall _ = object []

    -- Parse function arguments string to JSON object
    parseArgs (String argsStr) = case eitherDecode (BL.fromStrict $ TE.encodeUtf8 argsStr) of
      Right val -> val
      Left _ -> object []
    parseArgs other = other