packages feed

llm-simple-0.1.0.2: src/LLM/Load/LoadGateways.hs

module LLM.Load.LoadGateways
  ( GatewayMap,
    ProviderBaseUrl (..),
    buildGateway,
    loadGateways,
    loadGatewaysWithDotenv,
    loadGatewaysFromCatalog,
    loadGatewaysFromCatalogOrThrow,
    parseProviderBaseUrl,
  )
where

import Configuration.Dotenv (defaultConfig, loadFile)
import Control.Exception (SomeException, catch, throwIO)
import Control.Monad (forM)
import Control.Monad.Except (ExceptT (..), MonadError (throwError), runExceptT)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Functor ((<&>))
import Data.Map qualified as Map
import Data.ByteString.Char8 qualified as B
import Data.Maybe (catMaybes)
import Data.Text (Text)
import Data.Text qualified as T
import LLM.Core.Types (LLMGateway)
import LLM.Load.ProviderCatalog
  ( ProviderCatalogItem (..),
    ProviderCatalogMap,
    ProviderProtocol (..),
    defaultProviderCatalogMap,
  )
import LLM.Load.Types (LoadConfigError (LoadModelConfigError))
import LLM.Providers.Claude (claudeGateway)
import LLM.Providers.DeepSeek (deepSeekGatewayWith)
import LLM.Providers.Gemini (geminiGateway)
import LLM.Providers.Ollama (ollamaGatewayWith)
import LLM.Providers.OpenAI (openAIGatewayWithName)
import Network.HTTP.Client qualified as HC
import Network.HTTP.Req
  ( Option,
    Scheme (Http, Https),
    Url,
    http,
    https,
    port,
    (/:),
  )
import System.Environment (lookupEnv)

type GatewayMap = Map.Map Text LLMGateway

data ProviderBaseUrl
  = HttpsProviderBase (Url 'Https) (Option 'Https)
  | HttpProviderBase (Url 'Http) (Option 'Http)

parseProviderBaseUrl :: Text -> Either Text ProviderBaseUrl
parseProviderBaseUrl urlText =
  case HC.parseRequest (T.unpack urlText) of
    Left err -> Left $ "invalid baseUrl: " <> urlText <> ": " <> T.pack (show err)
    Right req -> do
      let hostText = T.pack (B.unpack (HC.host req))
          reqPort = HC.port req
          pathSegments =
            map T.pack $
              filter (not . null) $
                splitOnChar '/' $
                  B.unpack (HC.path req)
          mbPort
            | HC.secure req && reqPort == 443 = Nothing
            | not (HC.secure req) && reqPort == 80 = Nothing
            | otherwise = Just reqPort
      if HC.secure req
        then
          let (url, opts) = httpsBase hostText mbPort
           in Right $ HttpsProviderBase (applyPath pathSegments url) opts
        else
          let (url, opts) = httpBase hostText mbPort
           in Right $ HttpProviderBase (applyPath pathSegments url) opts
  where
    splitOnChar _ [] = []
    splitOnChar c xs =
      let (h, t) = break (== c) xs
       in h : case dropWhile (== c) t of
            [] -> []
            ys -> splitOnChar c ys
    httpsBase h Nothing = (https h, mempty)
    httpsBase h (Just p) = (https h, port p)
    httpBase h Nothing = (http h, mempty)
    httpBase h (Just p) = (http h, port p)
    applyPath segments url = foldl (/:) url segments

