llm-simple 0.1.0.2 → 0.1.1.0
raw patch · 21 files changed
+269/−97 lines, 21 filesdep ~basedep ~bytestringdep ~containersPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: base, bytestring, containers
API changes (from Hackage documentation)
+ LLM: createGenRequestNoTools :: Agent -> RuntimeArgs -> [Turn] -> GenRequest
+ LLM.Agent: createGenRequestNoTools :: Agent -> RuntimeArgs -> [Turn] -> GenRequest
+ LLM.Agent.ToolUtils: createGenRequestNoTools :: Agent -> RuntimeArgs -> [Turn] -> GenRequest
+ LLM.Providers: claudeGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
+ LLM.Providers: claudeProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
+ LLM.Providers: geminiGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
+ LLM.Providers: geminiProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
+ LLM.Providers.Claude: claudeGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
+ LLM.Providers.Claude: claudeProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
+ LLM.Providers.Gemini: geminiGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway
+ LLM.Providers.Gemini: geminiProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider
Files
- CHANGELOG.md +27/−0
- Readme.md +7/−6
- app/Main.hs +1/−1
- llm-simple.cabal +16/−13
- model-catalog.json +25/−25
- providers.json +4/−2
- src/LLM.hs +2/−0
- src/LLM/Agent.hs +1/−0
- src/LLM/Agent/GenerateObject.hs +8/−5
- src/LLM/Agent/ToolUtils.hs +18/−0
- src/LLM/Agent/Tools/HistoryTool.hs +12/−3
- src/LLM/Generate/GenerateUtils.hs +1/−1
- src/LLM/Load/LoadGateways.hs +8/−4
- src/LLM/Load/ProviderCatalog.hs +2/−2
- src/LLM/Providers.hs +6/−2
- src/LLM/Providers/Claude.hs +24/−17
- src/LLM/Providers/Gemini.hs +27/−8
- test/LLM/GenerateObjectSpec.hs +31/−1
- test/LLM/HistoryToolSpec.hs +21/−3
- test/LLM/LoadSpec.hs +28/−0
- test/LLM/TypesSpec.hs +0/−4
CHANGELOG.md view
@@ -7,6 +7,32 @@ ## [Unreleased] +## [0.1.1.0] - 2026-08-23++### Changed++- Widen dependency bounds for GHC 9.8–9.12 (`base`, `bytestring`, `containers`).+ `tested-with`: GHC 9.6.7, 9.8.4, 9.10.2, 9.12.2. GitHub Actions CI runs+ `cabal test` and `cabal haddock` on that matrix.+- Example `model-catalog.json`: default Gemini entry is `gemini_lite` (`gemini-3.1-flash-lite`);+ removed deprecated `gemini-2.5-flash` config; raised example `maxTokens` to 4096; added+ optional `gpt_5_6_terra` (`gpt-5.6-terra`); corrected `gemini_lite` and `deepseek4flash`+ pricing (DeepSeek rates are peak cache-miss; off-peak is half).+- `get_history` tool description now documents the `"(no earlier history)"` /+ `"(no more history)"` sentinels instead of claiming an empty result.++### Fixed++- Claude and Gemini gateways honor catalog `baseUrl` / `baseUrlEnv` (previously+ ignored; always hit the public Anthropic/Google hosts). New+ `claudeGatewayWith` / `geminiGatewayWith` constructors; optional+ `CLAUDE_BASE_URL` and `GEMINI_BASE_URL` env overrides.+- `get_history` no longer hangs when the visible context window has zero user+ turns (page size 0); `chunkBackward` treats `n <= 0` as a single unpaged chunk.+- `generateObject` / `generateObjectUntyped` never advertise tools (including+ auto-injected `get_history`), even when `agContextWindow` is set. New+ `createGenRequestNoTools` helper; windowing still truncates messages.+ ## [0.1.0.2] - 2026-07-15 ### Added@@ -59,6 +85,7 @@ DeepSeek), single-shot generation with fallbacks, agent tool loops, structured output, JSON model catalog loading, and workspace-scoped filesystem tools. +[0.1.1.0]: https://github.com/aische/llm-simple/compare/v0.1.0.2...v0.1.1.0 [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
@@ -21,7 +21,7 @@ cabal build ``` -Requires GHC 9.6+ with `GHC2021` (see `llm-simple.cabal`).+Requires GHC 9.6–9.12 with `GHC2021` (see `tested-with` in `llm-simple.cabal`). ## Quick start @@ -84,7 +84,7 @@ | ------------- | --------------------------------------------------------------------------- | | `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`) |+| `baseUrl` | Provider origin (scheme+host[+port[+optional path prefix]]). OpenAI/DeepSeek/Ollama append `/v1/chat/completions`; Claude appends `/v1/messages`; Gemini appends `/v1beta/models/{model}:generateContent` | | `apiKeyEnv` | Environment variable holding the API key (omit for keyless providers) | | `baseUrlEnv` | Optional env var that overrides `baseUrl` at runtime | @@ -93,8 +93,9 @@ `providerName` from your model catalog. Optional base URL overrides for built-in providers: `OPENAI_BASE_URL`,-`DEEPSEEK_BASE_URL`, `OLLAMA_BASE_URL`.-+`CLAUDE_BASE_URL`, `GEMINI_BASE_URL`, `DEEPSEEK_BASE_URL`, `OLLAMA_BASE_URL`.+Do not include the protocol path (`/v1/...` or `/v1beta/...`) in `baseUrl`; the+library appends it. `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`.@@ -109,7 +110,7 @@ main = do loadFile defaultConfig `catch` \(_ :: SomeException) -> pure () (gemini, deepseek) <-- loadModelsOrThrow "./model-catalog.json" ("gemini_2_5_flash", "deepseek4flash")+ loadModelsOrThrow "./model-catalog.json" ("gemini_lite", "deepseek4flash") ... ``` @@ -135,7 +136,7 @@ loadFile defaultConfig `catch` \(_ :: SomeException) -> pure () (gemini, deepseek) <-- loadModelsOrThrow "./model-catalog.json" ("gemini_2_5_flash", "deepseek4flash")+ loadModelsOrThrow "./model-catalog.json" ("gemini_lite", "deepseek4flash") let models = ModelWithFallbacks { mwfModel = gemini, mwfFallbacks = [deepseek] }
app/Main.hs view
@@ -30,7 +30,7 @@ (_gpt, llama, _haiku, gemini, mistral, deepseek) <- loadModelsOrThrow "./model-catalog.json"- ("gpt_4_1", "llama_3_2", "haiku_4_5", "gemini_2_5_flash", "mistral", "deepseek4flash")+ ("gpt_4_1", "llama_3_2", "haiku_4_5", "gemini_lite", "mistral", "deepseek4flash") let _models1 = ModelWithFallbacks {mwfModel = llama, mwfFallbacks = []} _models2 = ModelWithFallbacks {mwfModel = mistral, mwfFallbacks = []}
llm-simple.cabal view
@@ -2,7 +2,7 @@ name: llm-simple -version: 0.1.0.2+version: 0.1.1.0 synopsis: Multi-provider LLM library with agent tool loops and filesystem tools@@ -44,6 +44,9 @@ tested-with: GHC ==9.6.7+ , GHC ==9.8.4+ , GHC ==9.10.2+ , GHC ==9.12.2 source-repository head type: git@@ -142,9 +145,9 @@ aeson-pretty >=0.8 && <0.9, autodocodec >=0.5 && < 0.6, autodocodec-schema >=0.2 && <0.3,- base ^>=4.18,- bytestring >=0.11 && <0.12,- containers ^>=0.6.7,+ base >=4.18 && <4.22,+ bytestring >=0.11 && <0.13,+ containers >=0.6.7 && <0.8, directory >=1.3 && <1.4, dotenv >=0.9 && <0.13, filepath >=1.4 && <1.6,@@ -169,9 +172,9 @@ aeson >=2.0 && <2.3, autodocodec >=0.5 && < 0.6, autodocodec-schema >=0.2 && <0.3,- base ^>=4.18,- bytestring >=0.11 && <0.12,- containers ^>=0.6.7,+ base >=4.18 && <4.22,+ bytestring >=0.11 && <0.13,+ containers >=0.6.7 && <0.8, dotenv >=0.9 && <0.13, directory >=1.3 && <1.4, filepath >=1.4 && <1.6,@@ -193,9 +196,9 @@ aeson >=2.0 && <2.3, aeson-pretty >=0.8 && <0.9, autodocodec >=0.5 && < 0.6,- base ^>=4.18,- bytestring >=0.11 && <0.12,- containers ^>=0.6.7,+ base >=4.18 && <4.22,+ bytestring >=0.11 && <0.13,+ containers >=0.6.7 && <0.8, dotenv >=0.9 && <0.13, directory >=1.3 && <1.4, filepath >=1.4 && <1.6,@@ -228,8 +231,8 @@ LLM.WeatherTool hs-source-dirs: test build-depends:- base ^>=4.18,- containers ^>=0.6.7,+ base >=4.18 && <4.22,+ containers >=0.6.7 && <0.8, directory >=1.3 && <1.4, filepath >=1.4 && <1.6, temporary >=1.3 && <1.4,@@ -237,7 +240,7 @@ aeson >=2.0 && <2.3, aeson-pretty >=0.8 && <0.9, autodocodec >=0.5 && < 0.6,- bytestring >=0.11 && <0.12,+ bytestring >=0.11 && <0.13, hspec >=2.10 && <2.12, heptapod >=1.1 && <1.2, llm-simple,
model-catalog.json view
@@ -7,7 +7,7 @@ "pricePerMillionInput": 2.0, "pricePerMillionOutput": 8.0 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 10000, "throttleDelay": 1000,@@ -15,6 +15,21 @@ "jitterBackoff": 1000 }, {+ "modelConfigName": "gpt_5_6_terra",+ "providerName": "openai",+ "modelName": "gpt-5.6-terra",+ "pricing": {+ "pricePerMillionInput": 2.0,+ "pricePerMillionOutput": 12.0+ },+ "maxTokens": 4096,+ "temperature": 0.5,+ "requestTimeout": 10000,+ "throttleDelay": 1000,+ "retryCount": 3,+ "jitterBackoff": 1000+ },+ { "modelConfigName": "llama_3_2", "providerName": "ollama", "modelName": "llama3.2:latest",@@ -22,7 +37,7 @@ "pricePerMillionInput": 0.0, "pricePerMillionOutput": 0.0 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 3000, "throttleDelay": 1000,@@ -37,7 +52,7 @@ "pricePerMillionInput": 0.0, "pricePerMillionOutput": 0.0 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 30000, "throttleDelay": 1000,@@ -45,29 +60,14 @@ "jitterBackoff": 1000 }, {- "modelConfigName": "gemini_2_5_flash",- "providerName": "gemini",- "modelName": "gemini-2.5-flash",- "pricing": {- "pricePerMillionInput": 0.1,- "pricePerMillionOutput": 0.4- },- "maxTokens": 1024,- "temperature": 0.5,- "requestTimeout": 10000,- "throttleDelay": 1000,- "retryCount": 3,- "jitterBackoff": 1000- },- { "modelConfigName": "gemini_lite", "providerName": "gemini", "modelName": "gemini-3.1-flash-lite", "pricing": {- "pricePerMillionInput": 0.1,- "pricePerMillionOutput": 0.4+ "pricePerMillionInput": 0.25,+ "pricePerMillionOutput": 1.5 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 10000, "throttleDelay": 1000,@@ -82,7 +82,7 @@ "pricePerMillionInput": 1, "pricePerMillionOutput": 5 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 30000, "throttleDelay": 5000,@@ -94,10 +94,10 @@ "providerName": "deepseek", "modelName": "deepseek-v4-flash", "pricing": {- "pricePerMillionInput": 0.14,- "pricePerMillionOutput": 0.28+ "pricePerMillionInput": 0.44,+ "pricePerMillionOutput": 1.32 },- "maxTokens": 1024,+ "maxTokens": 4096, "temperature": 0.5, "requestTimeout": 20000, "throttleDelay": 3000,
providers.json view
@@ -10,13 +10,15 @@ "providerName": "claude", "protocol": "claude", "baseUrl": "https://api.anthropic.com",- "apiKeyEnv": "CLAUDE_API_KEY"+ "apiKeyEnv": "CLAUDE_API_KEY",+ "baseUrlEnv": "CLAUDE_BASE_URL" }, { "providerName": "gemini", "protocol": "gemini", "baseUrl": "https://generativelanguage.googleapis.com",- "apiKeyEnv": "GEMINI_API_KEY"+ "apiKeyEnv": "GEMINI_API_KEY",+ "baseUrlEnv": "GEMINI_BASE_URL" }, { "providerName": "deepseek",
src/LLM.hs view
@@ -98,6 +98,7 @@ Tool (..), ToolContext (..), createGenRequest,+ createGenRequestNoTools, toTool, GenerateEvent (..), GenerateEventDetail (..),@@ -125,6 +126,7 @@ Tool (..), ToolContext (..), createGenRequest,+ createGenRequestNoTools, generateText, noEventObserver, streamText,
src/LLM/Agent.hs view
@@ -26,6 +26,7 @@ noEventObserver, toTool, createGenRequest,+ createGenRequestNoTools, ) where
src/LLM/Agent/GenerateObject.hs view
@@ -1,8 +1,7 @@ module LLM.Agent.GenerateObject where import Data.Aeson (Value)-import Data.Map qualified as Map-import LLM.Agent.ToolUtils (createGenRequest)+import LLM.Agent.ToolUtils (createGenRequestNoTools) import LLM.Agent.Types ( Agent (..), RuntimeArgs (..),@@ -19,7 +18,8 @@ -- | Generate a typed Haskell value from the model via Autodocodec. ----- Tools are not used; conversation context is taken from @turns@ only.+-- Tools are never sent: @grTools@ is always empty. 'agContextWindow' still+-- truncates messages, but does not inject @get_history@. generateObject :: (GeneratableObject t) => Agent ->@@ -27,9 +27,12 @@ RuntimeArgs -> [Turn] -> IO (Either GenerateErrorResult (t, Usage))-generateObject a m r t = genObject (createGenRequest id a Map.empty r t) m+generateObject a m r t = genObject (createGenRequestNoTools a r t) m -- | Generate a JSON 'Value' from the model using a caller-supplied schema.+--+-- Same tool policy as 'generateObject': windowing may truncate, tools are not+-- advertised or executed. generateObjectUntyped :: Agent -> ModelWithFallbacks ->@@ -37,4 +40,4 @@ [Turn] -> Value -> IO (Either GenerateErrorResult (Value, Usage))-generateObjectUntyped a m r t = genObjectUntyped (createGenRequest id a Map.empty r t) m+generateObjectUntyped a m r t = genObjectUntyped (createGenRequestNoTools a r t) m
src/LLM/Agent/ToolUtils.hs view
@@ -9,6 +9,7 @@ getResolvedTools, windowOffset, createGenRequest,+ createGenRequestNoTools, embedTextTool, ) where@@ -165,6 +166,23 @@ in GenRequest { grSystemPrompt = agent.agSystemPrompt, grTools = map (\x -> x.toolDef) tools,+ grMessages = drop offset messages,+ grAbortSignal = rt.rtAbortSignal,+ grLLMHooks = rt.rtLLMHooks,+ grHooks = rt.rtHooks+ }++-- | Like 'createGenRequest', but never attaches tool definitions.+--+-- Still applies 'agContextWindow' truncation to 'grMessages'. Use this for+-- structured-output paths ('generateObject' / 'generateObjectUntyped'), which+-- cannot execute tools and must not advertise @get_history@.+createGenRequestNoTools :: Agent -> RuntimeArgs -> [Turn] -> GenRequest+createGenRequestNoTools agent rt messages =+ let offset = windowOffset agent.agContextWindow messages+ in GenRequest+ { grSystemPrompt = agent.agSystemPrompt,+ grTools = [], grMessages = drop offset messages, grAbortSignal = rt.rtAbortSignal, grLLMHooks = rt.rtLLMHooks,
src/LLM/Agent/Tools/HistoryTool.hs view
@@ -42,7 +42,8 @@ ttoolDescription = "Retrieve earlier conversation history that is not in your current context window. " <> "Pass chunk=0 for the most recent hidden history, chunk=1 for the one before that, etc. "- <> "Returns an empty result when there is no more history.",+ <> "Returns \"(no earlier history)\" when nothing is hidden, and \"(no more history)\" "+ <> "when the chunk index is out of range.", ttoolReadonly = True, ttoolExecute = getHistoryExecTyped }@@ -59,15 +60,23 @@ -- backward from the end. Each page starts at a 'UserTurn'. -- Chunk 0 is the most recent page, chunk 1 the one before, etc. -- The oldest chunk (highest index) may contain fewer than @n@ user messages.+--+-- When @n <= 0@ (e.g. the visible window has no user turns), paging is+-- disabled and the whole conversation is returned as a single chunk.+-- This avoids an infinite loop in the page walker when @start == end@. chunkBackward :: Int -> [Turn] -> [[Turn]] chunkBackward _ [] = []-chunkBackward n conv = reverse (go (length conv) [])+chunkBackward n conv+ | n <= 0 = [conv]+ | otherwise = reverse (go (length conv) []) where go 0 acc = acc go end acc = let start = findNthUserBack n (take end conv) page = slice start end conv- in go start (page : acc)+ in if start >= end+ then page : acc -- should not happen for n > 0; refuse to loop+ else go start (page : acc) -- | Find the start index for a page containing @n@ user messages, -- scanning backward from the end of the given prefix.
src/LLM/Generate/GenerateUtils.hs view
@@ -19,7 +19,7 @@ ) import LLM.Core.Usage (Usage (..), estimateCost) import LLM.Core.Utils (withRetry, withTimeout)-import LLM.Generate.Logger (Hooks (..), LogLevel (..), onLog)+import LLM.Generate.Logger (Hooks (..), LogLevel (..)) import LLM.Generate.ModelConfig ( ModelConfig (..), ModelWithFallbacks (..),
src/LLM/Load/LoadGateways.hs view
@@ -29,9 +29,9 @@ defaultProviderCatalogMap, ) import LLM.Load.Types (LoadConfigError (LoadModelConfigError))-import LLM.Providers.Claude (claudeGateway)+import LLM.Providers.Claude (claudeGatewayWith) import LLM.Providers.DeepSeek (deepSeekGatewayWith)-import LLM.Providers.Gemini (geminiGateway)+import LLM.Providers.Gemini (geminiGatewayWith) import LLM.Providers.Ollama (ollamaGatewayWith) import LLM.Providers.OpenAI (openAIGatewayWithName) import Network.HTTP.Client qualified as HC@@ -91,8 +91,12 @@ buildGateway :: ProviderCatalogItem -> ProviderBaseUrl -> Text -> LLMGateway buildGateway item baseUrl apiKey = case item.protocol of- ClaudeProtocol -> claudeGateway apiKey- GeminiProtocol -> geminiGateway apiKey+ ClaudeProtocol -> case baseUrl of+ HttpsProviderBase url opts -> claudeGatewayWith url opts apiKey+ HttpProviderBase url opts -> claudeGatewayWith url opts apiKey+ GeminiProtocol -> case baseUrl of+ HttpsProviderBase url opts -> geminiGatewayWith url opts apiKey+ HttpProviderBase url opts -> geminiGatewayWith url opts apiKey OpenAIProtocol -> case baseUrl of HttpsProviderBase url opts -> openAIGatewayWithName item.providerName url opts apiKey HttpProviderBase url opts -> openAIGatewayWithName item.providerName url opts apiKey
src/LLM/Load/ProviderCatalog.hs view
@@ -85,7 +85,7 @@ protocol = ClaudeProtocol, baseUrl = "https://api.anthropic.com", apiKeyEnv = Just "CLAUDE_API_KEY",- baseUrlEnv = Nothing+ baseUrlEnv = Just "CLAUDE_BASE_URL" } geminiItem = ProviderCatalogItem@@ -93,7 +93,7 @@ protocol = GeminiProtocol, baseUrl = "https://generativelanguage.googleapis.com", apiKeyEnv = Just "GEMINI_API_KEY",- baseUrlEnv = Nothing+ baseUrlEnv = Just "GEMINI_BASE_URL" } deepseekItem = ProviderCatalogItem
src/LLM/Providers.hs view
@@ -6,11 +6,15 @@ -- * Gemini geminiProvider,+ geminiProviderWith, geminiGateway,+ geminiGatewayWith, -- * Claude claudeProvider,+ claudeProviderWith, claudeGateway,+ claudeGatewayWith, -- * Ollama ollamaProvider,@@ -22,8 +26,8 @@ ) where -import LLM.Providers.Claude (claudeGateway, claudeProvider)+import LLM.Providers.Claude (claudeGateway, claudeGatewayWith, claudeProvider, claudeProviderWith) import LLM.Providers.DeepSeek (deepSeekGateway, deepSeekProvider)-import LLM.Providers.Gemini (geminiGateway, geminiProvider)+import LLM.Providers.Gemini (geminiGateway, geminiGatewayWith, geminiProvider, geminiProviderWith) import LLM.Providers.Ollama (ollamaGateway, ollamaProvider) import LLM.Providers.OpenAI (openAIGateway, openAIProvider)
src/LLM/Providers/Claude.hs view
@@ -1,6 +1,8 @@ module LLM.Providers.Claude ( claudeGateway,+ claudeGatewayWith, claudeProvider,+ claudeProviderWith, parseClaudeResponse, parseClaudeUsage, )@@ -53,7 +55,6 @@ ( Option, POST (POST), ReqBodyJson (ReqBodyJson),- Scheme (Https), Url, header, https,@@ -66,51 +67,57 @@ (/:), ) --- | Create a LLMGateway for the Claude provider. Takes the API key as a parameter.+-- | Create a LLMGateway for the Claude provider at api.anthropic.com. claudeGateway :: Text -> LLMGateway claudeGateway apiKey = toGateway $ claudeProvider apiKey --- | Create a LLMProvider for the Claude provider. Takes the API key as a parameter.+-- | Create a Claude-compatible client with a custom base URL (origin).+-- The library appends @/v1/messages@.+claudeGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway+claudeGatewayWith baseUrl baseOpts apiKey = toGateway (claudeProviderWith baseUrl baseOpts apiKey)++-- | Create a LLMProvider for the Claude provider at api.anthropic.com. claudeProvider :: Text -> LLMProvider-claudeProvider apiKey =+claudeProvider = claudeProviderWith (https "api.anthropic.com") mempty++-- | Claude-compatible provider with a custom base URL (origin).+-- The library appends @/v1/messages@.+claudeProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider+claudeProviderWith baseUrl baseOpts apiKey = LLMProvider { providerName = "claude", buildBody = claudeBuildBody, sendRequest = sendRequest, sendStreamRequest = \body callback ->- runReq lenientConfig $- reqBr POST claudeUrl (ReqBodyJson body) (claudeOpts apiKey) $ \resp ->+ runReq lenientConfig $ do+ let url = baseUrl /: "v1" /: "messages"+ opts = baseOpts <> claudeAuthOpts apiKey+ reqBr POST url (ReqBodyJson body) opts $ \resp -> handleStreamResponse resp (`parseClaudeStream` callback), parseResponse = pure . parseClaudeResponse,- -- buildObjectBody _ r schema = claudeBuildBody False (r {reqConversation = reqConversation r <> Conversation [UserTurn ("Generate a JSON object matching this schema: " <> T.pack (show schema))]}) buildObjectBody = \r schema -> let schemaText = TL.toStrict . decodeUtf8 $ encode schema instruction = "Respond with a raw JSON object matching this schema. No markdown, no explanation, no code fences:\n" <> schemaText conv' = r.reqConversation <> [UserTurn instruction] in claudeBuildBody False (r {reqConversation = conv'}), sendObjectRequest = sendRequest,- -- parseObjectResponse _ = parseClaudeObjectResponse parseObjectResponse = parseClaudeObjectResponse } where sendRequest body = runReq lenientConfig $ do- resp <- req POST claudeUrl (ReqBodyJson body) jsonResponse (claudeOpts apiKey)+ let url = baseUrl /: "v1" /: "messages"+ opts = baseOpts <> claudeAuthOpts apiKey+ resp <- req POST url (ReqBodyJson body) jsonResponse opts pure (responseStatusCode resp, responseBody resp) -- Internal helpers -claudeUrl :: Url 'Https-claudeUrl = https "api.anthropic.com" /: "v1" /: "messages"--claudeOpts :: Text -> Option 'Https-claudeOpts apiKey =+claudeAuthOpts :: Text -> Option scheme+claudeAuthOpts apiKey = header "x-api-key" (encodeUtf8 apiKey) <> header "anthropic-version" "2023-06-01" --- | Create an LLMClient from Claude credentials--- claudeGateway :: Text -> LLMGateway--- claudeGateway apiKey = toGateway (Claude apiKey) parseClaudeStream :: HC.BodyReader -> (StreamEvent -> IO ()) -> IO LLMTextResult parseClaudeStream reader callback = do blocksRef <- newIORef ([] :: [ContentBlock])
src/LLM/Providers/Gemini.hs view
@@ -1,6 +1,8 @@ module LLM.Providers.Gemini ( geminiGateway,+ geminiGatewayWith, geminiProvider,+ geminiProviderWith, parseGeminiResponse, parseGeminiUsage, )@@ -53,8 +55,10 @@ import LLM.Core.Usage (Usage (..)) import Network.HTTP.Client qualified as HC import Network.HTTP.Req- ( POST (POST),+ ( Option,+ POST (POST), ReqBodyJson (ReqBodyJson),+ Url, header, https, jsonResponse,@@ -67,13 +71,23 @@ (=:), ) --- | Create a LLMGateway for the Gemini provider. Takes the API key as a parameter.+-- | Create a LLMGateway for the Gemini provider at generativelanguage.googleapis.com. geminiGateway :: Text -> LLMGateway geminiGateway apiKey = toGateway (geminiProvider apiKey) --- | Create a LLMProvider for the Gemini provider. Takes the API key as a parameter.+-- | Create a Gemini-compatible client with a custom base URL (origin).+-- The library appends @/v1beta/models/{model}:generateContent@ (and the stream variant).+geminiGatewayWith :: Url scheme -> Option scheme -> Text -> LLMGateway+geminiGatewayWith baseUrl baseOpts apiKey = toGateway (geminiProviderWith baseUrl baseOpts apiKey)++-- | Create a LLMProvider for the Gemini provider at generativelanguage.googleapis.com. geminiProvider :: Text -> LLMProvider-geminiProvider apiKey =+geminiProvider = geminiProviderWith (https "generativelanguage.googleapis.com") mempty++-- | Gemini-compatible provider with a custom base URL (origin).+-- The library appends @/v1beta/models/{model}:generateContent@ (and the stream variant).+geminiProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider+geminiProviderWith baseUrl baseOpts apiKey = LLMProvider { providerName = "gemini", buildBody = const geminiBuildBody,@@ -82,11 +96,12 @@ runReq lenientConfig $ do let model = extractModel body url =- https "generativelanguage.googleapis.com"+ baseUrl /: "v1beta" /: "models" /: (model <> ":streamGenerateContent")- reqBr POST url (ReqBodyJson (stripBoundsAndComments $ stripModel body)) (header "x-goog-api-key" (encodeUtf8 apiKey) <> "alt" =: ("sse" :: Text)) $ \resp ->+ opts = baseOpts <> geminiAuthOpts apiKey <> "alt" =: ("sse" :: Text)+ reqBr POST url (ReqBodyJson (stripBoundsAndComments $ stripModel body)) opts $ \resp -> handleStreamResponse resp (`parseGeminiStream` callback), parseResponse = parseGeminiResponse, buildObjectBody = \r schema ->@@ -118,12 +133,16 @@ -- We extract it from the request body JSON since the LLMProvider only passes Value. let model = extractModel body url =- https "generativelanguage.googleapis.com"+ baseUrl /: "v1beta" /: "models" /: (model <> ":generateContent")- resp <- req POST url (ReqBodyJson (stripBoundsAndComments $ stripModel body)) jsonResponse (header "x-goog-api-key" (encodeUtf8 apiKey))+ opts = baseOpts <> geminiAuthOpts apiKey+ resp <- req POST url (ReqBodyJson (stripBoundsAndComments $ stripModel body)) jsonResponse opts pure (responseStatusCode resp, responseBody resp)++geminiAuthOpts :: Text -> Option scheme+geminiAuthOpts apiKey = header "x-goog-api-key" (encodeUtf8 apiKey) -- | Extract model name stashed in the request body by geminiBuildBody. extractModel :: Value -> Text
test/LLM/GenerateObjectSpec.hs view
@@ -3,13 +3,14 @@ module LLM.GenerateObjectSpec (spec) where import Data.Aeson (Value, object, (.=))+import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Text (Text) import Data.Text qualified as T import Heptapod (generate) import LLM.Agent.GenerateObject (generateObject, generateObjectUntyped) import LLM.Agent.Types (Agent (..), RuntimeArgs (..)) import LLM.Core.Abort (AbortSignal, abort, newAbortSignal)-import LLM.Core.Types (ChatResponse (..), LLMError (..), LLMGateway (..), LLMHooks (..), Turn (..))+import LLM.Core.Types (ChatRequest (..), ChatResponse (..), LLMError (..), LLMGateway (..), LLMHooks (..), Turn (..)) import LLM.Core.Usage (PricingInfo (..), Usage (..)) import LLM.Generate.Logger (noHooks) import LLM.Generate.ModelConfig@@ -88,6 +89,26 @@ T.unpack msg `shouldContain` "Can't decode object" _ -> expectationFailure "expected GErrParseObjectError" + it "does not advertise tools even when agContextWindow would inject get_history" $ do+ captured <- newIORef Nothing+ let gw = capturingObjectGateway captured (object ["location" .= ("Paris" :: Text)]) (Usage 1 0 0)+ models = ModelWithFallbacks (mockModel gw) []+ agent = defaultAgent {agContextWindow = Just 1}+ conv =+ [ UserTurn "first",+ AssistantTurn "a1" Nothing [],+ UserTurn "second"+ ]+ rt <- mkRuntime Nothing+ result <- generateObjectUntyped agent models rt conv (object ["type" .= ("object" :: Text)])+ case result of+ Right _ -> do+ mReq <- readIORef captured+ case mReq of+ Nothing -> expectationFailure "expected gwGenerateObject to be called"+ Just req -> req.reqTools `shouldBe` []+ Left err -> expectationFailure $ show err+ objectGateway :: Value -> Usage -> LLMGateway objectGateway value usage = LLMGateway@@ -95,6 +116,15 @@ gwGenerateText = \_ _ -> pure (Right (ChatResponse "" [] Nothing Nothing)), gwStreamText = \_ _ _ -> pure (Right (ChatResponse "" [] Nothing Nothing)), gwGenerateObject = \_ _ _ -> pure (Right (value, Just usage))+ }++-- | Mock that records the 'ChatRequest' seen by 'gwGenerateObject'.+capturingObjectGateway :: IORef (Maybe ChatRequest) -> Value -> Usage -> LLMGateway+capturingObjectGateway ref value usage =+ (objectGateway value usage)+ { gwGenerateObject = \_ _ req -> do+ writeIORef ref (Just req)+ pure (Right (value, Just usage)) } errorObjectGateway :: LLMError -> LLMGateway
test/LLM/HistoryToolSpec.hs view
@@ -52,6 +52,22 @@ result <- runHistory windowedAgent sampleConversation 9 result `shouldBe` "(no more history)" + -- When the visible window has zero UserTurns, page size is 0. Previously+ -- chunkBackward looped forever; it now returns the whole hidden prefix as+ -- a single chunk.+ it "returns the full hidden prefix when the visible window has no user turns" $ do+ let conv =+ [ UserTurn "hidden question",+ AssistantTurn "hidden answer" Nothing [],+ AssistantTurn "visible assistant only" Nothing []+ ]+ -- Offset past both user+assistant hidden turns; visible slice is+ -- assistant-only so countUserTurns == 0.+ offset = 2+ result <- runHistoryAtOffset offset conv 0+ T.unpack result `shouldContain` "[User] hidden question"+ T.unpack result `shouldContain` "[Assistant] hidden answer"+ sampleConversation :: [Turn] sampleConversation = [ UserTurn "first question",@@ -76,10 +92,12 @@ windowedAgent = noWindowAgent {agContextWindow = Just 1} runHistory :: Agent -> [Turn] -> Int -> IO Text-runHistory agent conv chunk = do+runHistory agent conv = runHistoryAtOffset (windowOffset agent.agContextWindow conv) conv++runHistoryAtOffset :: Int -> [Turn] -> Int -> IO Text+runHistoryAtOffset offset conv chunk = do genId <- generate- let offset = windowOffset agent.agContextWindow conv- ctx =+ let ctx = ToolContext { tcConversation = conv, tcUsage = mempty,
test/LLM/LoadSpec.hs view
@@ -95,6 +95,34 @@ LLMGateway {gwName = name} = gateway name `shouldBe` "openrouter" + it "builds a claude gateway from a custom baseUrl origin" $ do+ let item =+ ProviderCatalogItem+ { providerName = "claude",+ protocol = ClaudeProtocol,+ baseUrl = "https://claude-proxy.example.com",+ apiKeyEnv = Just "CLAUDE_API_KEY",+ baseUrlEnv = Nothing+ }+ Right baseUrl <- pure $ parseProviderBaseUrl item.baseUrl+ let gateway = buildGateway item baseUrl "test-key"+ LLMGateway {gwName = name} = gateway+ name `shouldBe` "claude"++ it "builds a gemini gateway from a custom baseUrl origin" $ do+ let item =+ ProviderCatalogItem+ { providerName = "gemini",+ protocol = GeminiProtocol,+ baseUrl = "https://gemini-proxy.example.com",+ apiKeyEnv = Just "GEMINI_API_KEY",+ baseUrlEnv = Nothing+ }+ Right baseUrl <- pure $ parseProviderBaseUrl item.baseUrl+ let gateway = buildGateway item baseUrl "test-key"+ LLMGateway {gwName = name} = gateway+ name `shouldBe` "gemini"+ it "loads gateways from a provider catalog file" $ do result <- runExceptT $ loadProviderCatalog providersOllamaOnly case result of
test/LLM/TypesSpec.hs view
@@ -13,10 +13,6 @@ addUsage, emptyUsage, estimateCost,- pricePerMillionInput,- pricePerMillionOutput,- usageInputTokens,- usageOutputTokens, ) import LLM.Core.Utils ( getToolCalls,