packages feed

intelli-monad 0.1.2.0 → 0.1.3.0

raw patch · 8 files changed

+332/−121 lines, 8 filesdep ~louterPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependency ranges changed: louter

API changes (from Hackage documentation)

- IntelliMonad.Prompt: assistant :: Text -> Content
- IntelliMonad.Prompt: call :: forall p (m :: Type -> Type). (MonadIO m, MonadFail m, PersistentBackend p) => Prompt m Contents
- IntelliMonad.Prompt: callPostHook :: forall p (m :: Type -> Type). (MonadIO m, MonadFail m, PersistentBackend p) => Prompt m ()
- IntelliMonad.Prompt: callPreHook :: forall p (m :: Type -> Type). (MonadIO m, MonadFail m, PersistentBackend p) => Prompt m ()
- IntelliMonad.Prompt: callWithValidation :: forall validation p (m :: Type -> Type). (MonadIO m, MonadFail m, PersistentBackend p, Tool validation, FromJSON validation, FromJSON (Output validation), ToJSON validation, ToJSON (Output validation), HasFunctionObject validation, JSONSchema validation) => Contents -> Prompt m (Maybe validation)
- IntelliMonad.Prompt: pushToolReturn :: forall p (m :: Type -> Type). (MonadIO m, MonadFail m, PersistentBackend p) => Contents -> Prompt m ()
- IntelliMonad.Prompt: switchContext :: forall (m :: Type -> Type). (MonadIO m, MonadFail m) => Context -> Prompt m ()
- IntelliMonad.Prompt: system :: Text -> Content
+ IntelliMonad.Config: [useStreaming] :: Config -> Maybe Bool
+ IntelliMonad.Config: getUseStreaming :: Config -> Bool
+ IntelliMonad.Types: SetModel :: Text -> ReplCommand
+ IntelliMonad.Types: SetTimeout :: Int -> ReplCommand
+ IntelliMonad.Types: [timeoutSeconds] :: PromptEnv -> Maybe Int
+ IntelliMonad.Types: runRequestStreaming :: ChatCompletion a => Text -> ChatRequest -> Maybe Int -> a -> (Text -> IO ()) -> IO ((a, FinishReason), ChatResponse)
- IntelliMonad.Cmd: runCmd :: PersistentBackend p => ReplCommand -> IO ()
+ IntelliMonad.Cmd: runCmd :: PersistentBackend p => ChatRequest -> ReplCommand -> IO ()
- IntelliMonad.Config: Config :: Text -> Text -> Text -> Maybe BackendType -> Config
+ IntelliMonad.Config: Config :: Text -> Text -> Text -> Maybe BackendType -> Maybe Bool -> Config
- IntelliMonad.Types: PromptEnv :: [ToolProxy] -> [CustomInstructionProxy] -> Context -> PersistProxy -> [HookProxy] -> PromptEnv
+ IntelliMonad.Types: PromptEnv :: [ToolProxy] -> [CustomInstructionProxy] -> Context -> PersistProxy -> [HookProxy] -> Maybe Int -> PromptEnv
- IntelliMonad.Types: runRequest :: ChatCompletion a => Text -> ChatRequest -> a -> IO ((a, FinishReason), ChatResponse)
+ IntelliMonad.Types: runRequest :: ChatCompletion a => Text -> ChatRequest -> Maybe Int -> a -> IO ((a, FinishReason), ChatResponse)

Files

