packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- | Gemini <-> OpenAI Protocol Converter
-- This module handles bidirectional conversion between Gemini and OpenAI formats
module Louter.Protocol.GeminiConverter
  ( -- * Request Conversion (Gemini -> OpenAI)
    geminiToOpenAI
  , convertGeminiContentToMessage
  , convertGeminiToolsToOpenAI

    -- * Response Conversion (OpenAI -> Gemini)
  , openAIResponseToGemini
  ) where

import Data.Aeson (Value(..), Object, encode, object, (.=))
import qualified Data.Aeson.KeyMap as HM
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector as V

-- ============================================================================
-- Request Conversion: Gemini -> OpenAI
-- ============================================================================

-- | Convert Gemini request to OpenAI format
geminiToOpenAI :: Text -> Bool -> Value -> Either Text Value
geminiToOpenAI modelName streaming (Object obj) = do
  -- Extract contents array
  contents <- case HM.lookup "contents" obj of
    Just (Array cs) -> Right $ V.toList cs
    _ -> Left "Missing 'contents' field"

  -- Convert contents to OpenAI messages
  messages <- mapM convertGeminiContentToMessage contents

  -- Extract system instruction if present
  let systemMsg = case HM.lookup "systemInstruction" obj of
        Just (Object sysInst) -> case HM.lookup "parts" sysInst of
          Just (Array parts) | not (V.null parts) ->
            case V.head parts of
              Object part -> case HM.lookup "text" part of
                Just (String txt) -> [object ["role" .= ("system" :: Text), "content" .= txt]]
                _ -> []
              _ -> []
          _ -> []
        _ -> []

  -- Extract generation config
  let temperature = case HM.lookup "generationConfig" obj of
        Just (Object cfg) -> HM.lookup "temperature" cfg
        _ -> Nothing

  let maxTokens = case HM.lookup "generationConfig" obj of
        Just (Object cfg) -> HM.lookup "maxOutputTokens" cfg
        _ -> Nothing

  -- Extract tools
  let tools = case HM.lookup "tools" obj of
        Just (Array ts) -> Just $ V.toList ts
        _ -> Nothing

  Right $ object $
    [ "model" .= modelName
    , "messages" .= (systemMsg ++ messages)
    , "stream" .= streaming
    ] ++ (case temperature of Just t -> ["temperature" .= t]; Nothing -> [])
      ++ (case maxTokens of Just m -> ["max_tokens" .= m]; Nothing -> [])
      ++ (case tools of Just t -> ["tools" .= convertGeminiToolsToOpenAI t]; Nothing -> [])

geminiToOpenAI _ _ _ = Left "Request must be a JSON object"

