langchain-hs-openai 0.0.6.0 → 0.0.6.2
raw patch · 5 files changed
+68/−12 lines, 5 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 +7/−0
- README.md +1/−1
- langchain-hs-openai.cabal +2/−2
- src/Langchain/Provider/OpenAI.hs +16/−9
- test/Test/Langchain/Provider/OpenAI.hs +42/−0
CHANGELOG.md view
@@ -1,5 +1,12 @@ # Changelog for langchain-hs-openai +## 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 SSE tool-call delta accumulation and `LLMChunk "" (Just toolCall)` emission in `ChatModel.stream`.+- Coordinated monorepo release alignment to `0.0.6.2` and 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.
README.md view
@@ -9,7 +9,7 @@ ```yaml # stack.yaml extra-deps-- langchain-hs-openai-0.0.6.0+- langchain-hs-openai-0.0.6.2 ``` ```yaml
langchain-hs-openai.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: langchain-hs-openai-version: 0.0.6.0+version: 0.0.6.2 synopsis: OpenAI provider and embeddings for langchain-hs description: OpenAI ChatModel provider, streaming support, structured output, and text embeddings integration for langchain-hs. Compatible with OpenAI and OpenAI-compatible endpoints (OpenRouter, Fireworks, Together AI). category: AI@@ -49,7 +49,7 @@ , http-client-tls >=0.3 && <0.5 , http-conduit ==2.* , http-types >=0.11 && <0.13- , langchain-hs-core ==0.0.6.*+ , langchain-hs-core >=0.0.6.2 && <0.0.7 , mtl >=2.2 && <2.4 , openai >=2.2.1 && <3 , servant >=0.19 && <0.21
src/Langchain/Provider/OpenAI.hs view
@@ -201,7 +201,7 @@ instance Aeson.FromJSON OpenAIStreamToolCall where parseJSON = Aeson.withObject "OpenAIStreamToolCall" $ \obj -> do- streamToolCallIndex <- obj Aeson..: "index"+ streamToolCallIndex <- obj Aeson..:? "index" Aeson..!= 0 streamToolCallId <- obj Aeson..:? "id" streamFunction <- obj Aeson..:? "function" let streamToolCallName = streamFunction >>= parseMaybe (Aeson..: "name")@@ -327,7 +327,9 @@ , CC.refusal = Nothing , CC.name = messageName msg , CC.assistant_audio = Nothing- , CC.tool_calls = V.fromList . map toOAIToolCall <$> messageToolCalls msg+ , CC.tool_calls = case messageToolCalls msg of+ Just tcs@(_ : _) -> Just $ V.fromList (map toOAIToolCall tcs)+ _ -> Nothing } Tool -> CC.Tool@@ -453,13 +455,15 @@ Map.alter (Just . update) streamToolCallIndex toolCalls' where update curr =- let prevId = curr >>= partialToolCallId+ let nonEmptyTxt (Just t) | not (T.null t) = Just t+ nonEmptyTxt _ = Nothing+ prevId = curr >>= partialToolCallId prevName = curr >>= partialToolCallName prevArgs = maybe "" partialToolCallArguments curr nextArgs = fromMaybe "" streamToolCallArguments in PartialToolCall- { partialToolCallId = streamToolCallId <|> prevId- , partialToolCallName = streamToolCallName <|> prevName+ { partialToolCallId = nonEmptyTxt streamToolCallId <|> prevId+ , partialToolCallName = nonEmptyTxt streamToolCallName <|> prevName , partialToolCallArguments = prevArgs <> nextArgs } @@ -479,10 +483,13 @@ toolCallName <- fromMaybeOrThrow "OpenAI stream ended with a tool call missing a function name" partialToolCallName toolCallArguments <-- either- (throwLlmError . ("Invalid JSON arguments in OpenAI tool call: " <>) . T.pack)- pure- (decode partialToolCallArguments)+ if T.null (T.strip partialToolCallArguments)+ then pure (object [])+ else+ either+ (throwLlmError . ("Invalid JSON arguments in OpenAI tool call: " <>) . T.pack)+ pure+ (decode partialToolCallArguments) pure ToolCall {toolCallId, toolCallType = "function", toolCallName, toolCallArguments} throwLlmError = throwError . llmError'
test/Test/Langchain/Provider/OpenAI.hs view
@@ -406,6 +406,48 @@ case result of Left _ -> pure () Right events -> assertFailure $ "Expected stream failure, got: " ++ show events+ , testCase "stream handles 0-argument tool call with empty arguments and empty delta id/name" $ do+ let frames =+ [ sseFrame+ "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"get_time\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}"+ , sseFrame+ "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}]},\"finish_reason\":\"tool_calls\"}]}"+ , sseFrame "[DONE]"+ ]+ expectedToolCall = ToolCall "call_0" "function" "get_time" (Aeson.object [])+ result <- collectRawStream frames+ case result of+ Left err -> assertFailure $ "Expected stream success, got: " ++ show err+ Right events -> case events of+ [LLMStart {}, LLMChunk _ "" (Just tc), LLMEnd _ responseMessage Nothing] -> do+ tc @?= expectedToolCall+ messageToolCalls responseMessage @?= Just [expectedToolCall]+ _ -> assertFailure $ "Unexpected stream events: " ++ show events+ , testCase "stream accumulates parallel fragmented tool calls by index" $ do+ let frames =+ [ sseFrame+ "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"add\",\"arguments\":\"{\\\"x\\\":\"}}]},\"finish_reason\":null}]}"+ , sseFrame+ "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"mul\",\"arguments\":\"{\\\"y\\\":\"}}]},\"finish_reason\":null}]}"+ , sseFrame+ "{\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}},{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]},\"finish_reason\":\"tool_calls\"}]}"+ , sseFrame "[DONE]"+ ]+ tcA = ToolCall "call_a" "function" "add" (Aeson.object ["x" Aeson..= (1 :: Int)])+ tcB = ToolCall "call_b" "function" "mul" (Aeson.object ["y" Aeson..= (2 :: Int)])+ result <- collectRawStream frames+ case result of+ Left err -> assertFailure $ "Expected stream success, got: " ++ show err+ Right events -> case events of+ [ LLMStart {}+ , LLMChunk _ "" (Just c0)+ , LLMChunk _ "" (Just c1)+ , LLMEnd _ responseMessage Nothing+ ] -> do+ c0 @?= tcA+ c1 @?= tcB+ messageToolCalls responseMessage @?= Just [tcA, tcB]+ _ -> assertFailure $ "Unexpected stream events: " ++ show events , testCase "stream closes the SSE connection when the consumer stops after a chunk" $ do clientClosed <- newEmptyMVar withCancellationAwareProvider (putMVar clientClosed ()) $ \provider -> do