llm-simple-0.4.0.0: src/LLM/Providers/Gemini.hs
module LLM.Providers.Gemini
( geminiGateway,
geminiGatewayWith,
geminiProvider,
geminiProviderWith,
parseGeminiResponse,
parseGeminiUsage,
encodeTurn,
signatureForModel,
skipThoughtSignatureValidator,
)
where
import Control.Applicative ((<|>))
import Control.Monad (forM)
import Data.Aeson
( KeyValue ((.=)),
Object,
Value (Object, String),
decodeStrict',
object,
withObject,
(.!=),
(.:),
(.:?),
)
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Pair, Parser, parseMaybe)
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import Data.Char (isDigit)
import Data.Foldable (for_)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.UUID.Types (UUID)
import Data.UUID.Types qualified as UUID
import LLM.Core.LLMProvider (LLMProvider (..), toGateway)
import LLM.Core.ProviderUtils (handleStreamResponse, lenientConfig, stripBoundsAndComments, stripJsonFences)
import LLM.Core.SSE (SSEEvent (sseData), readSSEEvents)
import LLM.Core.Types
( supportedImageMediaTypes,
ChatRequest
( reqConversation,
reqMaxTokens,
reqModel,
reqSystem,
reqTemperature,
reqThinking,
reqTools
),
ContentPart (..),
LLMError (EmptyResponse, HttpError, ParseError, ResponseBlocked),
LLMGateway,
LLMObjectResult,
LLMTextResult,
PartBody (..),
ProviderOpaque (..),
StreamEvent (..),
ThinkingContent (..),
ThinkingMode (..),
ToolCall (..),
ToolDef (toolDescription, toolName, toolParameters),
ToolResult (trContent, trName),
Turn (..),
ImageSource (..),
mkChatResponse,
mkToolCall,
stripForeignOpaque,
textPart,
thinkingPart,
toolCallPart,
)
import LLM.Core.Usage (Usage (..))
import Network.HTTP.Client qualified as HC
import Network.HTTP.Req
( Option,
POST (POST),
ReqBodyJson (ReqBodyJson),
Url,
header,
https,
bsResponse,
req,
reqBr,
responseBody,
responseStatusCode,
runReq,
(/:),
(=:),
)
import System.Random (randomIO)
geminiProviderName :: Text
geminiProviderName = "gemini"
-- | Create a LLMGateway for the Gemini provider at generativelanguage.googleapis.com.
geminiGateway :: Text -> LLMGateway
geminiGateway apiKey = toGateway (geminiProvider apiKey)
-- | Create a Gemini-compatible client with a custom base URL (origin).
-- The library appends @/v1beta/models/{model}:generateContent@ (and the stream variant).
geminiGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
geminiGatewayWith baseUrl baseOpts apiKey = toGateway (geminiProviderWith baseUrl baseOpts apiKey)
-- | Create a LLMProvider for the Gemini provider at generativelanguage.googleapis.com.
geminiProvider :: Text -> LLMProvider
geminiProvider = geminiProviderWith (https "generativelanguage.googleapis.com") mempty
-- | Gemini-compatible provider with a custom base URL (origin).
-- The library appends @/v1beta/models/{model}:generateContent@ (and the stream variant).
geminiProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
geminiProviderWith baseUrl baseOpts apiKey =
LLMProvider
{ providerName = geminiProviderName,
providerImageMediaTypes = supportedImageMediaTypes,
providerImageUrlSupport = True,
buildBody = const geminiBuildBody,
sendRequest = sendRequest,
openStream = \body k ->
runReq lenientConfig $ do
let model = extractModel body
url =
baseUrl
/: "v1beta"
/: "models"
/: (model <> ":streamGenerateContent")
opts = baseOpts <> geminiAuthOpts apiKey <> "alt" =: ("sse" :: Text)
reqBr POST url (ReqBodyJson (stripModel body)) opts $ \resp ->
handleStreamResponse resp k,
parseStream = const parseGeminiStream,
parseResponse = parseGeminiResponse,
buildObjectBody = \r schema ->
object $
[ "_model" .= r.reqModel,
"contents" .= concatMap (encodeTurn r.reqModel) r.reqConversation,
"generationConfig"
.= object
( [ "maxOutputTokens" .= r.reqMaxTokens,
"responseMimeType" .= ("application/json" :: Text),
"responseSchema" .= stripBoundsAndComments schema
]
++ ["temperature" .= t | Just t <- [r.reqTemperature]]
-- The object API returns no reasoning, so don't ask
-- for thought summaries.
++ thinkingConfigPairs False r
)
]
++ [ "system_instruction" .= object ["parts" .= [object ["text" .= sys]]]
| Just sys <- [r.reqSystem]
]
++ [ "tools" .= [object ["function_declarations" .= map encodeToolDef r.reqTools]]
| not (null r.reqTools)
],
sendObjectRequest = sendRequest,
parseObjectResponse = parseGeminiObjectResponse
}
where
sendRequest body =
runReq lenientConfig $ do
let model = extractModel body
url =
baseUrl
/: "v1beta"
/: "models"
/: (model <> ":generateContent")
opts = baseOpts <> geminiAuthOpts apiKey
resp <- req POST url (ReqBodyJson (stripModel body)) bsResponse opts
pure (responseStatusCode resp, responseBody resp)
geminiAuthOpts :: Text -> Option scheme
geminiAuthOpts apiKey = header "x-goog-api-key" (encodeUtf8 apiKey)
extractModel :: Value -> Text
extractModel v = fromMaybe "gemini-2.0-flash" (parseMaybe (withObject "body" (.: "_model")) v)
stripModel :: Value -> Value
stripModel (Object o) = Object (KM.delete "_model" o)
stripModel v = v
parseGeminiStream :: HC.BodyReader -> (StreamEvent -> IO ()) -> IO LLMTextResult
parseGeminiStream reader callback = do
partsRef <- newIORef ([] :: [ContentPart])
usageRef <- newIORef Nothing
errorRef <- newIORef (Nothing :: Maybe LLMError)
stopRef <- newIORef (Nothing :: Maybe GeminiStop)
readSSEEvents (HC.brRead reader) $ \sse -> do
case decodeStrict' (encodeUtf8 sse.sseData) of
Nothing -> pure ()
Just v -> do
-- The stream stays HTTP 200 when it fails midway; the error arrives
-- as a data event. Keep the first one.
for_ (parseMaybe parseErrorCode v) $ \code ->
modifyIORef' errorRef (<|> Just (HttpError code sse.sseData))
for_ (parseGeminiStop v) (writeIORef stopRef . Just)
let modelVer = parseMaybe parseModelVersion v
case parseMaybe (parseChunkParts modelVer) v of
Just parts -> do
newParts <- mapM (assignToolId callback) parts
modifyIORef' partsRef (++ newParts)
Nothing -> pure ()
case parseMaybe parseUsageMetadata v of
Just u -> writeIORef usageRef (Just u)
Nothing -> pure ()
parts <- readIORef partsRef
usage <- readIORef usageRef
mError <- readIORef errorRef
stop <- readIORef stopRef
pure $ case mError of
Just err -> Left err
Nothing
| null parts -> Left (noContentError stop)
| otherwise -> Right (mkChatResponse parts usage)
where
assignToolId :: (StreamEvent -> IO ()) -> ContentPart -> IO ContentPart
assignToolId cb (ContentPart (TextPart t) hint) = do
cb (StreamDelta t)
pure (textPart t) {partCacheHint = hint}
assignToolId cb (ContentPart (ThinkingPart tc) hint) = do
case tc.thinkingText of
Just t | not (T.null t) -> cb (StreamReasoningDelta t)
_ -> pure ()
pure (thinkingPart tc) {partCacheHint = hint}
assignToolId cb (ContentPart (ToolCallPart tc) hint) = do
tc' <- normalizeToolCallId tc
cb (StreamToolCall tc')
pure (toolCallPart tc') {partCacheHint = hint}
assignToolId _ (ContentPart (ImagePart src) hint) =
pure (ContentPart (ImagePart src) hint)
parseChunkParts :: Maybe Text -> Value -> Parser [ContentPart]
parseChunkParts modelVer = withObject "GeminiChunk" $ \o -> do
(cand : _) <- o .: "candidates" :: Parser [Value]
withObject
"candidate"
( \co -> do
cont <- co .: "content"
withObject "content" (\cco -> cco .: "parts" >>= mapM (parsePart modelVer)) cont
)
cand
parseUsageMetadata :: Value -> Parser Usage
parseUsageMetadata = withObject "GeminiChunk" $ \o -> do
u <- o .: "usageMetadata"
withObject "usageMetadata" parseGeminiUsageObject u
geminiBuildBody :: ChatRequest -> Value
geminiBuildBody r = object $ geminiBuildBodyPairs r
geminiBuildBodyPairs :: ChatRequest -> [Pair]
geminiBuildBodyPairs r =
[ "_model" .= r.reqModel,
"contents" .= concatMap (encodeTurn r.reqModel) r.reqConversation,
"generationConfig" .= genConfig r
]
++ [ "system_instruction" .= object ["parts" .= [object ["text" .= sys]]]
| Just sys <- [r.reqSystem]
]
++ [ "tools" .= [object ["function_declarations" .= map encodeToolDef r.reqTools]]
| not (null r.reqTools)
]
-- | Encode a turn for Gemini. Foreign opaque thinking / tool metadata is omitted
-- at encode time so fallbacks never replay Claude (or other) state.
-- A user or model turn whose parts encode to nothing (e.g. a Claude turn
-- holding only redacted thinking) is dropped, since Gemini rejects
-- @"parts": []@.
encodeTurn :: Text -> Turn -> [Value]
encodeTurn _ (UserMessage parts) =
nonEmptyTurn "user" (mapMaybe encodeUserPart parts)
where
-- Cache hints are ignored on the Gemini wire; content is unchanged.
encodeUserPart (ContentPart (TextPart t) _) = Just $ object ["text" .= t]
encodeUserPart (ContentPart (ImagePart src) _) = Just $ encodeImagePart src
encodeUserPart _ = Nothing
encodeTurn currentModel (AssistantMessage parts) =
nonEmptyTurn "model" (mapMaybe (encodeAssistantPart currentModel) cleaned)
where
cleaned = map (stripForeignOpaque geminiProviderName) parts
encodeTurn _ (ToolTurn results) =
[ object
[ "role" .= ("user" :: Text),
"parts" .= map encodeFunctionResponse results
]
]
nonEmptyTurn :: Text -> [Value] -> [Value]
nonEmptyTurn _ [] = []
nonEmptyTurn role ps = [object ["role" .= role, "parts" .= ps]]
encodeImagePart :: ImageSource -> Value
encodeImagePart (ImageUrl url) =
object
[ "fileData"
.= object
[ "mimeType" .= guessImageMimeFromUrl url,
"fileUri" .= url
]
]
encodeImagePart (ImageBase64 mediaType data_) =
object
[ "inlineData"
.= object
[ "mimeType" .= mediaType,
"data" .= data_
]
]
-- | Best-effort MIME guess for Gemini fileData URL parts.
guessImageMimeFromUrl :: Text -> Text
guessImageMimeFromUrl url =
let path = T.toLower $ T.takeWhile (/= '?') url
in case () of
_
| ".png" `T.isSuffixOf` path -> "image/png"
| ".gif" `T.isSuffixOf` path -> "image/gif"
| ".webp" `T.isSuffixOf` path -> "image/webp"
| ".heic" `T.isSuffixOf` path -> "image/heic"
| ".heif" `T.isSuffixOf` path -> "image/heif"
| otherwise -> "image/jpeg"
encodeAssistantPart :: Text -> ContentPart -> Maybe Value
encodeAssistantPart _ (ContentPart (TextPart t) _)
| T.null t = Nothing
| otherwise = Just $ object ["text" .= t]
encodeAssistantPart currentModel (ContentPart (ThinkingPart tc) _) =
case tc.thinkingOpaque of
Just o
| o.poProvider == geminiProviderName,
modelOk currentModel o.poModel ->
Just o.poPayload
_ ->
case tc.thinkingText of
Just t
| not (T.null t) ->
Just $ object ["text" .= t, "thought" .= True]
_ -> Nothing
encodeAssistantPart currentModel (ContentPart (ToolCallPart tc) _) =
Just $ encodeFunctionCall currentModel tc
encodeAssistantPart _ (ContentPart (ImagePart _) _) = Nothing
modelOk :: Text -> Maybe Text -> Bool
modelOk _ Nothing = True
modelOk current (Just m) = modelsMatch current m
encodeToolDef :: ToolDef -> Value
encodeToolDef td =
object
[ "name" .= td.toolName,
"description" .= td.toolDescription,
"parameters" .= stripBoundsAndComments td.toolParameters
]
-- | Google's documented placeholder for a @functionCall@ part that has no
-- thought signature of its own (a call from another provider or another
-- Gemini model). Gemini 3 rejects history whose function calls have no
-- signature.
skipThoughtSignatureValidator :: Text
skipThoughtSignatureValidator = "skip_thought_signature_validator"
encodeFunctionCall :: Text -> ToolCall -> Value
encodeFunctionCall currentModel tc =
object $
( "functionCall"
.= object
[ "name" .= tc.tcName,
"args" .= tc.tcArguments
]
)
: [ "thoughtSignature"
.= fromMaybe skipThoughtSignatureValidator (signatureForModel currentModel tc.tcProviderMeta)
]
encodeFunctionResponse :: ToolResult -> Value
encodeFunctionResponse tr =
object
[ "functionResponse"
.= object
[ "name" .= tr.trName,
"response" .= object ["result" .= tr.trContent]
]
]
-- | Give a Gemini function call an id. A random UUID, so ids never repeat
-- across processes: a reloaded history must not get new calls with ids it
-- already holds, or Claude / OpenAI reject it after a fallback.
normalizeToolCallId :: ToolCall -> IO ToolCall
normalizeToolCallId tc = do
u <- randomIO :: IO UUID
pure tc {tcId = "call_" <> UUID.toText u}
normalizePart :: ContentPart -> IO ContentPart
normalizePart (ContentPart (ToolCallPart tc) hint) =
(\tc' -> (toolCallPart tc') {partCacheHint = hint}) <$> normalizeToolCallId tc
normalizePart p = pure p
genConfig :: ChatRequest -> Value
genConfig r =
object $
("maxOutputTokens" .= r.reqMaxTokens)
: ["temperature" .= t | Just t <- [r.reqTemperature]]
++ thinkingConfigPairs True r
-- | Map 'ThinkingMode' to Gemini @thinkingConfig@.
--
-- Level strings (@low@/@medium@/@high@/@minimal@) become @thinkingLevel@
-- (Gemini 3). Numeric effort becomes @thinkingBudget@ (Gemini 2.5). Enabled
-- without effort requests dynamic budget (@-1@). With @includeThoughts@,
-- the response carries thought summaries as parts marked @"thought": true@.
thinkingConfigPairs :: Bool -> ChatRequest -> [Pair]
thinkingConfigPairs includeThoughts r =
case r.reqThinking of
Nothing -> []
Just tm
| not tm.tmEnabled ->
["thinkingConfig" .= object ["thinkingBudget" .= (0 :: Int)]]
| Just e <- tm.tmEffort,
e `elem` ["minimal", "low", "medium", "high"] ->
thinkingConfig ["thinkingLevel" .= e]
| Just e <- tm.tmEffort,
Just n <- readMaybeInt e ->
thinkingConfig ["thinkingBudget" .= n]
| otherwise ->
thinkingConfig ["thinkingBudget" .= (-1 :: Int)]
where
thinkingConfig pairs =
["thinkingConfig" .= object (pairs ++ ["includeThoughts" .= True | includeThoughts])]
readMaybeInt :: Text -> Maybe Int
readMaybeInt t =
case reads (T.unpack t) of
[(n, "")] -> Just n
_ -> Nothing
-- | Why Gemini stopped: the prompt was blocked (@promptFeedback.blockReason@)
-- or the first candidate finished (@finishReason@).
data GeminiStop = GeminiPromptBlocked Text | GeminiFinished Text
parseGeminiStop :: Value -> Maybe GeminiStop
parseGeminiStop v = parseMaybe blocked v <|> parseMaybe finished v
where
blocked = withObject "GeminiResponse" $ \o -> do
fb <- o .: "promptFeedback"
GeminiPromptBlocked <$> withObject "promptFeedback" (.: "blockReason") fb
finished = withObject "GeminiResponse" $ \o -> do
(cand : _) <- o .: "candidates" :: Parser [Value]
GeminiFinished <$> withObject "candidate" (.: "finishReason") cand
-- | The error for a response without usable content, named by why Gemini
-- stopped. Responses that do have content are returned as they are, even
-- when cut off by the token limit.
noContentError :: Maybe GeminiStop -> LLMError
noContentError = \case
Just (GeminiPromptBlocked reason) ->
ResponseBlocked ("Gemini blocked the prompt (blockReason: " <> reason <> ")")
Just (GeminiFinished reason)
| reason `elem` blockedFinishReasons ->
ResponseBlocked ("Gemini blocked the response (finishReason: " <> reason <> ")")
| reason == "MAX_TOKENS" ->
ParseError "Gemini returned no content (finishReason: MAX_TOKENS); increase max tokens, which thinking also uses"
| reason /= "STOP" ->
ParseError ("Gemini returned no content (finishReason: " <> reason <> ")")
_ -> EmptyResponse
-- | Finish reasons that mean a filter stopped the response.
blockedFinishReasons :: [Text]
blockedFinishReasons =
[ "SAFETY",
"RECITATION",
"LANGUAGE",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"IMAGE_SAFETY",
"IMAGE_PROHIBITED_CONTENT",
"IMAGE_RECITATION"
]
-- | The status code of an error event: @{"error": {"code": 503, ...}}@.
parseErrorCode :: Value -> Parser Int
parseErrorCode = withObject "GeminiError" $ \o -> do
e <- o .: "error"
withObject "error" (.: "code") e
parseGeminiResponse :: Value -> IO LLMTextResult
parseGeminiResponse v = case parseMaybe (go modelVer) v of
Nothing -> pure $ Left (noContentError (parseGeminiStop v))
Just parts -> do
parts' <- mapM normalizePart parts
case parts' of
[] -> pure $ Left (noContentError (parseGeminiStop v))
_ -> pure $ Right (mkChatResponse parts' (parseGeminiUsage v))
where
modelVer = parseMaybe parseModelVersion v
go :: Maybe Text -> Value -> Parser [ContentPart]
go mv = withObject "GeminiResponse" $ \o -> do
(cand : _) <- o .: "candidates" :: Parser [Value]
withObject
"candidate"
( \co -> do
cont <- co .: "content"
withObject
"content"
( \cco -> do
ps <- cco .: "parts" :: Parser [Value]
mapM (parsePart mv) ps
)
cont
)
cand
parsePart :: Maybe Text -> Value -> Parser ContentPart
parsePart mv = withObject "part" $ \o -> do
mSig <- o .:? "thoughtSignature" :: Parser (Maybe Text)
mThought <- o .:? "thought" :: Parser (Maybe Bool)
let tryText = do
t <- o .: "text"
if mThought == Just True
then do
let opaque =
case mSig of
Nothing -> Nothing
Just sig ->
Just
ProviderOpaque
{ poProvider = geminiProviderName,
poModel = mv,
poPayload =
object $
["text" .= t, "thought" .= True]
++ ["thoughtSignature" .= sig]
}
pure $ thinkingPart (ThinkingContent (Just t) opaque)
else
-- Plain text; preserve signature as opaque thinking sidecar only when
-- required — Gemini 2.5 may put the signature on the first part.
pure $ textPart t
tryFunctionCall = do
fc <- o .: "functionCall"
withObject
"functionCall"
( \fco -> do
name <- fco .: "name"
args <- fco .:? "args" .!= object []
pure $ toolCallPart (attachGeminiMeta mv mSig (mkToolCall name name args))
)
fc
tryFunctionCall <|> tryText
parseModelVersion :: Value -> Parser Text
parseModelVersion = withObject "GeminiResponse" (.: "modelVersion")
attachGeminiMeta :: Maybe Text -> Maybe Text -> ToolCall -> ToolCall
attachGeminiMeta _ Nothing tc = tc
attachGeminiMeta mModel (Just sig) tc =
tc
{ tcProviderMeta =
Just
ProviderOpaque
{ poProvider = geminiProviderName,
poModel = mModel,
poPayload = object ["thoughtSignature" .= sig]
}
}
-- | Pull a thought signature out of 'tcProviderMeta' iff it was emitted by
-- the model we're currently calling.
signatureForModel :: Text -> Maybe ProviderOpaque -> Maybe Text
signatureForModel currentModel (Just o)
| o.poProvider == geminiProviderName,
modelOk currentModel o.poModel =
case o.poPayload of
Object m | Just (String sig) <- KM.lookup "thoughtSignature" m -> Just sig
_ -> Nothing
signatureForModel _ _ = Nothing
-- | Whether a recorded model (the response's @modelVersion@) is the model
-- being called. Ignores only a @models/@ prefix and a stable version
-- suffix (@gemini-2.5-pro-001@ = @gemini-2.5-pro@); anything else must be
-- equal, so @gemini-2.5-flash@ and @gemini-2.5-flash-lite@ differ. A false
-- mismatch only costs a placeholder signature; a false match is a 400.
modelsMatch :: Text -> Text -> Bool
modelsMatch a b = canonical a == canonical b
where
canonical = dropStableVersion . dropModelsPrefix
dropModelsPrefix t = fromMaybe t (T.stripPrefix "models/" t)
dropStableVersion t =
let (stem, suffix) = T.breakOnEnd "-" t
in if T.length suffix == 3 && T.all isDigit suffix && T.length stem > 1
then T.dropEnd 1 stem
else t
parseGeminiUsage :: Value -> Maybe Usage
parseGeminiUsage = parseMaybe $ withObject "GeminiResponse" $ \o -> do
u <- o .: "usageMetadata"
withObject "usageMetadata" parseGeminiUsageObject u
-- | Gemini @promptTokenCount@ already includes cached content tokens.
parseGeminiUsageObject :: Object -> Parser Usage
parseGeminiUsageObject uo = do
prompt <- uo .: "promptTokenCount"
candidates <- uo .: "candidatesTokenCount"
cacheRead <- uo .:? "cachedContentTokenCount" .!= 0
pure
Usage
{ usageInputTokens = prompt,
usageOutputTokens = candidates,
usageCacheReadTokens = cacheRead,
usageCacheCreationTokens = 0,
usageTotalCost = 0
}
parseGeminiObjectResponse :: Value -> IO LLMObjectResult
parseGeminiObjectResponse v = case parseMaybe go v of
Nothing -> pure $ Left (noContentError stop)
Just text -> case decodeStrict' (encodeUtf8 (stripJsonFences text)) of
Nothing
| Just (GeminiFinished "MAX_TOKENS") <- stop ->
pure $ Left $ ParseError "Gemini structured output was cut off (finishReason: MAX_TOKENS); increase max tokens"
| otherwise -> pure $ Left EmptyResponse
Just obj -> pure $ Right (obj, parseGeminiUsage v)
where
stop = parseGeminiStop v
-- The JSON is the text of all parts that are not thought summaries
-- (@"thought": true@), which come first when thinking is enabled.
go :: Value -> Parser Text
go = withObject "GeminiObjectResponse" $ \o -> do
(cand : _) <- o .: "candidates" :: Parser [Value]
parts <- withObject "candidate" (\co -> co .: "content" >>= withObject "content" (.: "parts")) cand
texts <- forM parts $ withObject "part" $ \p -> do
thought <- p .:? "thought" .!= False
if thought then pure Nothing else p .:? "text"
case catMaybes texts of
[] -> fail "No text parts"
ts -> pure (T.concat ts)