langchain-hs-gemini 0.0.6.0 → 0.0.6.2
raw patch · 4 files changed
+143/−11 lines, 4 filesdep ~langchain-hs-corePVP ok
version bump matches the API change (PVP)
Dependency ranges changed: langchain-hs-core
API changes (from Hackage documentation)
Files
- CHANGELOG.md +8/−0
- langchain-hs-gemini.cabal +2/−2
- src/Langchain/Provider/Gemini.hs +81/−9
- test/Test/Langchain/Provider/GeminiSpec.hs +52/−0
CHANGELOG.md view
@@ -1,5 +1,13 @@ # Changelog for langchain-hs-gemini +## 0.0.6.2 - 2026-11-14++> **Note**: `0.0.6.1` was skipped globally after `langchain-hs-ollama-v0.0.6.1`.++- Added incremental tool-call emission during SSE streaming in `Langchain.Provider.Gemini` (`ChatModel.stream`).+- Consolidates Interactions v1 Servant bindings (`Gemini.Client.*`) and `Langchain.Embeddings.Gemini` under aligned `0.0.6.2`.+- Updated `langchain-hs-core` dependency bound to `>= 0.0.6.2 && < 0.0.7`.+ ## 0.0.6.0 - 2026-10-02 - Initial release. Split from the monolithic `langchain-hs` package.
langchain-hs-gemini.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: langchain-hs-gemini-version: 0.0.6.0+version: 0.0.6.2 synopsis: Google Gemini provider for langchain-hs description: Google Gemini ChatModel provider for langchain-hs. Supports multi-modal content, streaming via Server-Sent Events, and function calling. Does not depend on OpenAI or Ollama packages. category: AI@@ -51,7 +51,7 @@ , http-client ==0.7.* , http-client-tls >=0.3 && <0.5 , http-conduit ==2.*- , langchain-hs-core ==0.0.6.*+ , langchain-hs-core >=0.0.6.2 && <0.0.7 , mtl >=2.2 && <2.4 , resourcet >=1.2 && <1.4 , servant >=0.19 && <0.21
src/Langchain/Provider/Gemini.hs view
@@ -29,14 +29,16 @@ ) where import Control.Applicative ((<|>))+import Control.Monad (forM_) import Control.Monad.Except (throwError) import Control.Monad.IO.Class (liftIO)-import Data.Aeson (FromJSON, ToJSON, decodeStrict, object, toJSON, (.=))+import Data.Aeson (FromJSON, ToJSON, decodeStrict, eitherDecodeStrict, object, toJSON, (.=)) import Data.Conduit (await, yield, (.|)) 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.Set as Set import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE@@ -270,31 +272,101 @@ Nothing -> newGeminiClientEnv (apiKey provider) let req = buildCreateRequest provider msgs mbConfig - (accumulated, mbCompletedInter) <-- streamGeminiInteraction env req .| consumeStream rId "" Nothing+ (accumulated, pendingCalls, emittedCalls, thoughtSigs, mbCompletedInter) <-+ streamGeminiInteraction env req .| consumeStream rId "" Map.empty [] [] Nothing + -- Finalize any tool calls whose step.start was not followed by step.stop+ remainingCalls <- traverse finalizePendingCall (Map.elems pendingCalls)+ forM_ remainingCalls $ \tc ->+ yield $ LLMChunk rId "" (Just tc)++ let allEmitted = reverse emittedCalls ++ remainingCalls case mbCompletedInter of Just completedInter -> do let finalMsg = interactionToMessage completedInter usage = toTokenUsage (interactionUsage completedInter)+ alreadyEmittedIds = Set.fromList [(toolCallId tc, toolCallName tc) | tc <- allEmitted]+ missingChunkCalls =+ filter+ (\tc -> not $ Set.member (toolCallId tc, toolCallName tc) alreadyEmittedIds)+ (fromMaybe [] (messageToolCalls finalMsg))+ forM_ missingChunkCalls $ \tc ->+ yield $ LLMChunk rId "" (Just tc) yield $ LLMEnd rId finalMsg usage Nothing -> do- let finalMsg = assistantMessage accumulated+ let base = assistantMessage accumulated+ withCalls =+ if null allEmitted+ then base+ else base {messageToolCalls = Just allEmitted}+ sigs = reverse thoughtSigs+ finalMsg =+ if null sigs+ then withCalls+ else+ withCalls+ { messageMetadata =+ Map.insert+ "langchain.gemini.thoughtSignatures"+ (toJSON sigs)+ (messageMetadata withCalls)+ } yield $ LLMEnd rId finalMsg Nothing where- consumeStream rId acc mbCompleted =+ finalizePendingCall (cid, cname, initArgs, argText)+ | T.null (T.strip argText) =+ pure+ ToolCall+ { toolCallId = cid+ , toolCallType = "function"+ , toolCallName = cname+ , toolCallArguments = initArgs+ }+ | otherwise =+ case eitherDecodeStrict (TE.encodeUtf8 argText) of+ Left err ->+ throwError $ llmError ("Invalid JSON arguments in Gemini tool call: " <> T.pack err) Nothing Nothing+ Right parsedArgs ->+ pure+ ToolCall+ { toolCallId = cid+ , toolCallType = "function"+ , toolCallName = cname+ , toolCallArguments = parsedArgs+ }++ consumeStream rId acc pending emitted sigs mbCompleted = await >>= \case- Nothing -> pure (acc, mbCompleted)+ Nothing -> pure (acc, pending, emitted, sigs, mbCompleted)+ Just (SseStepStart idx FunctionCallStep {..}) ->+ let pending' = Map.insert idx (callId, callName, callArguments, "") pending+ in consumeStream rId acc pending' emitted sigs mbCompleted Just (SseStepDelta _ (TextDelta txt)) -> do yield $ LLMChunk rId txt Nothing- consumeStream rId (acc <> txt) mbCompleted+ consumeStream rId (acc <> txt) pending emitted sigs mbCompleted+ Just (SseStepDelta idx (ArgumentsDelta argDelta)) ->+ let pending' =+ Map.adjust+ (\(cid, cname, initArgs, argTxt) -> (cid, cname, initArgs, argTxt <> argDelta))+ idx+ pending+ in consumeStream rId acc pending' emitted sigs mbCompleted+ Just (SseStepDelta _ (ThoughtSignatureDelta sig)) ->+ consumeStream rId acc pending emitted (sig : sigs) mbCompleted+ Just (SseStepStop idx) ->+ case Map.lookup idx pending of+ Nothing -> consumeStream rId acc pending emitted sigs mbCompleted+ Just entry -> do+ tc <- finalizePendingCall entry+ yield $ LLMChunk rId "" (Just tc)+ consumeStream rId acc (Map.delete idx pending) (tc : emitted) sigs mbCompleted Just (SseInteractionCompleted inter) ->- consumeStream rId acc (Just inter)+ consumeStream rId acc pending emitted sigs (Just inter) Just (SseError code msg) -> throwError $ llmError (code <> ": " <> msg) Nothing Nothing Just _ ->- consumeStream rId acc mbCompleted+ consumeStream rId acc pending emitted sigs mbCompleted toTokenUsage Nothing = Nothing toTokenUsage (Just Usage {..}) =
test/Test/Langchain/Provider/GeminiSpec.hs view
@@ -163,6 +163,58 @@ case (events, reverse events) of (LLMStart {} : _, LLMEnd {} : _) -> pure () other -> assertFailure ("Unexpected events: " ++ show other)+ , testCase "stream emits toolCallDelta chunks from step.start/step.delta/step.stop" $ do+ let sseBody =+ "data: {\"event_type\": \"step.delta\", \"index\": 0, \"delta\": {\"type\": \"text\", \"text\": \"Checking \"}}\n\n"+ <> "data: {\"event_type\": \"step.start\", \"index\": 1, \"step\": {\"type\": \"function_call\", \"id\": \"call_g1\", \"name\": \"get_weather\", \"arguments\": {}}}\n\n"+ <> "data: {\"event_type\": \"step.delta\", \"index\": 1, \"delta\": {\"type\": \"arguments\", \"arguments\": \"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\n"+ <> "data: {\"event_type\": \"step.stop\", \"index\": 1}\n\n"+ <> "data: {\"event_type\": \"interaction.completed\", \"interaction\": {\"id\": \"v1_tc\", \"status\": \"requires_action\", \"steps\": [{\"type\": \"model_output\", \"content\": [{\"type\": \"text\", \"text\": \"Checking \"}]}, {\"type\": \"function_call\", \"id\": \"call_g1\", \"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}]}}\n\n"+ app _req respond =+ respond $+ responseStream status200 [("Content-Type", "text/event-stream")] $ \write flush -> do+ write (Builder.lazyByteString sseBody)+ flush+ expectedTc =+ ToolCall+ { toolCallId = "call_g1"+ , toolCallType = "function"+ , toolCallName = "get_weather"+ , toolCallArguments = [aesonQQ|{"city": "Paris"}|]+ }+ withMockServer app $ \url -> do+ let provider = newGemini "fake-key" "gemini-3.6-flash" (Just url)+ eEvents <-+ runResourceT $+ runExceptT $+ collectEvents $+ stream provider [userMessage "Weather in Paris?"] Nothing+ case eEvents of+ Left err -> assertFailure (show err)+ Right events -> do+ let toolChunks = [tc | LLMChunk _ _ (Just tc) <- events]+ toolChunks @?= [expectedTc]+ case reverse events of+ LLMEnd _ finalMsg _ : _ ->+ messageToolCalls finalMsg @?= Just [expectedTc]+ _ -> assertFailure ("Expected LLMEnd, got: " ++ show events)+ , testCase "stream emits toolCallDelta from interaction.completed fallback" $ do+ let sseBody =+ "data: {\"event_type\": \"interaction.completed\", \"interaction\": {\"id\": \"v1_tc2\", \"status\": \"requires_action\", \"steps\": [{\"type\": \"function_call\", \"id\": \"call_g2\", \"name\": \"ping\", \"arguments\": {}}]}}\n\n"+ app _req respond =+ respond $+ responseStream status200 [("Content-Type", "text/event-stream")] $ \write flush -> do+ write (Builder.lazyByteString sseBody)+ flush+ expectedTc = ToolCall "call_g2" "function" "ping" [aesonQQ|{}|]+ withMockServer app $ \url -> do+ let provider = newGemini "fake-key" "gemini-3.6-flash" (Just url)+ eEvents <- runResourceT $ runExceptT $ collectEvents $ stream provider [userMessage "Ping"] Nothing+ case eEvents of+ Left err -> assertFailure (show err)+ Right events -> do+ let toolChunks = [tc | LLMChunk _ _ (Just tc) <- events]+ toolChunks @?= [expectedTc] ] , testGroup "Embeddings GeminiEmbeddings (Mock)"