llm-simple 0.1.0.1 → 0.1.0.2
raw patch · 13 files changed
+514/−51 lines, 13 filesPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
API changes (from Hackage documentation)
+ LLM: LoadProviderCatalogError :: Text -> LoadConfigError
+ LLM.Load: ClaudeProtocol :: ProviderProtocol
+ LLM.Load: DeepSeekProtocol :: ProviderProtocol
+ LLM.Load: GeminiProtocol :: ProviderProtocol
+ LLM.Load: LoadProviderCatalogError :: Text -> LoadConfigError
+ LLM.Load: OllamaProtocol :: ProviderProtocol
+ LLM.Load: OpenAIProtocol :: ProviderProtocol
+ LLM.Load: ProviderCatalogItem :: Text -> ProviderProtocol -> Text -> Maybe Text -> Maybe Text -> ProviderCatalogItem
+ LLM.Load: [$sel:apiKeyEnv:ProviderCatalogItem] :: ProviderCatalogItem -> Maybe Text
+ LLM.Load: [$sel:baseUrl:ProviderCatalogItem] :: ProviderCatalogItem -> Text
+ LLM.Load: [$sel:baseUrlEnv:ProviderCatalogItem] :: ProviderCatalogItem -> Maybe Text
+ LLM.Load: [$sel:protocol:ProviderCatalogItem] :: ProviderCatalogItem -> ProviderProtocol
+ LLM.Load: [$sel:providerName:ProviderCatalogItem] :: ProviderCatalogItem -> Text
+ LLM.Load: data ProviderCatalogItem
+ LLM.Load: data ProviderProtocol
+ LLM.Load: loadGatewaysFromCatalog :: ProviderCatalogMap -> IO GatewayMap
+ LLM.Load: loadProviderCatalog :: FilePath -> ExceptT LoadConfigError IO ProviderCatalogMap
+ LLM.Load: type ProviderCatalogMap = Map Text ProviderCatalogItem
+ LLM.Load.LoadGateways: HttpProviderBase :: Url 'Http -> Option 'Http -> ProviderBaseUrl
+ LLM.Load.LoadGateways: HttpsProviderBase :: Url 'Https -> Option 'Https -> ProviderBaseUrl
+ LLM.Load.LoadGateways: buildGateway :: ProviderCatalogItem -> ProviderBaseUrl -> Text -> LLMGateway
+ LLM.Load.LoadGateways: data ProviderBaseUrl
+ LLM.Load.LoadGateways: loadGatewaysFromCatalog :: ProviderCatalogMap -> IO GatewayMap
+ LLM.Load.LoadGateways: loadGatewaysFromCatalogOrThrow :: ProviderCatalogMap -> IO GatewayMap
+ LLM.Load.LoadGateways: parseProviderBaseUrl :: Text -> Either Text ProviderBaseUrl
+ LLM.Load.ProviderCatalog: ClaudeProtocol :: ProviderProtocol
+ LLM.Load.ProviderCatalog: DeepSeekProtocol :: ProviderProtocol
+ LLM.Load.ProviderCatalog: GeminiProtocol :: ProviderProtocol
+ LLM.Load.ProviderCatalog: OllamaProtocol :: ProviderProtocol
+ LLM.Load.ProviderCatalog: OpenAIProtocol :: ProviderProtocol
+ LLM.Load.ProviderCatalog: ProviderCatalogItem :: Text -> ProviderProtocol -> Text -> Maybe Text -> Maybe Text -> ProviderCatalogItem
+ LLM.Load.ProviderCatalog: [$sel:apiKeyEnv:ProviderCatalogItem] :: ProviderCatalogItem -> Maybe Text
+ LLM.Load.ProviderCatalog: [$sel:baseUrl:ProviderCatalogItem] :: ProviderCatalogItem -> Text
+ LLM.Load.ProviderCatalog: [$sel:baseUrlEnv:ProviderCatalogItem] :: ProviderCatalogItem -> Maybe Text
+ LLM.Load.ProviderCatalog: [$sel:protocol:ProviderCatalogItem] :: ProviderCatalogItem -> ProviderProtocol
+ LLM.Load.ProviderCatalog: [$sel:providerName:ProviderCatalogItem] :: ProviderCatalogItem -> Text
+ LLM.Load.ProviderCatalog: data ProviderCatalogItem
+ LLM.Load.ProviderCatalog: data ProviderProtocol
+ LLM.Load.ProviderCatalog: defaultProviderCatalogMap :: ProviderCatalogMap
+ LLM.Load.ProviderCatalog: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Load.ProviderCatalog.ProviderProtocol
+ LLM.Load.ProviderCatalog: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Load.ProviderCatalog.ProviderProtocol
+ LLM.Load.ProviderCatalog: instance GHC.Classes.Eq LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance GHC.Classes.Eq LLM.Load.ProviderCatalog.ProviderProtocol
+ LLM.Load.ProviderCatalog: instance GHC.Classes.Ord LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance GHC.Classes.Ord LLM.Load.ProviderCatalog.ProviderProtocol
+ LLM.Load.ProviderCatalog: instance GHC.Generics.Generic LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance GHC.Show.Show LLM.Load.ProviderCatalog.ProviderCatalogItem
+ LLM.Load.ProviderCatalog: instance GHC.Show.Show LLM.Load.ProviderCatalog.ProviderProtocol
+ LLM.Load.ProviderCatalog: loadProviderCatalog :: FilePath -> ExceptT LoadConfigError IO ProviderCatalogMap
+ LLM.Load.ProviderCatalog: loadProviderCatalogForModelCatalog :: FilePath -> ExceptT LoadConfigError IO ProviderCatalogMap
+ LLM.Load.ProviderCatalog: type ProviderCatalogMap = Map Text ProviderCatalogItem
+ LLM.Load.Types: LoadProviderCatalogError :: Text -> LoadConfigError
+ LLM.Providers.OpenAI: openAIGatewayWithName :: Text -> Url scheme -> Option scheme -> Text -> LLMGateway
+ LLM.Providers.OpenAI: openAIProviderWithName :: Text -> Url scheme -> Option scheme -> Text -> LLMProvider
- LLM.Providers.Ollama: ollamaGatewayWith :: Url 'Http -> Option 'Http -> LLMGateway
+ LLM.Providers.Ollama: ollamaGatewayWith :: Url scheme -> Option scheme -> LLMGateway
Files
- CHANGELOG.md +18/−0
- Readme.md +31/−4
- llm-simple.cabal +6/−3
- providers.json +34/−0
- src/LLM/Load.hs +21/−13
- src/LLM/Load/LoadGateways.hs +164/−22
- src/LLM/Load/LoadModels.hs +4/−2
- src/LLM/Load/ModelCatalog.hs +1/−1
- src/LLM/Load/ProviderCatalog.hs +134/−0
- src/LLM/Load/Types.hs +2/−0
- src/LLM/Providers/Ollama.hs +1/−2
- src/LLM/Providers/OpenAI.hs +12/−3
- test/LLM/LoadSpec.hs +86/−1
CHANGELOG.md view
@@ -5,6 +5,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased]++## [0.1.0.2] - 2026-07-15++### Added++- `providers.json` provider catalog: configure provider endpoints, API key env+ vars, and protocols without hardcoding providers in Haskell.+- `loadProviderCatalog`, `ProviderCatalogItem`, and `loadGatewaysFromCatalog`+ for explicit provider catalog loading.+- Optional `baseUrlEnv` overrides for OpenAI, DeepSeek, and Ollama.++### Changed++- Partial `providers.json` files are merged with built-in provider defaults;+ file entries add or override providers by `providerName`.+ ## [0.1.0.1] - 2026-07-11 ### Changed@@ -42,5 +59,6 @@ DeepSeek), single-shot generation with fallbacks, agent tool loops, structured output, JSON model catalog loading, and workspace-scoped filesystem tools. +[0.1.0.2]: https://github.com/aische/llm-simple/compare/v0.1.0.1...v0.1.0.2 [0.1.0.1]: https://github.com/aische/llm-simple/compare/v0.1.0.0...v0.1.0.1 [0.1.0.0]: https://github.com/aische/llm-simple/releases/tag/v0.1.0.0
Readme.md view
@@ -2,7 +2,7 @@ An experimental Haskell **LLM toolbox** for trying out providers, tool loops, and workspace agents locally. It is meant to get you from zero to a working agent quickly — not to be a production agent platform. -The API stays small on purpose: a JSON model catalog, bundled filesystem tools, and a straight line from `loadModelOrThrow` to `generateText`. Under that surface you still get multi-provider gateways, fallbacks, streaming, structured output, and sandboxed workspace tools — enough to prototype and compare ideas without assembling the pieces yourself.+The API stays small on purpose: JSON model and provider catalogs, bundled filesystem tools, and a straight line from `loadModelOrThrow` to `generateText`. Under that surface you still get multi-provider gateways, fallbacks, streaming, structured output, and sandboxed workspace tools — enough to prototype and compare ideas without assembling the pieces yourself. **Status:** early 0.1.x release. APIs may change. @@ -12,7 +12,7 @@ - **Generate:** single-request text, streaming, and structured object generation with model fallbacks, timeouts, retries, and throttling - **Agent:** multi-round tool loops (`generateText`, `streamText`, `generateObject`) - **Tools:** filesystem tool suite with workspace path sandboxing-- **Load:** JSON model catalog and API-key gateway initialization+- **Load:** JSON model and provider catalogs, API-key gateway initialization - **Observability:** hooks, logging, and generation lifecycle events ## Install@@ -59,7 +59,7 @@ | Field | Description | | ----------------- | ------------------------------------------------------------------------ | | `modelConfigName` | Name used in code to look up this config |-| `providerName` | `"openai"`, `"claude"`, `"gemini"`, `"ollama"`, or `"deepseek"` |+| `providerName` | Provider key defined in `providers.json` | | `modelName` | Provider-specific model identifier | | `pricing` | `pricePerMillionInput` / `pricePerMillionOutput` for usage cost tracking | | `maxTokens` | Max tokens per request |@@ -72,6 +72,33 @@ A provider is only available if its API key is set (except Ollama, which is always available). +### Provider catalog++Providers are defined in `providers.json` (bundled with the package as a+reference). Built-in providers are always available. When you load a model+catalog, the loader looks for `providers.json` in the same directory; if present,+its entries are merged on top of the built-ins — adding new providers or+overriding built-ins with the same `providerName`.++| Field | Description |+| ------------- | --------------------------------------------------------------------------- |+| `providerName`| Key referenced by `providerName` in model catalog entries |+| `protocol` | `"openai"`, `"claude"`, `"gemini"`, `"ollama"`, or `"deepseek"` |+| `baseUrl` | Provider host/base URL (OpenAI-compatible protocols append `/v1/chat/completions`) |+| `apiKeyEnv` | Environment variable holding the API key (omit for keyless providers) |+| `baseUrlEnv` | Optional env var that overrides `baseUrl` at runtime |++To add an OpenAI-compatible provider (OpenRouter, Groq, Together, etc.), add an+entry with `"protocol": "openai"`, set `baseUrl` and `apiKeyEnv`, then reference+`providerName` from your model catalog.++Optional base URL overrides for built-in providers: `OPENAI_BASE_URL`,+`DEEPSEEK_BASE_URL`, `OLLAMA_BASE_URL`.++`loadGateways` and `loadGatewaysWithDotenv` always use built-in provider defaults.+Custom providers from a co-located `providers.json` apply when loading models via+`loadModelOrThrow` or `loadModelsOrThrow`.+ ### Loading models ```haskell@@ -202,7 +229,7 @@ | `LLM.Generate` | Single-request generation with fallbacks (`generateTextWithFallbacks`, `streamTextWithFallbacks`, `genObject`) | | `LLM.Agent` | Agent loops with tool execution (`generateText`, `streamText`) | | `LLM.Tools` | Built-in filesystem tool definitions |-| `LLM.Load` | Model catalog loading, gateway init, `fsTools` |+| `LLM.Load` | Model and provider catalog loading, gateway init, `fsTools` | Import `LLM` for the common surface, or import sub-modules directly for agent loops, providers, and advanced configuration. Sub-module APIs are exposed but considered experimental in 0.1.x.
llm-simple.cabal view
@@ -2,7 +2,7 @@ name: llm-simple -version: 0.1.0.1+version: 0.1.0.2 synopsis: Multi-provider LLM library with agent tool loops and filesystem tools@@ -13,8 +13,8 @@ . Features multi-provider gateways (OpenAI, Claude, Gemini, Ollama, DeepSeek), single-shot text/stream/object generation with model fallbacks, agent tool- loops, structured output via Autodocodec, JSON model-catalog loading, and- workspace-scoped filesystem tools.+ loops, structured output via Autodocodec, JSON model- and provider-catalog+ loading, and workspace-scoped filesystem tools. . Status: early 0.1.x release — APIs may change. @@ -40,6 +40,7 @@ extra-source-files: model-catalog.json+ providers.json tested-with: GHC ==9.6.7@@ -127,6 +128,7 @@ LLM.Load LLM.Load.LoadGateways LLM.Load.ModelCatalog+ LLM.Load.ProviderCatalog LLM.Load.LoadModels LLM.Load.Types LLM.Load.Utils@@ -239,5 +241,6 @@ hspec >=2.10 && <2.12, heptapod >=1.1 && <1.2, llm-simple,+ mtl >=2.3 && <2.4, retry >=0.9 && <0.10 build-tool-depends: hspec-discover:hspec-discover
+ providers.json view
@@ -0,0 +1,34 @@+[+ {+ "providerName": "openai",+ "protocol": "openai",+ "baseUrl": "https://api.openai.com",+ "apiKeyEnv": "OPENAI_API_KEY",+ "baseUrlEnv": "OPENAI_BASE_URL"+ },+ {+ "providerName": "claude",+ "protocol": "claude",+ "baseUrl": "https://api.anthropic.com",+ "apiKeyEnv": "CLAUDE_API_KEY"+ },+ {+ "providerName": "gemini",+ "protocol": "gemini",+ "baseUrl": "https://generativelanguage.googleapis.com",+ "apiKeyEnv": "GEMINI_API_KEY"+ },+ {+ "providerName": "deepseek",+ "protocol": "deepseek",+ "baseUrl": "https://api.deepseek.com",+ "apiKeyEnv": "DEEPSEEK_API_KEY",+ "baseUrlEnv": "DEEPSEEK_BASE_URL"+ },+ {+ "providerName": "ollama",+ "protocol": "ollama",+ "baseUrl": "http://localhost:11434",+ "baseUrlEnv": "OLLAMA_BASE_URL"+ }+]
src/LLM/Load.hs view
@@ -22,46 +22,54 @@ -- -- @ ----- available providers:------ * "openai"------ * "claude"------ * "gemini"------ * "ollama"------ * "deepseek"---+-- available providers come from built-in defaults. Place an optional+-- @providers.json@ next to your model catalog to add OpenAI-compatible+-- providers or override built-in endpoints; file entries merge on top of+-- the defaults. -- -- If a model (resp. its provider) requires an API key, it must be set in the -- process environment. This module does not load a @.env@ file automatically; -- use @Configuration.Dotenv.loadFile@ in your @main@ or call -- 'loadGatewaysWithDotenv' when building gateways yourself. --+-- Built-in API key environment variables:+-- -- @ -- GEMINI_API_KEY=... -- CLAUDE_API_KEY=... -- OPENAI_API_KEY=... -- DEEPSEEK_API_KEY=... -- @+--+-- Optional base URL overrides: @OPENAI_BASE_URL@, @DEEPSEEK_BASE_URL@,+-- @OLLAMA_BASE_URL@. module LLM.Load ( loadModelsOrThrow, loadModelOrThrow, LoadConfigError (..), loadGateways, loadGatewaysWithDotenv,+ loadGatewaysFromCatalog, loadModelCatalog,+ loadProviderCatalog, ModelCatalogItem (..), ModelCatalogMap,+ ProviderCatalogItem (..),+ ProviderCatalogMap,+ ProviderProtocol (..), fsTools, fsTools', ) where import LLM.Load.FsTools (fsTools, fsTools')-import LLM.Load.LoadGateways (loadGateways, loadGatewaysWithDotenv)+import LLM.Load.LoadGateways (loadGateways, loadGatewaysFromCatalog, loadGatewaysWithDotenv) import LLM.Load.LoadModels (loadModelOrThrow, loadModelsOrThrow) import LLM.Load.ModelCatalog (ModelCatalogItem (..), ModelCatalogMap, loadModelCatalog)+import LLM.Load.ProviderCatalog+ ( ProviderCatalogItem (..),+ ProviderCatalogMap,+ ProviderProtocol (..),+ loadProviderCatalog,+ ) import LLM.Load.Types (LoadConfigError (..))
src/LLM/Load/LoadGateways.hs view
@@ -1,46 +1,188 @@ module LLM.Load.LoadGateways ( GatewayMap,+ ProviderBaseUrl (..),+ buildGateway, loadGateways, loadGatewaysWithDotenv,+ loadGatewaysFromCatalog,+ loadGatewaysFromCatalogOrThrow,+ parseProviderBaseUrl, ) where import Configuration.Dotenv (defaultConfig, loadFile)-import Control.Exception (SomeException, catch)-import Control.Lens ((<&>))+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 (deepSeekGateway)+import LLM.Providers.DeepSeek (deepSeekGatewayWith) import LLM.Providers.Gemini (geminiGateway)-import LLM.Providers.Ollama (ollamaGateway)-import LLM.Providers.OpenAI (openAIGateway)+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 --- | Build a gateway map from the current process environment.--- Does not read a @.env@ file; set variables yourself or use--- 'loadGatewaysWithDotenv'.+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 = loadGatewaysFromEnv+loadGateways = loadGatewaysFromCatalog defaultProviderCatalogMap --- | Load @.env@ (if present) and then build a gateway map from the--- environment. Convenience for local development; prefer explicit env--- injection in production.+-- | 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 ()- loadGatewaysFromEnv--loadGatewaysFromEnv :: IO GatewayMap-loadGatewaysFromEnv = do- let ollama = Just ("ollama", ollamaGateway)- openai <- lookupEnv "OPENAI_API_KEY" <&> fmap (("openai",) . openAIGateway . T.pack)- claude <- lookupEnv "CLAUDE_API_KEY" <&> fmap (("claude",) . claudeGateway . T.pack)- gemini <- lookupEnv "GEMINI_API_KEY" <&> fmap (("gemini",) . geminiGateway . T.pack)- deepseek <- lookupEnv "DEEPSEEK_API_KEY" <&> fmap (("deepseek",) . deepSeekGateway . T.pack)- pure $ Map.fromList $ catMaybes [openai, claude, gemini, deepseek, ollama]+ loadGateways
src/LLM/Load/LoadModels.hs view
@@ -20,15 +20,17 @@ import Data.Text (Text) import LLM.Core.Types (ThinkingMode (..)) import LLM.Generate.ModelConfig (ModelConfig (..))-import LLM.Load.LoadGateways (GatewayMap, loadGateways)+import LLM.Load.LoadGateways (GatewayMap, loadGatewaysFromCatalogOrThrow) import LLM.Load.ModelCatalog (ModelCatalogItem (..), loadModelCatalog)+import LLM.Load.ProviderCatalog (loadProviderCatalogForModelCatalog) import LLM.Load.Types (LoadConfigError (LoadModelConfigError)) type ModelConfigMap = Map Text ModelConfig loadModelConfigMap :: FilePath -> ExceptT LoadConfigError IO (ModelConfigMap, GatewayMap) loadModelConfigMap filePath = do- gatewayMap <- liftIO loadGateways+ providerCatalog <- loadProviderCatalogForModelCatalog filePath+ gatewayMap <- liftIO $ loadGatewaysFromCatalogOrThrow providerCatalog modelCatalogMap <- loadModelCatalog filePath modelConfigs <- forM (Map.toList modelCatalogMap) $ \(modelConfigName, item) -> do case Map.lookup item.providerName gatewayMap of
src/LLM/Load/ModelCatalog.hs view
@@ -13,7 +13,7 @@ data ModelCatalogItem = ModelCatalogItem { modelConfigName :: Text,- providerName :: Text, -- provider name: "openai", "claude", "gemini", "ollama"+ providerName :: Text, -- key from providers.json (built-in or custom) modelName :: Text, pricing :: PricingInfo, maxTokens :: Int,
+ src/LLM/Load/ProviderCatalog.hs view
@@ -0,0 +1,134 @@+module LLM.Load.ProviderCatalog+ ( ProviderProtocol (..),+ ProviderCatalogItem (..),+ ProviderCatalogMap,+ defaultProviderCatalogMap,+ loadProviderCatalog,+ loadProviderCatalogForModelCatalog,+ )+where++import Control.Monad.Except (ExceptT (..))+import Control.Monad.IO.Class (liftIO)+import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON), withText)+import Data.Map (Map)+import Data.Map qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)+import LLM.Load.Types (LoadConfigError (..))+import LLM.Load.Utils (decodeJsonFile)+import System.Directory (doesFileExist)+import System.FilePath ((</>), takeDirectory)++data ProviderProtocol+ = OpenAIProtocol+ | ClaudeProtocol+ | GeminiProtocol+ | OllamaProtocol+ | DeepSeekProtocol+ deriving (Show, Eq, Ord)++instance ToJSON ProviderProtocol where+ toJSON = toJSON . protocolToText++instance FromJSON ProviderProtocol where+ parseJSON = withText "ProviderProtocol" $ \t ->+ case T.toLower t of+ "openai" -> pure OpenAIProtocol+ "claude" -> pure ClaudeProtocol+ "gemini" -> pure GeminiProtocol+ "ollama" -> pure OllamaProtocol+ "deepseek" -> pure DeepSeekProtocol+ other -> fail $ "unknown provider protocol: " <> T.unpack other++protocolToText :: ProviderProtocol -> Text+protocolToText = \case+ OpenAIProtocol -> "openai"+ ClaudeProtocol -> "claude"+ GeminiProtocol -> "gemini"+ OllamaProtocol -> "ollama"+ DeepSeekProtocol -> "deepseek"++data ProviderCatalogItem = ProviderCatalogItem+ { providerName :: Text,+ protocol :: ProviderProtocol,+ baseUrl :: Text,+ apiKeyEnv :: Maybe Text,+ baseUrlEnv :: Maybe Text+ }+ deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)++type ProviderCatalogMap = Map Text ProviderCatalogItem++defaultProviderCatalogMap :: ProviderCatalogMap+defaultProviderCatalogMap =+ Map.fromList+ [ ("openai", openaiItem),+ ("claude", claudeItem),+ ("gemini", geminiItem),+ ("deepseek", deepseekItem),+ ("ollama", ollamaItem)+ ]+ where+ openaiItem =+ ProviderCatalogItem+ { providerName = "openai",+ protocol = OpenAIProtocol,+ baseUrl = "https://api.openai.com",+ apiKeyEnv = Just "OPENAI_API_KEY",+ baseUrlEnv = Just "OPENAI_BASE_URL"+ }+ claudeItem =+ ProviderCatalogItem+ { providerName = "claude",+ protocol = ClaudeProtocol,+ baseUrl = "https://api.anthropic.com",+ apiKeyEnv = Just "CLAUDE_API_KEY",+ baseUrlEnv = Nothing+ }+ geminiItem =+ ProviderCatalogItem+ { providerName = "gemini",+ protocol = GeminiProtocol,+ baseUrl = "https://generativelanguage.googleapis.com",+ apiKeyEnv = Just "GEMINI_API_KEY",+ baseUrlEnv = Nothing+ }+ deepseekItem =+ ProviderCatalogItem+ { providerName = "deepseek",+ protocol = DeepSeekProtocol,+ baseUrl = "https://api.deepseek.com",+ apiKeyEnv = Just "DEEPSEEK_API_KEY",+ baseUrlEnv = Just "DEEPSEEK_BASE_URL"+ }+ ollamaItem =+ ProviderCatalogItem+ { providerName = "ollama",+ protocol = OllamaProtocol,+ baseUrl = "http://localhost:11434",+ apiKeyEnv = Nothing,+ baseUrlEnv = Just "OLLAMA_BASE_URL"+ }++loadProviderCatalog :: FilePath -> ExceptT LoadConfigError IO ProviderCatalogMap+loadProviderCatalog filePath =+ Map.fromList . fmap (\item -> (item.providerName, item))+ <$> decodeJsonFile filePath (LoadProviderCatalogError . T.pack)++-- | Load providers for a model catalog path.+--+-- Built-in providers are always available. When @providers.json@ exists in the+-- same directory as the model catalog, its entries are merged on top: file+-- entries add new providers or override built-ins with the same+-- @providerName@.+loadProviderCatalogForModelCatalog :: FilePath -> ExceptT LoadConfigError IO ProviderCatalogMap+loadProviderCatalogForModelCatalog modelCatalogPath = do+ let providerPath = takeDirectory modelCatalogPath </> "providers.json"+ exists <- liftIO $ doesFileExist providerPath+ fileCatalog <-+ if exists+ then loadProviderCatalog providerPath+ else pure Map.empty+ pure $ Map.union fileCatalog defaultProviderCatalogMap
src/LLM/Load/Types.hs view
@@ -7,6 +7,8 @@ data LoadConfigError = -- | The catalog file is missing or contains invalid JSON. LoadModelCatalogError Text+ | -- | The provider catalog file is missing or contains invalid JSON.+ LoadProviderCatalogError Text | -- | A catalog entry references an unknown model or provider. LoadModelConfigError Text | -- | A @file:@ system prompt path could not be read.
src/LLM/Providers/Ollama.hs view
@@ -35,7 +35,6 @@ ( Option, POST (POST), ReqBodyJson (ReqBodyJson),- Scheme (Http), Url, http, jsonResponse,@@ -53,7 +52,7 @@ ollamaGateway = toGateway ollamaProvider -- | Create a LLMGateway for a custom Ollama instance.-ollamaGatewayWith :: Url 'Http -> Option 'Http -> LLMGateway+ollamaGatewayWith :: Url scheme -> Option scheme -> LLMGateway ollamaGatewayWith baseUrl baseOpts = toGateway (ollamaProviderWith baseUrl baseOpts) -- | Default Ollama provider at localhost:11434.
src/LLM/Providers/OpenAI.hs view
@@ -1,8 +1,10 @@ module LLM.Providers.OpenAI ( openAIGateway, openAIGatewayWith,+ openAIGatewayWithName, openAIProvider, openAIProviderWith,+ openAIProviderWithName, parseOpenAIResponse, parseOpenAIUsage, buildMessages,@@ -85,16 +87,23 @@ -- | Create an OpenAI-compatible client with a custom base URL. openAIGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway-openAIGatewayWith baseUrl baseOpts apiKey = toGateway (openAIProviderWith baseUrl baseOpts apiKey)+openAIGatewayWith = openAIGatewayWithName "openai" +-- | Create an OpenAI-compatible client with a custom provider name and base URL.+openAIGatewayWithName :: Text -> Url scheme -> Option scheme -> Text -> LLMGateway+openAIGatewayWithName name baseUrl baseOpts apiKey = toGateway (openAIProviderWithName name baseUrl baseOpts apiKey)+ -- | Create an OpenAI provider. Takes the API key as a parameter. openAIProvider :: Text -> LLMProvider openAIProvider = openAIProviderWith (https "api.openai.com") mempty openAIProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider-openAIProviderWith baseUrl baseOpts apiKey =+openAIProviderWith = openAIProviderWithName "openai"++openAIProviderWithName :: Text -> Url scheme -> Option scheme -> Text -> LLMProvider+openAIProviderWithName name baseUrl baseOpts apiKey = LLMProvider- { providerName = "openai",+ { providerName = name, buildBody = openAIBuildBody, sendRequest = sendRequest, sendStreamRequest = \body callback ->
test/LLM/LoadSpec.hs view
@@ -1,14 +1,23 @@ module LLM.LoadSpec (spec) where import Control.Exception (IOException, SomeException, catch, displayException, fromException, try)+import Control.Monad.Except (runExceptT) import Data.Aeson (Value (Array), eitherDecodeFileStrict') import Data.Map qualified as Map import Data.Maybe (isJust) import Data.Text (Text) import Data.Text qualified as T+import LLM.Core.Types (LLMGateway (..)) import LLM.Generate.ModelConfig (ModelConfig (..))-import LLM.Load.LoadGateways (loadGateways)+import LLM.Load.LoadGateways (buildGateway, loadGateways, loadGatewaysFromCatalog, parseProviderBaseUrl) import LLM.Load.LoadModels (loadModelOrThrow, loadModelOrThrow_, loadModelsOrThrow)+import LLM.Load.ProviderCatalog+ ( ProviderCatalogItem (..),+ ProviderProtocol (..),+ defaultProviderCatalogMap,+ loadProviderCatalog,+ loadProviderCatalogForModelCatalog,+ ) import LLM.Load.Types (LoadConfigError (..)) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)@@ -25,6 +34,12 @@ ollamaCatalog :: FilePath ollamaCatalog = "./test/fixtures/ollama-catalog.json" +providersOllamaOnly :: FilePath+providersOllamaOnly = "./test/fixtures/providers-ollama-only.json"++providersOpenrouterOnly :: FilePath+providersOpenrouterOnly = "./test/fixtures/providers-openrouter-only.json"+ spec :: Spec spec = describe "Load" $ do describe "model catalog JSON" $ do@@ -66,6 +81,66 @@ gateways <- loadGateways Map.member "ollama" gateways `shouldBe` True + it "builds an openai-compatible gateway from a custom provider catalog" $ do+ let item =+ ProviderCatalogItem+ { providerName = "openrouter",+ protocol = OpenAIProtocol,+ baseUrl = "https://openrouter.ai/api",+ apiKeyEnv = Just "OPENROUTER_API_KEY",+ baseUrlEnv = Nothing+ }+ Right baseUrl <- pure $ parseProviderBaseUrl item.baseUrl+ let gateway = buildGateway item baseUrl "test-key"+ LLMGateway {gwName = name} = gateway+ name `shouldBe` "openrouter"++ it "loads gateways from a provider catalog file" $ do+ result <- runExceptT $ loadProviderCatalog providersOllamaOnly+ case result of+ Left err -> expectationFailure $ show err+ Right catalog -> do+ gateways <- loadGatewaysFromCatalog catalog+ Map.keys gateways `shouldSatisfy` ("ollama" `elem`)++ describe "provider catalog JSON" $ do+ it "parses the bundled provider catalog" $ do+ result <- eitherDecodeFileStrict' "./providers.json"+ case result of+ Right (Array xs) -> length xs `shouldSatisfy` (> 0)+ _ -> expectationFailure "expected a JSON array"++ it "includes built-in providers in the default catalog" $ do+ Map.member "openai" defaultProviderCatalogMap `shouldBe` True+ Map.member "ollama" defaultProviderCatalogMap `shouldBe` True++ it "merges a partial providers.json with built-in defaults" $+ withProviderCatalogDir providersOpenrouterOnly $ \catalogPath -> do+ result <- runExceptT $ loadProviderCatalogForModelCatalog catalogPath+ case result of+ Left err -> expectationFailure $ show err+ Right catalog -> do+ Map.member "openrouter" catalog `shouldBe` True+ Map.member "openai" catalog `shouldBe` True+ Map.member "ollama" catalog `shouldBe` True++ it "lets providers.json override a built-in provider" $+ withProviderCatalogDir providersOllamaOnly $ \catalogPath -> do+ result <- runExceptT $ loadProviderCatalogForModelCatalog catalogPath+ case result of+ Left err -> expectationFailure $ show err+ Right catalog -> do+ Map.lookup "ollama" catalog+ `shouldBe` Just+ ProviderCatalogItem+ { providerName = "ollama",+ protocol = OllamaProtocol,+ baseUrl = "http://localhost:11434",+ apiKeyEnv = Nothing,+ baseUrlEnv = Nothing+ }+ Map.member "openai" catalog `shouldBe` True+ describe "invalid catalog JSON" $ do it "reports a catalog parse/load error" $ withTempCatalog "[not valid json" $ \path -> do result <- try @LoadConfigError (loadModelOrThrow path "x")@@ -80,3 +155,13 @@ let path = root </> "catalog.json" writeFile path contents act path `catch` \(_ :: IOException) -> act path++withProviderCatalogDir :: FilePath -> (FilePath -> IO a) -> IO a+withProviderCatalogDir providersFixture act =+ withSystemTempDirectory "llm-simple-providers" $ \root -> do+ let catalogPath = root </> "model-catalog.json"+ providersPath = root </> "providers.json"+ writeFile catalogPath "[]"+ providersJson <- readFile providersFixture+ writeFile providersPath providersJson+ act catalogPath `catch` \(_ :: IOException) -> act catalogPath