CHANGELOG.md view
@@ -1,5 +1,28 @@ # Revision history for intelli-monad +## 0.1.3.0 -- 2026-04-27++### Added+* New REPL command `:model` for switching the active model at runtime.+* Model name can now be set via the config file and overridden by an environment variable.+* `OPENAI_HTTP_DEBUG` environment variable and additional HTTP debugging settings.+* Timeout passthrough to the louter backend.+* Helper functions used by the examples are now exported from `IntelliMonad.Prompt`.++### Changed+* Default model switched to an open-weight option; the default model name is now empty (must be supplied via config or environment).+* REPL command prefix changed from `/` to `:` (e.g. `:model`, `:help`).+* `cabal.project` no longer requires cabal files in parent directories.++### Removed+* Removed `cabal.project.freeze` so the package builds on Debian.++### Internal+* Hid unused functions and tidied up `IntelliMonad.Prompt` / `IntelliMonad.Types`.++### Acknowledgments+* Many thanks to [@julialongtin](https://github.com/julialongtin) (Julia Longtin) for contributing all of the changes in this release.+ ## 0.1.0.2 -- 2024-03-30  * Add 'generate' function with typed input and output.
intelli-monad.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               intelli-monad-version:            0.1.2.0+version:            0.1.3.0 synopsis:           Type level prompt with LLMs via louter. description:        Type level prompt with LLMs via louter. This allows us to define function calls and value validation using types. homepage:           https://github.com/junjihashimoto/intelli-monad@@ -73,7 +73,7 @@                     , http-client-tls >= 0.3.6 && < 0.4                     , http-conduit >= 2.2 && < 2.4                     , megaparsec >= 9.5 && < 9.7-                    , louter >= 0.1.1 && < 0.1.2+                    , louter >= 0.1.1.2 && < 0.1.2                     , persistent >= 2.14.6 && < 2.15                     , persistent-sqlite >= 2.13.3 && < 2.14                     , sixel >= 0.1.2 && < 0.2
src/IntelliMonad/Cmd.hs view
@@ -45,6 +45,9 @@ import Text.Megaparsec.Char import Text.Megaparsec.Char.Lexer as L +-- Our request contains the target server, and model type.+import Louter.Types.Request (ChatRequest)+ opts :: Options.Applicative.Parser ReplCommand opts =   subparser@@ -73,13 +76,12 @@         <> command "help" (info (pure Help) (progDesc "Show the help"))     ) -runCmd :: forall p. (PersistentBackend p) => ReplCommand -> IO ()-runCmd cmd = do+runCmd :: forall p. (PersistentBackend p) => ChatRequest -> ReplCommand -> IO ()+runCmd request cmd = do   config <- readConfig   let tools = defaultTools       customs = []       sessionName = "default"-      defaultReq = defaultRequest   runInputT     ( Settings         { complete = completeFilename,@@ -87,14 +89,19 @@           autoAddHistory = True         }     )-    (runPrompt @p [] customs sessionName defaultReq (runCmd' @p (Right cmd) Nothing))+    (runPrompt @p [] customs sessionName request (runCmd' @p (Right cmd) Nothing))  main :: IO () main = do+  -- Parse our config file. We only use the model, at this point.+  config <- readConfig++  -- Determine what model to use by default. Use what's in the config file by default, but override with an environment variable (OPENAI_MODEL).   model <- do     lookupEnv "OPENAI_MODEL" >>= \case       Just model -> return $ T.pack model-      --      Nothing -> return "gpt-4-vision-preview"-      Nothing -> return "gpt-4"+      Nothing -> return config.model+   cmd <- customExecParser (prefs showHelpOnEmpty) (info (helper <*> opts) (fullDesc <> progDesc "intelli-monad"))-  runCmd @SqliteConf cmd++  runCmd @SqliteConf (fromModel model) cmd
src/IntelliMonad/Config.hs view
@@ -30,9 +30,16 @@   , endpoint :: Text   , model :: Text   , backend :: Maybe BackendType  -- Optional, defaults to openai+  , useStreaming :: Maybe Bool     -- Optional, defaults to True   } deriving (Show, Generic)  instance FromJSON Config++-- | Get whether to use streaming (defaults to True if not specified)+getUseStreaming :: Config -> Bool+getUseStreaming cfg = case useStreaming cfg of+  Just val -> val+  Nothing -> True  -- Default to streaming enabled  readConfig :: IO Config readConfig = do
src/IntelliMonad/Persist.hs view
@@ -115,5 +115,5 @@ withBackend func = do   (env :: PromptEnv) <- get   case (env) of-    (PromptEnv _ _ _ (PersistProxy v) _) -> func v+    (PromptEnv _ _ _ (PersistProxy v) _ _) -> func v 
src/IntelliMonad/Prompt.hs view
@@ -15,7 +15,24 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -module IntelliMonad.Prompt where+module IntelliMonad.Prompt+  (+    callWithContents,+    callWithImage,+    callWithText,+    clear,+    generate,+    getContext,+    getSessionName,+    initializePrompt,+    push,+    setContext,+    user,+    runPrompt,+    runPromptWithValidation,+    showContents+  )+where  import Control.Monad (forM_) import Control.Monad.IO.Class@@ -32,7 +49,8 @@ import qualified Data.Text.Encoding as T import qualified Data.Text.IO as T import Data.Time-import IntelliMonad.Config+import IntelliMonad.Config (readConfig)+import qualified IntelliMonad.Config as Config import IntelliMonad.CustomInstructions import IntelliMonad.Persist import IntelliMonad.Tools@@ -42,6 +60,7 @@ import Network.HTTP.Client (managerResponseTimeout, newManager, responseTimeoutMicro) import Network.HTTP.Client.TLS (tlsManagerSettings) import System.Environment (getEnv, lookupEnv)+import qualified System.IO as IO  getContext :: (MonadIO m, MonadFail m) => Prompt m Context getContext = context <$> get@@ -56,12 +75,6 @@ getSessionName :: (MonadIO m, MonadFail m) => Prompt m Text getSessionName = contextSessionName <$> getContext -switchContext :: (MonadIO m, MonadFail m) => Context -> Prompt m ()-switchContext context = do-  env <- get-  put $ env {context = context}-  return ()- push :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => Contents -> Prompt m () push contents = do   prev <- getContext@@ -122,7 +135,23 @@     loop ret = do       callPreHook @p       prev <- getContext-      ((contents, finishReason), res) <- liftIO $ runRequest prev.contextSessionName prev.contextRequest (prev.contextHeader <> prev.contextBody <> prev.contextFooter)+      config <- liftIO readConfig+      env <- get++      -- Check if streaming is enabled+      let streaming = Config.getUseStreaming config++      ((contents, finishReason), res) <- if streaming+        then do+          -- Use streaming with incremental output+          liftIO $ runRequestStreaming prev.contextSessionName prev.contextRequest env.timeoutSeconds+            (prev.contextHeader <> prev.contextBody <> prev.contextFooter)+            (\chunk -> T.putStr chunk >> IO.hFlush IO.stdout)  -- Stream to stdout+        else do+          -- Use non-streaming+          liftIO $ runRequest prev.contextSessionName prev.contextRequest env.timeoutSeconds+            (prev.contextHeader <> prev.contextBody <> prev.contextFooter)+       let current_total_tokens = 0 -- fromMaybe 0 $ API.completionUsageTotalUnderscoretokens <$> API.createChatCompletionResponseUsage res           next =             prev@@ -164,100 +193,6 @@   push @p input   call @p -callWithValidation ::-  forall validation p m.-  ( MonadIO m,-    MonadFail m,-    PersistentBackend p,-    Tool validation,-    A.FromJSON validation,-    A.FromJSON (Output validation),-    A.ToJSON validation,-    A.ToJSON (Output validation),-    HasFunctionObject validation,-    JSONSchema validation-  ) =>-  Contents ->-  Prompt m (Maybe validation)-callWithValidation contents = do-  let valid = ToolProxy (Proxy :: Proxy validation)-  case findToolCall valid contents of-    Just (Content _ (ToolCall _ _ args') _ _) -> do-      let v = (A.eitherDecode (BS.fromStrict (T.encodeUtf8 args')) :: Either String validation)-      case v of-        Left err -> do-          liftIO $ putStrLn err-          return Nothing-        Right v' -> return $ Just v'-    _ -> return Nothing--runPromptWithValidation ::-  forall validation p m.-  ( MonadIO m,-    MonadFail m,-    PersistentBackend p,-    Tool validation,-    A.FromJSON validation,-    A.FromJSON (Output validation),-    A.ToJSON validation,-    A.ToJSON (Output validation),-    HasFunctionObject validation,-    JSONSchema validation-  ) =>-  [ToolProxy] ->-  [CustomInstructionProxy] ->-  Text ->-  Louter.ChatRequest ->-  Text ->-  m (Maybe validation)-runPromptWithValidation tools customs sessionName req input = do-  let valid = ToolProxy (Proxy :: Proxy validation)-  runPrompt @p (valid : tools) customs sessionName req (callWithText @p input >>= callWithValidation @validation @p)--user :: Text -> Content-user input = Content User (Message input) "default" defaultUTCTime--system :: Text -> Content-system input = Content System (Message input) "default" defaultUTCTime--assistant :: Text -> Content-assistant input = Content Assistant (Message input) "default" defaultUTCTime--generate ::-  forall input output m p.-  ( MonadIO m,-    MonadFail m,-    p ~ StatelessConf,-    A.ToJSON input,-    A.FromJSON input,-    JSONSchema input,-    Tool output,-    A.FromJSON output,-    A.FromJSON (Output output),-    A.ToJSON output,-    A.ToJSON (Output output),-    HasFunctionObject output,-    JSONSchema output-  ) => Contents -> input -> m (Maybe output)-generate userContext input = do-  let valid = ToolProxy (Proxy :: Proxy output)-      req = (fromModel "gpt-4")-  runPrompt @p [valid] [] "default" req $ do-    time <- liftIO getCurrentTime-    context <- getContext-    let schemaText :: Text-        schemaText = T.decodeUtf8Lenient $ BS.toStrict $ A.encode $ toAeson (schema @input)-        inputText :: Text-        inputText = T.decodeUtf8Lenient $ BS.toStrict $ A.encode input-        contents = userContext ++-          [ user ("#User-input format is as follows:\n" <> schemaText)-          , user ("#User-input is as follows:\n" <> inputText)-          , user ("#Save the processing results using " <> toolFunctionName @output <> " function")-          ]-    -    push @p contents-    call @p >>= callWithValidation @output @StatelessConf- initializePrompt :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> Louter.ChatRequest -> m PromptEnv initializePrompt tools customs sessionName req = do --  config <- readConfig@@ -334,3 +269,111 @@   context <- getContext   let contents' = [Content User (Image imageType file) context.contextSessionName time]   callWithContents @p contents'++-- Only called by calc example.++user :: Text -> Content+user input = Content User (Message input) "default" defaultUTCTime++generate ::+  forall input output m p.+  ( MonadIO m,+    MonadFail m,+    p ~ StatelessConf,+    A.ToJSON input,+    A.FromJSON input,+    JSONSchema input,+    Tool output,+    A.FromJSON output,+    A.FromJSON (Output output),+    A.ToJSON output,+    A.ToJSON (Output output),+    HasFunctionObject output,+    JSONSchema output+  ) => Contents -> input -> m (Maybe output)+generate userContext input = do+  let valid = ToolProxy (Proxy :: Proxy output)+      req = (fromModel "gpt-4")+  runPrompt @p [valid] [] "default" req $ do+    time <- liftIO getCurrentTime+    context <- getContext+    let schemaText :: Text+        schemaText = T.decodeUtf8Lenient $ BS.toStrict $ A.encode $ toAeson (schema @input)+        inputText :: Text+        inputText = T.decodeUtf8Lenient $ BS.toStrict $ A.encode input+        contents = userContext +++          [ user ("#User-input format is as follows:\n" <> schemaText)+          , user ("#User-input is as follows:\n" <> inputText)+          , user ("#Save the processing results using " <> toolFunctionName @output <> " function")+          ]+    +    push @p contents+    call @p >>= callWithValidation @output @StatelessConf++runPromptWithValidation ::+  forall validation p m.+  ( MonadIO m,+    MonadFail m,+    PersistentBackend p,+    Tool validation,+    A.FromJSON validation,+    A.FromJSON (Output validation),+    A.ToJSON validation,+    A.ToJSON (Output validation),+    HasFunctionObject validation,+    JSONSchema validation+  ) =>+  [ToolProxy] ->+  [CustomInstructionProxy] ->+  Text ->+  Louter.ChatRequest ->+  Text ->+  m (Maybe validation)+runPromptWithValidation tools customs sessionName req input = do+  let valid = ToolProxy (Proxy :: Proxy validation)+  runPrompt @p (valid : tools) customs sessionName req (callWithText @p input >>= callWithValidation @validation @p)++callWithValidation ::+  forall validation p m.+  ( MonadIO m,+    MonadFail m,+    PersistentBackend p,+    Tool validation,+    A.FromJSON validation,+    A.FromJSON (Output validation),+    A.ToJSON validation,+    A.ToJSON (Output validation),+    HasFunctionObject validation,+    JSONSchema validation+  ) =>+  Contents ->+  Prompt m (Maybe validation)+callWithValidation contents = do+  let valid = ToolProxy (Proxy :: Proxy validation)+  case findToolCall valid contents of+    Just (Content _ (ToolCall _ _ args') _ _) -> do+      let v = (A.eitherDecode (BS.fromStrict (T.encodeUtf8 args')) :: Either String validation)+      case v of+        Left err -> do+          liftIO $ putStrLn err+          return Nothing+        Right v' -> return $ Just v'+    _ -> return Nothing++-- Everything below this line is not called.++{-++switchContext :: (MonadIO m, MonadFail m) => Context -> Prompt m ()+switchContext context = do+  env <- get+  put $ env {context = context}+  return ()++system :: Text -> Content+system input = Content System (Message input) "default" defaultUTCTime++assistant :: Text -> Content+assistant input = Content Assistant (Message input) "default" defaultUTCTime+-}+
src/IntelliMonad/Repl.hs view
@@ -50,11 +50,13 @@ parseRepl =   (try (lexm (string ":quit")) >> pure Quit)     <|> (try (lexm (string ":clear")) >> pure Clear)+    <|> (try (lexm (string ":model") >> T.pack <$> lexm modelName) >>= pure . SetModel)     <|> (try (lexm (string ":show") >> lexm (string "contents")) >> pure ShowContents)     <|> (try (lexm (string ":show") >> lexm (string "usage")) >> pure ShowUsage)     <|> (try (lexm (string ":show") >> lexm (string "request")) >> pure ShowRequest)     <|> (try (lexm (string ":show") >> lexm (string "context")) >> pure ShowContext)     <|> (try (lexm (string ":show") >> lexm (string "session")) >> pure ShowSession)+    <|> (try (lexm (string ":set" ) >> lexm (string "timeout") >> L.decimal) >>= pure . SetTimeout)     <|> (try (lexm (string ":read") >> lexm (string "image") >> lexm imagePath) >>= pure . ReadImage . T.pack)     <|> (try (lexm (string ":list") >> lexm (string "sessions")) >> pure ListSessions)     <|> (try (lexm (string ":list")) >> pure ListSessions)@@ -78,6 +80,7 @@     lexm = lexeme sc     sessionName = many alphaNumChar     imagePath = many (alphaNumChar <|> char '.' <|> char '/' <|> char '-')+    modelName = many (alphaNumChar <|> char '-' <|> char '.' <|> char ':' <|> char '/')  getTextInputLine :: (MonadTrans t) => t (InputT IO) (Maybe T.Text) getTextInputLine = fmap (fmap T.pack) (lift $ getInputLine "% ")@@ -159,6 +162,18 @@     Right Quit -> return ()     Right Clear -> do       clear @p+      repl+    Right (SetModel modelName) -> do+      prev <- getContext+      let req = prev.contextRequest { Louter.reqModel = modelName }+          newContext = prev {contextRequest = req}+      setContext @p newContext+      liftIO $ T.putStrLn $ "Model set to: " <> modelName+      repl+    Right (SetTimeout timeout) -> do+      env <- get+      put $ env { timeoutSeconds = Just timeout }+      liftIO $ T.putStrLn $ "Timeout set to: " <> T.pack (show timeout) <> " seconds"       repl     Right ShowContents -> do       context <- getContext
src/IntelliMonad/Types.hs view
@@ -33,9 +33,11 @@ module IntelliMonad.Types where  import qualified Codec.Picture as P+import Control.Monad (when) import Control.Monad.IO.Class import Control.Monad.Trans.State (StateT) import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode, Value)+import Data.IORef (IORef, newIORef, readIORef, writeIORef, modifyIORef') import Data.Map (Map) import qualified Data.Aeson as A import qualified Data.Aeson.Key as A@@ -61,6 +63,7 @@ import qualified Louter.Client as Louter import qualified Louter.Types.Request as Louter import qualified Louter.Types.Response as Louter+import qualified Louter.Types.Streaming as Louter import IntelliMonad.Config (readConfig) import qualified IntelliMonad.Config as Config import qualified Data.ByteString as BS@@ -295,6 +298,8 @@   -- ^ The backend for prompt logging   , hooks :: [HookProxy]   -- ^ The hook functions before or after calling LLM+  , timeoutSeconds :: Maybe Int+  -- ^ The timeout in seconds to wait for results. Given to Louter.   }  type Contents = [Content]@@ -599,6 +604,8 @@   | EditHeader   | EditFooter   | ListSessions+  | SetModel Text+  | SetTimeout Int   | CopySession       { sessionNameFrom :: Text,         sessionNameTo :: Text@@ -648,10 +655,11 @@   deleteKey :: (MonadIO m, MonadFail m) => Conn p -> Unique KeyValue -> m ()  +-- FIXME: use mempty here, instead of providing a model name. defaultRequest :: Louter.ChatRequest defaultRequest =   Louter.ChatRequest-    { Louter.reqModel = "gpt-4"+    { Louter.reqModel = ""     , Louter.reqMessages = []     , Louter.reqTools = []     , Louter.reqToolChoice = Louter.ToolChoiceAuto@@ -760,8 +768,8 @@ fromModel :: Text -> Louter.ChatRequest fromModel = fromModel_ -runRequest :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> a -> IO ((a, FinishReason), Louter.ChatResponse)-runRequest sessionName defaultReq request = do+runRequest :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> Maybe Int -> a -> IO ((a, FinishReason), Louter.ChatResponse)+runRequest sessionName defaultReq timeout request = do   config <- readConfig    -- Determine backend type (default to OpenAI if not specified)@@ -786,17 +794,125 @@           , Louter.backendRequiresAuth = not (T.null (Config.apiKey config))           } -  client <- Louter.newClient louterBackend+  client <- Louter.newClientWithTimeout timeout louterBackend   let req = toRequest defaultReq request -  lookupEnv "OPENAI_DEBUG" >>= \case-    Just "1" -> do+  openai_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_DEBUG"+  when openai_debug $ do       liftIO $ do+        T.putStrLn "========== Request ==========="         BS.putStr $ BS.toStrict $ encodePretty req         T.putStrLn ""-    _ -> return () +  openai_http_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_HTTP_DEBUG"+  when openai_http_debug $ do+      liftIO $ do+        T.putStrLn "========== Backend ==========="+        print louterBackend+        T.putStrLn "========== Request ==========="+        BS.putStr $ BS.toStrict $ encodePretty req+        T.putStrLn ""+   result <- Louter.chatCompletion client req   case result of     Left err -> error $ T.unpack $ "Louter error: " <> err-    Right res -> return $ (fromResponse sessionName res, res)+    Right res -> do+      when openai_http_debug $ do+        T.putStrLn "========== Response =========="+        BS.putStr $ BS.toStrict $ encodePretty res+        T.putStrLn ""+      return $ (fromResponse sessionName res, res)++runRequestStreaming :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> Maybe Int -> a -> (Text -> IO ()) -> IO ((a, FinishReason), Louter.ChatResponse)+runRequestStreaming sessionName defaultReq timeout request contentCallback = do+  config <- readConfig++  -- Determine backend type (default to OpenAI if not specified)+  let backendType = case Config.backend config of+        Just bt -> bt+        Nothing -> Config.OpenAI++  let louterBackend = case backendType of+        Config.OpenAI -> Louter.BackendOpenAI+          { Louter.backendApiKey = Config.apiKey config+          , Louter.backendBaseUrl = Just (Config.endpoint config)+          , Louter.backendRequiresAuth = not (T.null (Config.apiKey config))+          }+        Config.Anthropic -> Louter.BackendAnthropic+          { Louter.backendApiKey = Config.apiKey config+          , Louter.backendBaseUrl = Just (Config.endpoint config)+          , Louter.backendRequiresAuth = not (T.null (Config.apiKey config))+          }+        Config.Gemini -> Louter.BackendGemini+          { Louter.backendApiKey = Config.apiKey config+          , Louter.backendBaseUrl = Just (Config.endpoint config)+          , Louter.backendRequiresAuth = not (T.null (Config.apiKey config))+          }++  client <- Louter.newClientWithTimeout timeout louterBackend+  let req = toRequest defaultReq request++  openai_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_DEBUG"+  when openai_debug $ do+      liftIO $ do+        T.putStrLn "========== Request ==========="+        BS.putStr $ BS.toStrict $ encodePretty req+        T.putStrLn ""++  openai_http_debug <- maybe False (== "1") <$> lookupEnv "OPENAI_HTTP_DEBUG"+  when openai_http_debug $ do+      liftIO $ do+        T.putStrLn "========== Backend ==========="+        print louterBackend+        T.putStrLn "========== Request ==========="+        BS.putStr $ BS.toStrict $ encodePretty req+        T.putStrLn ""++  -- Accumulate response while streaming+  contentRef <- newIORef []+  finishReasonRef <- newIORef "stop"++  -- Stream with callback+  Louter.streamChatWithCallback client req $ \event -> case event of+    Louter.StreamContent txt -> do+      contentCallback txt  -- Call user callback for incremental output+      modifyIORef' contentRef (++ [txt])+    Louter.StreamFinish reason -> do+      writeIORef finishReasonRef reason+    Louter.StreamError err -> do+      error $ T.unpack $ "Louter streaming error: " <> err+    _ -> pure ()  -- Ignore reasoning and tool calls for now++  -- Build response from accumulated content+  fullContent <- T.concat <$> readIORef contentRef+  finishReasonText <- readIORef finishReasonRef++  -- Convert text finish reason to FinishReason type+  let finishReason = case finishReasonText of+        "stop" -> Louter.FinishStop+        "length" -> Louter.FinishLength+        "tool_calls" -> Louter.FinishToolCalls+        "content_filter" -> Louter.FinishContentFilter+        _ -> Louter.FinishStop++  -- Create a minimal ChatResponse for compatibility+  let response = Louter.ChatResponse+        { Louter.respId = "stream-1"+        , Louter.respModel = Louter.reqModel req+        , Louter.respChoices =+            [ Louter.Choice+                { Louter.choiceIndex = 0+                , Louter.choiceMessage = fullContent+                , Louter.choiceToolCalls = []+                , Louter.choiceFinishReason = Just finishReason+                }+            ]+        , Louter.respUsage = Nothing+        }++  when openai_http_debug $ do+    T.putStrLn "==== Response (Accumulated) ===="+    BS.putStr $ BS.toStrict $ encodePretty response+    T.putStrLn ""++  return $ (fromResponse sessionName response, response)