buildGateway :: ProviderCatalogItem -> ProviderBaseUrl -> Text -> LLMGateway
buildGateway item baseUrl apiKey =
  case item.protocol of
    ClaudeProtocol -> claudeGateway apiKey
    GeminiProtocol -> geminiGateway apiKey
    OpenAIProtocol -> case baseUrl of
      HttpsProviderBase url opts -> openAIGatewayWithName item.providerName url opts apiKey
      HttpProviderBase url opts -> openAIGatewayWithName item.providerName url opts apiKey
    DeepSeekProtocol -> case baseUrl of
      HttpsProviderBase url opts -> deepSeekGatewayWith url opts apiKey
      HttpProviderBase url opts -> deepSeekGatewayWith url opts apiKey
    OllamaProtocol -> case baseUrl of
      HttpsProviderBase url opts -> ollamaGatewayWith url opts
      HttpProviderBase url opts -> ollamaGatewayWith url opts

resolveBaseUrl :: ProviderCatalogItem -> IO Text
resolveBaseUrl item =
  case item.baseUrlEnv of
    Nothing -> pure item.baseUrl
    Just envVar -> do
      mbOverride <- lookupEnv (T.unpack envVar)
      pure $ maybe item.baseUrl T.pack mbOverride

tryBuildGateway :: ProviderCatalogItem -> IO (Maybe (Text, LLMGateway))
tryBuildGateway item =
  case item.apiKeyEnv of
    Nothing -> buildWithKey ""
    Just envVar ->
      lookupEnv (T.unpack envVar) >>= (\case
        Nothing -> pure Nothing
        Just apiKey -> buildWithKey apiKey) . fmap T.pack
  where
    buildWithKey apiKey = do
      baseUrlText <- resolveBaseUrl item
      case parseProviderBaseUrl baseUrlText of
        Left _ -> pure Nothing
        Right baseUrl ->
          pure $
            Just
              ( item.providerName,
                buildGateway item baseUrl apiKey
              )

tryBuildGatewayOrThrow :: ProviderCatalogItem -> ExceptT LoadConfigError IO (Maybe (Text, LLMGateway))
tryBuildGatewayOrThrow item =
  case item.apiKeyEnv of
    Nothing -> buildWithKey ""
    Just envVar ->
      liftIO (lookupEnv (T.unpack envVar) <&> fmap T.pack) >>= \case
        Nothing -> pure Nothing
        Just apiKey -> buildWithKey apiKey
  where
    buildWithKey apiKey = do
      baseUrlText <- liftIO $ resolveBaseUrl item
      baseUrl <- case parseProviderBaseUrl baseUrlText of
        Left err ->
          throwError $
            LoadModelConfigError $
              "Invalid baseUrl for provider "
                <> item.providerName
                <> ": "
                <> err
        Right parsed -> pure parsed
      pure $
        Just
          ( item.providerName,
            buildGateway item baseUrl apiKey
          )

loadGatewaysFromCatalog :: ProviderCatalogMap -> IO GatewayMap
loadGatewaysFromCatalog catalog = do
  pairs <- forM (Map.elems catalog) tryBuildGateway
  pure $ Map.fromList $ catMaybes pairs

loadGatewaysFromCatalogOrThrow :: ProviderCatalogMap -> IO GatewayMap
loadGatewaysFromCatalogOrThrow catalog = do
  result <- runExceptT $ do
    pairs <- forM (Map.elems catalog) tryBuildGatewayOrThrow
    pure $ Map.fromList $ catMaybes pairs
  case result of
    Left err -> throwIO err
    Right gatewayMap -> pure gatewayMap

-- | Build a gateway map from the bundled default provider catalog and the
-- current process environment. Does not read a @.env@ file; set variables
-- yourself or use 'loadGatewaysWithDotenv'.
--
-- For custom providers, place a @providers.json@ next to your model catalog;
-- 'loadModelOrThrow' loads that file automatically.
loadGateways :: IO GatewayMap
loadGateways = loadGatewaysFromCatalog defaultProviderCatalogMap

-- | Load @.env@ (if present) and then build a gateway map from the bundled
-- default provider catalog.
loadGatewaysWithDotenv :: IO GatewayMap
loadGatewaysWithDotenv = do
  loadFile defaultConfig `catch` \(_ :: SomeException) -> pure ()
  loadGateways