packages feed

langchain-hs-0.0.5.0: src/Langchain/Provider/Gemini.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}

{- |
Module      : Langchain.Provider.Gemini
Description : Google Gemini provider implementing ChatModel
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Gemini provider with multi-modal content parts support.
-}
module Langchain.Provider.Gemini
  ( Gemini (..)
  , GeminiConfig (..)
  , defaultConfig
  , defaultGeminiConfig
  , newGemini
  , parseGeminiResponse
  ) where

import Control.Applicative ((<|>))
import Control.Concurrent.Async (AsyncCancelled (..))
import Control.Exception (SomeException, fromException, throwIO, try)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Aeson.Types (Parser, parseEither)
import Data.Conduit (ConduitT, await, runConduit, yield, (.|))
import qualified Data.Conduit.Combinators as C
import qualified Data.List as List
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import qualified Data.Proxy as Proxy
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import Network.HTTP.Client (newManager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Simple
import Servant.API (Capture, JSON, QueryParam, ReqBody, (:>))
import Servant.API.EventStream
  ( FromServerEvent (fromServerEvent)
  , PostServerSentEvents
  , jsonData
  )
import Servant.Client.Core.BaseUrl (parseBaseUrl)
import Servant.Client.Streaming (ClientM, client, mkClientEnv, withClientM)
import Servant.Conduit ()

import Langchain.Core.Error (LangchainError, llmError)
import Langchain.Core.Model
import Langchain.Core.Stream (StreamEvent (..), TokenUsage (..), callbackSource)
import qualified Langchain.Core.Tool as CoreTool
import Langchain.Tool.Binding (ToolBinder (..))

-- | Gemini configuration
data GeminiConfig = GeminiConfig
  { configApiKey :: Text
  , configModel :: Text
  }
  deriving (Eq, Show, Generic, ToJSON, FromJSON)

defaultConfig :: Text -> GeminiConfig
defaultConfig key = GeminiConfig key "gemini-2.0-flash"

defaultGeminiConfig :: Text -> GeminiConfig
defaultGeminiConfig = defaultConfig

-- | Gemini ChatModel provider
data Gemini
  = Gemini
  { apiKey :: Text
  , model :: Text
  , baseUrl :: Maybe Text
  }
  deriving (Eq, Show)

-- | Create a new Gemini provider instance
newGemini :: Text -> Text -> Maybe Text -> Gemini
newGemini = Gemini

geminiApiKey :: Gemini -> Text
geminiApiKey = apiKey

geminiModel :: Gemini -> Text
geminiModel = model

geminiBaseUrl :: Gemini -> Text
geminiBaseUrl Gemini {baseUrl = Just baseUrl} = T.dropWhileEnd (== '/') baseUrl
geminiBaseUrl Gemini {} = "https://generativelanguage.googleapis.com"

-- Convert ContentBlock to Gemini Part JSON
contentBlockToPart :: ContentBlock -> Value
contentBlockToPart (TextBlock t) =
  object ["text" .= t]
contentBlockToPart (ImageBlock ImageContent {imageSource = ImageBase64 (Just mime) b64}) =
  object
    [ "inline_data"
        .= object
          [ "mime_type" .= mime
          , "data" .= b64
          ]
    ]
contentBlockToPart (ImageBlock ImageContent {imageSource = ImageUrl url}) =
  object ["text" .= ("[Image URL: " <> url <> "]")]
contentBlockToPart (ImageBlock ImageContent {imageSource = ImageBase64 Nothing _}) =
  object ["text" .= ("[Image data block: base64]" :: Text)]
contentBlockToPart (AudioBlock mime b64) =
  object
    [ "inline_data"
        .= object
          [ "mime_type" .= mime
          , "data" .= b64
          ]
    ]
contentBlockToPart (DataBlock _) =
  object ["text" .= ("[Data block]" :: Text)]

-- Convert a non-tool Message to Gemini Content JSON.
messageToGemini :: Message -> Value
messageToGemini msg =
  let role = messageRole msg
      geminiRole = case role of
        User -> "user"
        Assistant -> "model"
        System -> "user"
        Developer -> "user"
        Tool -> "user"
        Function -> "user"
      toolCallParts = case role of
        Assistant -> maybe [] (functionCallParts $ messageMetadata msg) (messageToolCalls msg)
        _ -> []
      contentBlocks = NonEmpty.toList (messageContents msg)
      contentParts = map contentBlockToPart contentBlocks
      parts
        | null toolCallParts = contentParts
        | otherwise = map contentBlockToPart (filter (not . emptyTextPart) contentBlocks) <> toolCallParts
   in object ["role" .= (geminiRole :: Text), "parts" .= parts]
  where
    functionCallParts metadata toolCalls =
      zipWith functionCallPart toolCalls (thoughtSignatures metadata <> repeat Nothing)

    functionCallPart (ToolCall {toolCallName = name, toolCallArguments = args, toolCallId = callId}) thoughtSignature =
      object $
        [ "functionCall"
            .= object
              ( [ "name" .= name
                , "args" .= args
                ]
                  <> (["id" .= callId | notNull callId])
              )
        ]
          <> maybe [] (pure . ("thoughtSignature" .=)) thoughtSignature

    notNull = not . T.null

    emptyTextPart (TextBlock text) = T.null text
    emptyTextPart _ = False

geminiThoughtSignaturesKey :: Text
geminiThoughtSignaturesKey = "langchain.gemini.thoughtSignatures"

thoughtSignatures :: Map.Map Text Value -> [Maybe Text]
thoughtSignatures metadata =
  case Map.lookup geminiThoughtSignaturesKey metadata of
    Just value -> case fromJSON value of
      Success signatures -> signatures
      Error _ -> []
    Nothing -> []

withThoughtSignatures :: [ToolCall] -> [Maybe Text] -> Message -> Message
withThoughtSignatures [] _ message = message {messageToolCalls = Nothing}
withThoughtSignatures toolCalls signatures message =
  message
    { messageToolCalls = Just toolCalls
    , messageMetadata =
        Map.insert geminiThoughtSignaturesKey (toJSON signatures) (messageMetadata message)
    }

functionResponsePart :: [ToolCall] -> Message -> Either Text Value
functionResponsePart priorToolCalls msg = do
  toolName <-
    maybe
      (Left "Gemini function response is missing a function name")
      Right
      (messageName msg <|> (messageToolId msg >>= lookupToolName))
  let functionResponseFields =
        [ "name" .= toolName
        , "response" .= object ["result" .= extractMessageText msg]
        ]
          <> maybe [] (pure . ("id" .=)) (messageToolId msg)
  pure $ object ["functionResponse" .= object functionResponseFields]
  where
    lookupToolName toolId =
      toolCallName <$> List.find ((== toolId) . toolCallId) priorToolCalls

messagesToGemini :: [ToolCall] -> [Message] -> Either Text [Value]
messagesToGemini _ [] = Right []
messagesToGemini priorToolCalls (msg : remaining)
  | isFunctionResponse msg = do
      let (responseMessages, followingMessages) = span isFunctionResponse remaining
      parts <- traverse (functionResponsePart priorToolCalls) (msg : responseMessages)
      contents <- messagesToGemini priorToolCalls followingMessages
      pure $ object ["role" .= ("user" :: Text), "parts" .= parts] : contents
  | otherwise = do
      contents <- messagesToGemini priorToolCalls remaining
      pure $ messageToGemini msg : contents
  where
    isFunctionResponse message = messageRole message `elem` [Tool, Function]

geminiRequestPayload :: [Message] -> Maybe Value -> Either Text Value
geminiRequestPayload inputMsgs config = do
  let priorToolCalls = concatMap (fromMaybe [] . messageToolCalls) inputMsgs
  contents <- messagesToGemini priorToolCalls inputMsgs
  case config of
    Just (Object fields) -> pure $ Object $ KeyMap.insert "contents" (toJSON contents) fields
    Nothing -> pure $ object ["contents" .= contents]
    Just _ -> Left "Gemini config must be a JSON object"

instance ChatModel Gemini where
  type ModelConfig Gemini = Value

  invoke provider inputMsgs config = do
    payload <-
      either (throwError . \err -> llmError err Nothing Nothing) pure $
        geminiRequestPayload inputMsgs config
    let url =
          geminiBaseUrl provider
            <> "/v1beta/models/"
            <> geminiModel provider
            <> ":generateContent?key="
            <> geminiApiKey provider
        initReq = parseRequest_ (T.unpack url)
        req =
          setRequestMethod "POST" $
            setRequestHeader "Content-Type" ["application/json"] $
              setRequestBodyJSON payload initReq

    eRes <- liftIO $ safeHttpRequest req
    case eRes of
      Left err -> throwError $ llmError err Nothing Nothing
      Right bodyVal -> case parseGeminiResponse bodyVal of
        Left parseErr -> throwError $ llmError (T.pack parseErr) Nothing Nothing
        Right respMsg -> pure respMsg

  stream provider inputMsgs config = do
    let model = geminiModel provider
        requestPayload = geminiRequestPayload inputMsgs
    yield $ LLMStart rId model inputMsgs

    payload <-
      either (throwError . llmError') pure $ requestPayload config

    let events = geminiEvents payload
    (accumulated, toolCalls, thoughtSignatures', usage) <-
      callbackSource events
        .| receiveChunks "" [] [] Nothing

    let message = withThoughtSignatures toolCalls thoughtSignatures' $ assistantMessage accumulated
    yield $ LLMEnd rId message usage
    where
      receiveChunks accumulated toolCalls thoughtSignatures' usage = do
        next <- await
        case next of
          Nothing -> pure (accumulated, toolCalls, thoughtSignatures', usage)
          Just (Left err) -> throwError $ llmError' err
          Just (Right (GeminiStreamEvent GeminiStreamChunk {streamCandidates, streamUsage})) -> do
            let parts = maybe [] streamParts $ candidate0 streamCandidates
                texts = [text | GeminiText text <- parts]
                calls = [(toolCall, signature) | GeminiFunctionCall toolCall signature <- parts]
                nextUsage = streamUsage <|> usage
            emitParts texts (map fst calls)
            receiveChunks
              (accumulated <> mconcat texts)
              (toolCalls <> map fst calls)
              (thoughtSignatures' <> map snd calls)
              nextUsage

      candidate0 = List.find ((== 0) . streamCandidateIndex)

      emitParts texts [] = mapM_ (`yieldChunk` Nothing) texts
      emitParts texts (toolCall : remaining) = do
        yieldChunk (mconcat texts) (Just toolCall)
        mapM_ (yieldChunk "" . Just) remaining

      yieldChunk text mbToolCall = yield $ LLMChunk rId text mbToolCall

      geminiEvents requestPayload emit = do
        result <- try $ do
          manager <- newManager tlsManagerSettings

          let baseUrl = parseBaseUrl (T.unpack $ geminiBaseUrl provider)
              model = geminiModel provider
              apiKey = geminiApiKey provider
              request =
                geminiStreamClient
                  (model <> ":streamGenerateContent")
                  (Just "sse")
                  (Just apiKey)
                  requestPayload

          clientEnv <- mkClientEnv manager <$> baseUrl
          withClientM request clientEnv $
            either
              emitError
              (\source -> runConduit $ source .| C.mapM_ (emit . Right))
        case result of
          Left err
            | Just AsyncCancelled <- fromException err -> throwIO err
            | otherwise -> emitError err
          Right () -> pure ()
        where
          emitError :: Show a => a -> IO ()
          emitError =
            emit . Left . redactKey (geminiApiKey provider) . T.pack . show

      rId = "gemini-stream-run"

llmError' :: Text -> LangchainError
llmError' err = llmError err Nothing Nothing

{- | Replace the literal API key with @[REDACTED]@ in error messages so it
  never appears in 'LangchainError' values or test output.
-}
redactKey :: Text -> Text -> Text
redactKey key txt
  | T.null key = txt
  | otherwise = T.replace key "[REDACTED]" txt

data GeminiStreamChunk = GeminiStreamChunk
  { streamCandidates :: [GeminiStreamCandidate]
  , streamUsage :: Maybe TokenUsage
  }

instance FromJSON GeminiStreamChunk where
  parseJSON = withObject "GeminiStreamChunk" $ \obj ->
    GeminiStreamChunk
      <$> obj .:? "candidates" .!= []
      <*> (obj .:? "usageMetadata" >>= traverse parseGeminiUsage)

data GeminiStreamCandidate = GeminiStreamCandidate
  { streamCandidateIndex :: Int
  , streamParts :: [GeminiPart]
  }

instance FromJSON GeminiStreamCandidate where
  parseJSON = withObject "GeminiStreamCandidate" $ \obj -> do
    streamCandidateIndex <- obj .:? "index" .!= 0
    content <- obj .:? "content"
    streamParts <- case content of
      Nothing -> pure []
      Just contentValue -> withObject "GeminiStreamContent" parseParts contentValue
    pure GeminiStreamCandidate {streamCandidateIndex, streamParts}
    where
      parseParts contentObj = do
        parts <- contentObj .:? "parts" .!= []
        traverse parseGeminiPart parts

data GeminiPart
  = GeminiText Text
  | GeminiFunctionCall ToolCall (Maybe Text)

parseGeminiPart :: Value -> Parser GeminiPart
parseGeminiPart = withObject "GeminiPart" $ \obj -> do
  functionCall <- obj .:? "functionCall"
  case functionCall of
    Just value -> GeminiFunctionCall <$> parseGeminiFunctionCall value <*> pure (parseThoughtSignature obj)
    Nothing -> GeminiText <$> obj .:? "text" .!= ""

parseGeminiFunctionCall :: Value -> Parser ToolCall
parseGeminiFunctionCall = withObject "GeminiFunctionCall" $ \obj ->
  ToolCall
    <$> obj .:? "id" .!= ""
    <*> pure "function"
    <*> obj .: "name"
    <*> obj .:? "args" .!= object []

parseThoughtSignature :: Object -> Maybe Text
parseThoughtSignature obj =
  case KeyMap.lookup "thoughtSignature" obj of
    Just (String signature) -> Just signature
    _ -> Nothing

parseGeminiUsage :: Value -> Parser TokenUsage
parseGeminiUsage = withObject "GeminiUsageMetadata" $ \obj ->
  TokenUsage
    <$> obj .:? "promptTokenCount" .!= 0
    <*> obj .:? "candidatesTokenCount" .!= 0
    <*> obj .:? "totalTokenCount" .!= 0

newtype GeminiStreamEvent = GeminiStreamEvent GeminiStreamChunk

instance FromServerEvent GeminiStreamEvent where
  fromServerEvent event = GeminiStreamEvent <$> jsonData event

type GeminiStreamApi =
  "v1beta"
    :> "models"
    :> Capture "modelAction" Text
    :> QueryParam "alt" Text
    :> QueryParam "key" Text
    :> ReqBody '[JSON] Value
    :> PostServerSentEvents (ConduitT () GeminiStreamEvent IO ())

geminiStreamClient ::
  Text -> Maybe Text -> Maybe Text -> Value -> ClientM (ConduitT () GeminiStreamEvent IO ())
geminiStreamClient = client (Proxy.Proxy :: Proxy.Proxy GeminiStreamApi)

-- Helper for HTTP requests
safeHttpRequest :: Request -> IO (Either Text Value)
safeHttpRequest req = do
  eRes <-
    try (httpJSONEither req) :: IO (Either SomeException (Response (Either JSONException Value)))
  case eRes of
    Left ex -> pure $ Left (T.pack $ show ex)
    Right res -> case getResponseBody res of
      Left err -> pure $ Left (T.pack $ show err)
      Right val -> pure $ Right val

-- Parse Gemini response JSON
parseGeminiResponse :: Value -> Either String Message
parseGeminiResponse = parseEither $ withObject "GeminiResponse" $ \o -> do
  -- Surface API-level errors (e.g. safety blocks, quota exhausted) verbatim
  case KeyMap.lookup "error" o of
    Just (Object errObj) -> do
      msg <- errObj .: "message" <|> pure "Unknown Gemini API error"
      fail (T.unpack msg)
    _ -> pure ()
  candidates <- o .: "candidates"
  case candidates of
    [] -> fail "Empty candidates array in Gemini response"
    (c : _) ->
      flip (withObject "Candidate") c $ \cand -> do
        -- "content" is absent when finishReason is SAFETY or MAX_TOKENS with no output
        mContentObj <- cand .:? "content"
        case mContentObj of
          Nothing -> pure $ assistantMessage ""
          Just contentObj -> do
            parts <- contentObj .: "parts"
            parsedParts <- traverse parseGeminiPart parts
            let texts = [text | GeminiText text <- parsedParts]
                toolCalls = [toolCall | GeminiFunctionCall toolCall _ <- parsedParts]
                signatures = [signature | GeminiFunctionCall _ signature <- parsedParts]
            pure $
              withThoughtSignatures toolCalls signatures $
                assistantMessage $
                  T.intercalate "\n" texts

-- | Bind tools to a Gemini model by adding function declarations to the config.
instance ToolBinder Gemini m where
  bindToolsConfig tools config =
    case tools of
      [] -> config
      _ ->
        let generated =
              KeyMap.singleton
                "tools"
                ( toJSON
                    [ object ["functionDeclarations" .= map functionDeclaration tools]
                    ]
                )
         in Just $ case config of
              Nothing -> Object generated
              Just (Object existing) -> Object (KeyMap.union generated existing)
              Just other -> other
    where
      functionDeclaration tool =
        object
          [ "name" .= CoreTool.toolName tool
          , "description" .= CoreTool.toolDescription tool
          , "parameters" .= CoreTool.toolSchema tool
          ]