llm-simple-0.1.0.1: app/RecordConversation.hs
module Main where
import Configuration.Dotenv (defaultConfig, loadFile)
import Control.Exception (SomeException, catch)
import Control.Monad (unless, void, when)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import LLM.Core.Types (LLMGateway, ThinkingMode (..))
import LLM.Providers.Claude (claudeGateway)
import LLM.Providers.DeepSeek (deepSeekGateway)
import LLM.Providers.Gemini (geminiGateway)
import LLM.Providers.Ollama (ollamaGateway)
import LLM.Providers.OpenAI (openAIGateway)
import LLM.TestKit (recordConversation, recordedConversationPrompts)
import Options.Applicative
( Parser,
ParserInfo,
auto,
execParser,
fullDesc,
header,
help,
helper,
info,
long,
metavar,
optional,
option,
progDesc,
strOption,
switch,
value,
(<**>),
)
import System.Environment (lookupEnv)
import System.Exit (die)
data Options = Options
{ optProvider :: ProviderName,
optModel :: Maybe Text,
optThinking :: Bool,
optNoThinking :: Bool,
optThinkingEffort :: Maybe Text,
optGeneratedOut :: FilePath,
optStreamedOut :: FilePath,
optGeneratedOnly :: Bool,
optStreamedOnly :: Bool
}
data ProviderName = Ollama | OpenAI | Claude | Gemini | DeepSeek
deriving (Show, Read, Eq)
providerParser :: Parser ProviderName
providerParser =
option auto $
mconcat
[ long "provider",
metavar "PROVIDER",
value DeepSeek,
help "Provider to record (Ollama, OpenAI, Claude, Gemini, DeepSeek)"
]
optionsParser :: Parser Options
optionsParser =
Options
<$> providerParser
<*> optional
( fmap T.pack $
strOption $
mconcat
[ long "model",
metavar "MODEL",
help "Provider model name (defaults per provider when omitted)"
]
)
<*> switch
( mconcat
[ long "thinking",
help "Enable thinking mode (default for DeepSeek)"
]
)
<*> switch
( mconcat
[ long "no-thinking",
help "Disable thinking mode (overrides DeepSeek default)"
]
)
<*> optional
( fmap T.pack $
strOption $
mconcat
[ long "thinking-effort",
metavar "LEVEL",
help "Thinking effort level (e.g. high, max)"
]
)
<*> strOption
( mconcat
[ long "generated-out",
metavar "PATH",
help "Output path for non-streaming fixture"
]
)
<*> strOption
( mconcat
[ long "streamed-out",
metavar "PATH",
help "Output path for streaming fixture"
]
)
<*> switch
( mconcat
[ long "generated-only",
help "Record only the non-streaming fixture"
]
)
<*> switch
( mconcat
[ long "streamed-only",
help "Record only the streaming fixture"
]
)
main :: IO ()
main = do
loadFile defaultConfig `catch` \(_ :: SomeException) -> pure ()
opts <- execParser optionsInfo
when (opts.optGeneratedOnly && opts.optStreamedOnly) $
die "--generated-only and --streamed-only are mutually exclusive"
when (opts.optThinking && opts.optNoThinking) $
die "--thinking and --no-thinking are mutually exclusive"
gateway <- loadGateway opts.optProvider
let modelName = fromMaybe (defaultModel opts.optProvider) opts.optModel
thinking = resolveThinking opts
putStrLn $
unlines
[ "Recording conversation fixtures",
" provider: " <> show opts.optProvider,
" model: " <> T.unpack modelName,
" thinking: " <> show thinking,
" prompts: " <> show (map T.unpack recordedConversationPrompts)
]
unless opts.optStreamedOnly $
void $ recordConversation False gateway modelName thinking opts.optGeneratedOut
unless opts.optGeneratedOnly $
void $ recordConversation True gateway modelName thinking opts.optStreamedOut
putStrLn "Done."
optionsInfo :: ParserInfo Options
optionsInfo =
info
(optionsParser <**> helper)
( mconcat
[ fullDesc,
progDesc "Record live LLM conversation fixtures for replay tests.",
header "record-conversation - capture provider request/response fixtures"
]
)
loadGateway :: ProviderName -> IO LLMGateway
loadGateway = \case
Ollama -> pure ollamaGateway
OpenAI -> requireEnv "OPENAI_API_KEY" >>= pure . openAIGateway . T.pack
Claude -> requireEnv "CLAUDE_API_KEY" >>= pure . claudeGateway . T.pack
Gemini -> requireEnv "GEMINI_API_KEY" >>= pure . geminiGateway . T.pack
DeepSeek -> requireEnv "DEEPSEEK_API_KEY" >>= pure . deepSeekGateway . T.pack
requireEnv :: String -> IO String
requireEnv name = do
mVal <- lookupEnv name
case mVal of
Nothing -> die $ name <> " is not set"
Just val | null val -> die $ name <> " is empty"
Just val -> pure val
defaultModel :: ProviderName -> Text
defaultModel = \case
Ollama -> "llama3.2:latest"
OpenAI -> "gpt-4.1-2025-04-14"
Claude -> "claude-haiku-4-5-20251001"
Gemini -> "gemini-2.5-flash"
DeepSeek -> "deepseek-v4-flash"
resolveThinking :: Options -> Maybe ThinkingMode
resolveThinking opts
| opts.optNoThinking = Nothing
| opts.optThinking || opts.optProvider == DeepSeek =
Just
ThinkingMode
{ tmEnabled = True,
tmEffort = opts.optThinkingEffort
}
| otherwise = Nothing