packages feed

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

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

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

OpenAI and OpenAICompatible provider using the @openai@ Haskell package
for typed API calls. Multi-modal content and streaming support.
-}
module Langchain.Provider.OpenAI
  ( OpenAI (..)
  , OpenAIConfig (..)
  , defaultConfig
  , defaultOpenAIConfig
  , OpenAIToolChoice (..)
  , openAITools
  , newOpenAI
  , openAICompatible
  , normalizeBaseUrl
  , parseOpenAIResponse
  ) where

import Control.Applicative ((<|>))
import Control.Concurrent.Async (AsyncCancelled (..))
import Control.Exception (SomeException, fromException, throwIO, try)
import Control.Monad (forM)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import qualified Data.Conduit.Combinators as C

import Data.Aeson (Value (..), object, (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Aeson.Types (Parser, parseEither, parseMaybe)
import Data.Bifunctor (first)
import qualified Data.ByteString.Lazy as LBS
import Data.Conduit
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 Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector as V
import GHC.Generics (Generic)

import qualified OpenAI.V1 as OAI
import qualified OpenAI.V1.Chat.Completions as CC
import qualified OpenAI.V1.Models as OM
import qualified OpenAI.V1.ToolCall as OTC
import qualified OpenAI.V1.Usage as OU

import Langchain.Core.Error (LangchainError, llmError)
import Langchain.Core.Model
import Langchain.Core.Stream (StreamEvent (..), StreamM, TokenUsage (..), callbackSource)
import Langchain.Core.Tool (Tool, toolToValue)
import Langchain.Tool.Binding (ToolBinder (..))
import Network.HTTP.Client (newManager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Servant.API (Header, JSON, ReqBody, (:>))
import Servant.API.EventStream
  ( FromServerEvent (fromServerEvent)
  , PostServerSentEvents
  , ServerEvent (eventData)
  , jsonData
  )
import Servant.Client.Core.BaseUrl (parseBaseUrl)
import Servant.Client.Streaming (ClientM, client, mkClientEnv, withClientM)
import Servant.Conduit ()

-- | Configuration for OpenAI provider
data OpenAIConfig = OpenAIConfig
  { configApiKey :: Text
  , configModel :: Text
  , configBaseUrl :: Maybe Text
  , configTemperature :: Maybe Double
  }
  deriving (Eq, Show, Generic, Aeson.ToJSON, Aeson.FromJSON)

defaultConfig :: Text -> OpenAIConfig
defaultConfig key = OpenAIConfig key "gpt-4o" Nothing (Just 0.7)

defaultOpenAIConfig :: Text -> OpenAIConfig
defaultOpenAIConfig = defaultConfig

-- | OpenAI ChatModel implementation
data OpenAI = OpenAI
  { apiKey :: Text
  , model :: Text
  , baseUrl :: Text
  {- ^ Base URL (e.g. @"https://api.openai.com"@). The @openai@ package
  automatically appends @\/v1\/chat\/completions@.
  -}
  , temperature :: Maybe Double
  }
  deriving (Eq, Show)

-- | Controls how OpenAI chooses among request tool definitions.
data OpenAIToolChoice
  = OpenAIToolAuto
  | OpenAIToolNone
  | OpenAIToolRequired
  | OpenAIToolFunction Text
  deriving (Eq, Show)

-- | Build stream request options from langchain tools.
openAITools :: [Tool m] -> OpenAIToolChoice -> Value
openAITools tools choice =
  object
    [ "tools" .= map toolToValue tools
    , "tool_choice" .= toolChoiceValue choice
    ]

toolChoiceValue :: OpenAIToolChoice -> Value
toolChoiceValue OpenAIToolAuto = String "auto"
toolChoiceValue OpenAIToolNone = String "none"
toolChoiceValue OpenAIToolRequired = String "required"
toolChoiceValue (OpenAIToolFunction name) =
  object
    [ "type" .= ("function" :: Text)
    , "function" .= object ["name" .= name]
    ]

data OpenAIStreamEvent
  = OpenAIChunk OpenAIStreamChunk
  | OpenAIDone

instance FromServerEvent OpenAIStreamEvent where
  fromServerEvent event
    | eventData event == "[DONE]" = Right OpenAIDone
    | otherwise = OpenAIChunk <$> jsonData event

data OpenAIStreamChunk = OpenAIStreamChunk
  { streamChoices :: [OpenAIStreamChoice]
  , streamUsage :: Maybe TokenUsage
  }

instance Aeson.FromJSON OpenAIStreamChunk where
  parseJSON = Aeson.withObject "OpenAIStreamChunk" $ \obj ->
    OpenAIStreamChunk
      <$> obj Aeson..:? "choices" Aeson..!= []
      <*> (obj Aeson..:? "usage" >>= traverse parseOpenAIStreamUsage)

parseOpenAIStreamUsage :: Value -> Parser TokenUsage
parseOpenAIStreamUsage = Aeson.withObject "OpenAIStreamUsage" $ \obj ->
  TokenUsage
    <$> obj Aeson..: "prompt_tokens"
    <*> obj Aeson..: "completion_tokens"
    <*> obj Aeson..: "total_tokens"

data OpenAIStreamChoice = OpenAIStreamChoice
  { streamChoiceIndex :: Int
  , streamChoiceDelta :: OpenAIStreamDelta
  }

instance Aeson.FromJSON OpenAIStreamChoice where
  parseJSON = Aeson.withObject "OpenAIStreamChoice" $ \obj ->
    OpenAIStreamChoice
      <$> obj Aeson..: "index"
      <*> obj Aeson..: "delta"

data OpenAIStreamDelta = OpenAIStreamDelta
  { streamContent :: Maybe Text
  , streamToolCalls :: [OpenAIStreamToolCall]
  }

instance Aeson.FromJSON OpenAIStreamDelta where
  parseJSON = Aeson.withObject "OpenAIStreamDelta" $ \obj ->
    OpenAIStreamDelta
      <$> obj Aeson..:? "content"
      <*> obj Aeson..:? "tool_calls" Aeson..!= []

data OpenAIStreamToolCall = OpenAIStreamToolCall
  { streamToolCallIndex :: Int
  , streamToolCallId :: Maybe Text
  , streamToolCallName :: Maybe Text
  , streamToolCallArguments :: Maybe Text
  }

instance Aeson.FromJSON OpenAIStreamToolCall where
  parseJSON = Aeson.withObject "OpenAIStreamToolCall" $ \obj -> do
    streamToolCallIndex <- obj Aeson..: "index"
    streamToolCallId <- obj Aeson..:? "id"
    streamFunction <- obj Aeson..:? "function"
    let streamToolCallName = streamFunction >>= parseMaybe (Aeson..: "name")
        streamToolCallArguments = streamFunction >>= parseMaybe (Aeson..: "arguments")
    pure
      OpenAIStreamToolCall
        { streamToolCallIndex
        , streamToolCallId
        , streamToolCallName
        , streamToolCallArguments
        }

data PartialToolCall = PartialToolCall
  { partialToolCallId :: Maybe Text
  , partialToolCallName :: Maybe Text
  , partialToolCallArguments :: Text
  }

type OpenAIStreamApi =
  "v1"
    :> "chat"
    :> "completions"
    :> Header "Authorization" Text
    :> ReqBody '[JSON] Value
    :> PostServerSentEvents (ConduitT () OpenAIStreamEvent IO ())

openAIStreamClient ::
  Maybe Text -> Value -> ClientM (ConduitT () OpenAIStreamEvent IO ())
openAIStreamClient = client (Proxy :: Proxy OpenAIStreamApi)

streamRequestBody :: CC.CreateChatCompletion -> Maybe Value -> Value
streamRequestBody request options = case Aeson.toJSON request of
  Object fields ->
    Object $
      KeyMap.insert "stream_options" (object ["include_usage" Aeson..= True]) $
        KeyMap.insert "stream" (Bool True) $
          KeyMap.union fields optionFields
  value -> value
  where
    optionFields = case options of
      Just (Object fields) -> fields
      _ -> mempty

-- | Create standard OpenAI provider instance
newOpenAI :: Text -> Text -> OpenAI
newOpenAI key mName =
  OpenAI
    { apiKey = key
    , model = mName
    , baseUrl = "https://api.openai.com"
    , temperature = Just 0.7
    }

{- | Create OpenAICompatible provider instance for OpenRouter/Fireworks/Together.

The @endpoint@ should be the __base URL__ only (e.g.
@"https://openrouter.ai/api"@), not the full chat completions path.
The @openai@ package appends @\/v1\/chat\/completions@ automatically.
-}
openAICompatible :: Text -> Text -> Text -> OpenAI
openAICompatible key mName endpoint =
  OpenAI
    { apiKey = key
    , model = mName
    , baseUrl = endpoint
    , temperature = Just 0.7
    }

-- ---------------------------------------------------------------------------
-- Conversion: langchain-hs Message -> openai package Message
-- ---------------------------------------------------------------------------

-- | Convert a langchain 'ContentBlock' to an openai 'CC.Content'.
contentBlockToOAI :: ContentBlock -> CC.Content
contentBlockToOAI (TextBlock t) = CC.Text {CC.text = t}
contentBlockToOAI (ImageBlock ImageContent {imageSource = ImageUrl url}) =
  CC.Image_URL {CC.image_url = CC.ImageURL {CC.url = url, CC.detail = Nothing}}
contentBlockToOAI (ImageBlock ImageContent {imageSource = ImageBase64 (Just mime) b64}) =
  CC.Image_URL
    { CC.image_url =
        CC.ImageURL
          { CC.url = "data:" <> mime <> ";base64," <> b64
          , CC.detail = Nothing
          }
    }
contentBlockToOAI (ImageBlock ImageContent {imageSource = ImageBase64 Nothing b64}) =
  CC.Image_URL
    { CC.image_url =
        CC.ImageURL
          { CC.url = "data:application/octet-stream;base64," <> b64
          , CC.detail = Nothing
          }
    }
contentBlockToOAI (AudioBlock _mime _b64) =
  -- Audio blocks are represented as text placeholders in the request
  CC.Text {CC.text = "[Audio content]"}
contentBlockToOAI (DataBlock _) =
  CC.Text {CC.text = "[Data block]"}

-- | Convert a langchain 'Message' to an openai package 'CC.Message'.
toLangchainOAIMessage :: Message -> CC.Message (V.Vector CC.Content)
toLangchainOAIMessage msg =
  let contents = V.fromList $ map contentBlockToOAI (NonEmpty.toList (messageContents msg))
   in case messageRole msg of
        System ->
          CC.System {CC.content = contents, CC.name = messageName msg}
        User ->
          CC.User {CC.content = contents, CC.name = messageName msg}
        Assistant ->
          CC.Assistant
            { CC.assistant_content = Just contents
            , CC.refusal = Nothing
            , CC.name = messageName msg
            , CC.assistant_audio = Nothing
            , CC.tool_calls = V.fromList . map toOAIToolCall <$> messageToolCalls msg
            }
        Tool ->
          CC.Tool
            { CC.content = contents
            , CC.tool_call_id = fromMaybe "" (messageToolId msg)
            }
        -- Developer and Function map to System for the openai package
        Developer ->
          CC.System {CC.content = contents, CC.name = messageName msg}
        Function ->
          CC.System {CC.content = contents, CC.name = messageName msg}
  where
    toOAIToolCall toolCall =
      OTC.ToolCall_Function
        { OTC.id = toolCallId toolCall
        , OTC.function = OTC.Function {OTC.name = toolCallName toolCall, OTC.arguments = arguments toolCall}
        }

    arguments = TE.decodeUtf8 . LBS.toStrict . Aeson.encode . toolCallArguments

-- ---------------------------------------------------------------------------
-- Conversion: openai package response -> langchain-hs Message
-- ---------------------------------------------------------------------------

-- | Convert an openai package 'CC.Choice' response message to a langchain 'Message'.
fromOAIMessage :: CC.Message Text -> Message
fromOAIMessage oaiMsg = case oaiMsg of
  CC.Assistant {CC.assistant_content, CC.tool_calls = oaiToolCalls, CC.name = nm} ->
    let contentText = fromMaybe "" assistant_content
        baseMsg = (assistantMessage contentText) {messageName = nm}
        tcList = case oaiToolCalls of
          Nothing -> Nothing
          Just tcs ->
            Just $
              map
                ( \(OTC.ToolCall_Function {OTC.id = tcId, OTC.function = fn}) ->
                    let argVal = case Aeson.decode (LBS.fromStrict (TE.encodeUtf8 (OTC.arguments fn))) of
                          Just v -> v
                          Nothing -> object []
                     in ToolCall tcId "function" (OTC.name fn) argVal
                )
                (V.toList tcs)
     in baseMsg {messageToolCalls = tcList}
  CC.System {CC.content = c} -> systemMessage c
  CC.User {CC.content = c} -> userMessage c
  CC.Tool {CC.content = c} ->
    textMessage Tool c

-- | Convert openai 'OU.Usage' to langchain 'TokenUsage'.
fromOAIUsage :: OU.Usage ctd ptd -> TokenUsage
fromOAIUsage u =
  TokenUsage
    { promptTokens = fromIntegral (OU.prompt_tokens u)
    , completionTokens = fromIntegral (OU.completion_tokens u)
    , totalTokens = fromIntegral (OU.total_tokens u)
    }

-- ---------------------------------------------------------------------------
-- ChatModel instance
-- ---------------------------------------------------------------------------

instance ChatModel OpenAI where
  type ModelConfig OpenAI = Value

  invoke provider inputMsgs mbOptions = do
    resp <- liftIO $ first asText <$> try createComplention
    case resp of
      Left err -> throwError $ llmError' err
      Right (CC.ChatCompletionObject {CC.choices = choicesVec, CC.usage = oaiUsage}) -> do
        case V.toList choicesVec of
          [] -> throwError $ llmError "Empty choices array in OpenAI response" Nothing Nothing
          (choice : _) -> do
            let respMsg = fromOAIMessage $ CC.message choice
                _usage = fromOAIUsage oaiUsage
            pure respMsg {messageToolCalls = messageToolCalls respMsg}
    where
      createComplention = do
        methods <- getMethods
        let baseBody = reqBody provider inputMsgs
            body = mergeOptions baseBody mbOptions
        OAI.createChatCompletion methods body
      getMethods = do
        clientEnv <- OAI.getClientEnv (normalizeBaseUrl (baseUrl provider))
        pure $ OAI.makeMethods clientEnv (apiKey provider) Nothing Nothing

  stream provider inputMsgs options = do
    yield $ LLMStart rId (model provider) inputMsgs

    (accumulated, toolCalls, usage) <-
      callbackSource openAIEvents
        .| receiveChunks "" Map.empty Nothing

    yield $ LLMEnd rId ((assistantMessage accumulated) {messageToolCalls = toolCalls}) usage
    where
      rId = "openai-stream-run"
      receiveChunks accumulated toolCalls usage =
        await >>= \case
          Nothing -> finishStream
          Just (Left err) -> throwError $ llmError' err
          Just (Right OpenAIDone) -> finishStream
          Just (Right (OpenAIChunk OpenAIStreamChunk {streamChoices, streamUsage})) -> do
            let (texts, nextToolCalls) = handleChoice $ choice0 streamChoices
                nextUsage = streamUsage <|> usage
            mapM_ (\text -> yield $ LLMChunk rId text Nothing) texts
            receiveChunks (accumulated <> mconcat texts) nextToolCalls nextUsage
        where
          choice0 = List.find $ (== 0) . streamChoiceIndex

          handleChoice Nothing = ([], toolCalls)
          handleChoice (Just OpenAIStreamChoice {streamChoiceDelta = OpenAIStreamDelta {streamContent, streamToolCalls}}) =
            let nextToolCalls = List.foldl' addToolCall toolCalls streamToolCalls
             in (maybe [] pure streamContent, nextToolCalls)

          addToolCall
            toolCalls'
            OpenAIStreamToolCall
              { streamToolCallIndex
              , streamToolCallId
              , streamToolCallName
              , streamToolCallArguments
              } =
              Map.alter (Just . update) streamToolCallIndex toolCalls'
              where
                update curr =
                  let prevId = curr >>= partialToolCallId
                      prevName = curr >>= partialToolCallName
                      prevArgs = maybe "" partialToolCallArguments curr
                      nextArgs = fromMaybe "" streamToolCallArguments
                   in PartialToolCall
                        { partialToolCallId = streamToolCallId <|> prevId
                        , partialToolCallName = streamToolCallName <|> prevName
                        , partialToolCallArguments = prevArgs <> nextArgs
                        }

          finishStream = do
            let finalizeToolCalls = traverse toToolCall $ Map.elems toolCalls
            finalToolCalls <- lift finalizeToolCalls
            mapM_ (yield . LLMChunk rId "" . Just) finalToolCalls
            pure (accumulated, nonEmpty finalToolCalls, usage)
            where
              nonEmpty [] = Nothing
              nonEmpty xs = Just xs

              toToolCall :: PartialToolCall -> StreamM ToolCall
              toToolCall PartialToolCall {partialToolCallId, partialToolCallName, partialToolCallArguments} = do
                toolCallId <-
                  fromMaybeOrThrow "OpenAI stream ended with a tool call missing an id" partialToolCallId
                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)
                pure ToolCall {toolCallId, toolCallType = "function", toolCallName, toolCallArguments}

              throwLlmError = throwError . llmError'
              fromMaybeOrThrow err = maybe (throwLlmError err) pure
              decode = Aeson.eitherDecode . LBS.fromStrict . TE.encodeUtf8

      -- \| Internal function to handle streaming events from OpenAI.
      openAIEvents emit = do
        result <- try $ do
          manager <- newManager tlsManagerSettings
          let baseUrl' = T.unpack $ normalizeBaseUrl $ baseUrl provider
          clientEnv <- mkClientEnv manager <$> parseBaseUrl baseUrl'
          let body = reqBody provider inputMsgs
              bearerToken = Just $ "Bearer " <> apiKey provider
              request = openAIStreamClient bearerToken $ streamRequestBody body options

          withClientM request clientEnv $ \case
            Left err ->
              emit $ Left $ T.pack $ show err
            Right source ->
              runConduit $
                source .| C.mapM_ (emit . Right)

        case result of
          Left err
            | Just AsyncCancelled <- fromException err -> throwIO err
            | otherwise -> emit $ Left $ asText err
          Right () -> pure ()

-- | Convert a 'SomeException' to 'Text' for error reporting.
asText :: SomeException -> Text
asText ex = T.pack $ show (ex :: SomeException)

-- | Construct a 'LangchainError' for LLM errors with optional details.
llmError' :: Text -> LangchainError
llmError' msg = llmError msg Nothing Nothing

-- | Merge option fields (tools, tool_choice, response_format, etc.) into a 'CreateChatCompletion'.
mergeOptions :: CC.CreateChatCompletion -> Maybe Value -> CC.CreateChatCompletion
mergeOptions body Nothing = body
mergeOptions body (Just opts) =
  case Aeson.toJSON body of
    Object baseFields ->
      let optFields = case opts of
            Object fs -> fs
            _ -> mempty
          merged = KeyMap.union optFields baseFields
       in case Aeson.fromJSON (Object merged) of
            Aeson.Success merged' -> merged'
            _ -> body -- fallback: ignore unparseable options
    _ -> body

-- | Construct the request body for OpenAI chat completion.
reqBody :: OpenAI -> [Message] -> CC.CreateChatCompletion
reqBody provider inputMsgs =
  CC._CreateChatCompletion
    { CC.messages = toVec inputMsgs
    , CC.model = OM.Model $ model provider
    , CC.temperature = temperature provider
    }
  where
    toVec = V.fromList . map toLangchainOAIMessage

{- | Normalize base URL to ensure compatibility with the @openai@ package.
Strips any trailing @/v1/chat/completions@, @/chat/completions@, or @/v1@
so that Servant's route constructs the expected URL path.
-}
normalizeBaseUrl :: Text -> Text
normalizeBaseUrl rawUrl =
  let u0 = T.dropWhileEnd (== '/') rawUrl
      u1
        | "/v1/chat/completions" `T.isSuffixOf` u0 =
            T.dropEnd (T.length "/v1/chat/completions") u0
        | "/chat/completions" `T.isSuffixOf` u0 =
            T.dropEnd (T.length "/chat/completions") u0
        | "/v1" `T.isSuffixOf` u0 =
            T.dropEnd (T.length "/v1") u0
        | otherwise =
            u0
   in T.dropWhileEnd (== '/') u1

-- ---------------------------------------------------------------------------
-- Backward-compatible parseOpenAIResponse
-- ---------------------------------------------------------------------------

{- | Parse a raw OpenAI JSON response 'Value' into a langchain 'Message'
and optional 'TokenUsage'.

This function is provided for backward compatibility. New code should use
the typed @openai@ package types directly.
-}
parseOpenAIResponse :: Value -> Either String (Message, Maybe TokenUsage)
parseOpenAIResponse = parseEither $ Aeson.withObject "OpenAIResponse" $ \o -> do
  choices <- o Aeson..: "choices"
  usageVal <- o Aeson..:? "usage"
  mbUsage <- case usageVal of
    Nothing -> pure Nothing
    Just u -> flip (Aeson.withObject "Usage") u $ \uo -> do
      pTok <- uo Aeson..:? "prompt_tokens" Aeson..!= 0
      cTok <- uo Aeson..:? "completion_tokens" Aeson..!= 0
      tTok <- uo Aeson..:? "total_tokens" Aeson..!= 0
      pure $ Just $ TokenUsage pTok cTok tTok
  case choices of
    [] -> fail "Empty choices array in OpenAI response"
    (c : _) -> flip (Aeson.withObject "Choice") c $ \ch -> do
      msgObj <- ch Aeson..: "message"
      contentTxt <- msgObj Aeson..:? "content" Aeson..!= ""
      mbToolCalls <- msgObj Aeson..:? "tool_calls"
      cToolCalls <- case mbToolCalls of
        Nothing -> pure Nothing
        Just tcs -> do
          calls <- forM (tcs :: [Value]) $ Aeson.withObject "ToolCall" $ \tcObj -> do
            tcId <- tcObj Aeson..:? "id" Aeson..!= ""
            fnObj <- tcObj Aeson..: "function"
            fnName <- fnObj Aeson..: "name"
            fnArgsVal <- fnObj Aeson..:? "arguments"
            let fnArgs = case fnArgsVal of
                  Just (String s) -> case Aeson.decode (LBS.fromStrict (TE.encodeUtf8 s)) of
                    Just val -> val
                    Nothing -> object []
                  Just obj@(Object _) -> obj
                  _ -> object []
            pure $ ToolCall tcId "function" fnName fnArgs
          pure (Just calls)
      let msg = (assistantMessage contentTxt) {messageToolCalls = cToolCalls}
      pure (msg, mbUsage)

-- | Bind tools to an OpenAI model by merging tool definitions into the options Value
instance ToolBinder OpenAI m where
  bindToolsConfig tools mbOpts =
    case tools of
      [] -> mbOpts
      _ ->
        let toolsVal = openAITools tools OpenAIToolAuto
         in Just $ case (mbOpts, toolsVal) of
              (Nothing, v) -> v
              (Just (Object existing), Object newFields) ->
                Object (KeyMap.union newFields existing)
              (Just existing, _) -> existing