-- | Convert Gemini content to OpenAI message
convertGeminiContentToMessage :: Value -> Either Text Value
convertGeminiContentToMessage (Object content) = do
  role <- case HM.lookup "role" content of
    Just (String r) -> Right r
    _ -> Right "user"  -- Default to user

  -- Convert Gemini roles to OpenAI roles
  -- Gemini uses "model", OpenAI uses "assistant"
  let openAIRole = if role == "model" then "assistant" else role

  parts <- case HM.lookup "parts" content of
    Just (Array ps) -> Right $ V.toList ps
    _ -> Left "Missing 'parts' in content"

  -- Check if any part is a functionResponse (tool result)
  let hasFunctionResponse = any isFunctionResponse parts

  if hasFunctionResponse && not (null parts)
    then do
      -- Convert function response to OpenAI tool message format
      -- Gemini can have multiple function responses in one message
      let toolMessages = map convertFunctionResponsePart (filter isFunctionResponse parts)
      -- For now, return the first tool message (OpenAI expects one tool result per message)
      case toolMessages of
        (msg:_) -> Right msg
        [] -> Left "Function response part missing required fields"
    else do
      -- Regular text/image content - convert to OpenAI format
      let convertedParts = map convertPart parts
          convertPart (Object part)
            -- Text part: {"text": "..."}
            | Just (String txt) <- HM.lookup "text" part =
                object ["type" .= ("text" :: Text), "text" .= txt]

            -- Image part: {"inlineData": {"mimeType": "...", "data": "..."}}
            | Just (Object inlineData) <- HM.lookup "inlineData" part =
                let mimeType = case HM.lookup "mimeType" inlineData of
                      Just (String mt) -> mt
                      _ -> "image/png"
                    imageData = case HM.lookup "data" inlineData of
                      Just (String dat) -> dat
                      _ -> ""
                    dataUrl = "data:" <> mimeType <> ";base64," <> imageData
                in object
                    [ "type" .= ("image_url" :: Text)
                    , "image_url" .= object ["url" .= dataUrl]
                    ]

            -- Unknown part type
            | otherwise = object []
          convertPart _ = object []

      Right $ case convertedParts of
        -- Single text part - simplify to string
        [part] | isSimpleText part -> object
          [ "role" .= openAIRole
          , "content" .= extractText part
          ]
        -- Multiple parts or has images - use array format
        _ -> object
          [ "role" .= openAIRole
          , "content" .= filter (not . isEmptyObject) convertedParts
          ]
  where
    -- Helper functions for vision content
    isSimpleText (Object obj) = HM.lookup "type" obj == Just (String "text")
    isSimpleText _ = False

    extractText (Object obj) = case HM.lookup "text" obj of
      Just (String txt) -> txt
      _ -> ""
    extractText _ = ""

    isEmptyObject (Object obj) = HM.null obj
    isEmptyObject _ = False

    -- Helper functions for function responses
    isFunctionResponse (Object part) = HM.member "functionResponse" part
    isFunctionResponse _ = False

    convertFunctionResponsePart (Object part) =
      case HM.lookup "functionResponse" part of
        Just (Object funcResp) ->
          let funcName = case HM.lookup "name" funcResp of
                Just (String n) -> n
                _ -> "unknown"
              funcResult = case HM.lookup "response" funcResp of
                Just resp -> encode resp
                _ -> "{}"
              -- Generate a tool_call_id (in real Gemini API, this would come from the original call)
              -- For now, use the function name as ID
              toolCallId = funcName <> "_result"
          in object
              [ "role" .= ("tool" :: Text)
              , "content" .= TE.decodeUtf8 (BL.toStrict funcResult)
              , "tool_call_id" .= toolCallId
              ]
        _ -> object []
    convertFunctionResponsePart _ = object []

convertGeminiContentToMessage _ = Left "Content must be a JSON object"

-- | Convert Gemini tools to OpenAI format
convertGeminiToolsToOpenAI :: [Value] -> [Value]
convertGeminiToolsToOpenAI = concatMap convertTool
  where
    convertTool (Object tool) =
      case HM.lookup "functionDeclarations" tool of
        Just (Array funcs) -> map (\func -> object
          [ "type" .= ("function" :: Text)
          , "function" .= convertFunctionDeclaration func
          ]) (V.toList funcs)
        _ -> []
    convertTool _ = []

    -- Convert Gemini function declaration to OpenAI format
    -- Rename "parametersJsonSchema" to "parameters"
    convertFunctionDeclaration (Object funcObj) =
      let renamedObj = case HM.lookup "parametersJsonSchema" funcObj of
            Just params -> HM.insert "parameters" params (HM.delete "parametersJsonSchema" funcObj)
            Nothing -> funcObj
      in Object renamedObj
    convertFunctionDeclaration other = other

-- ============================================================================
-- Response Conversion: OpenAI -> Gemini (Non-Streaming)
-- ============================================================================

-- | Convert OpenAI non-streaming response to Gemini format
openAIResponseToGemini :: Value -> Value
openAIResponseToGemini (Object openAIResp) =
  let candidates = case HM.lookup "choices" openAIResp 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 message = case HM.lookup "message" choice of
            Just (Object m) -> m
            _ -> HM.empty
          finishReason = HM.lookup "finish_reason" choice
          content = HM.lookup "content" message
          parts = case content of
            Just (String txt) -> [object ["text" .= txt]]
            _ -> []
      in object $
          [ "content" .= object
              [ "parts" .= parts
              , "role" .= ("model" :: Text)
              ]
          ] ++ (case finishReason of
                  Just r -> ["finishReason" .= r]
                  Nothing -> [])
    convertChoice _ = object []
openAIResponseToGemini _ = object []