packages feed

llm-simple 0.1.1.0 → 0.2.0.0

raw patch · 35 files changed

+2959/−529 lines, 35 filesdep +scientificPVP ok

version bump matches the API change (PVP)

Dependencies added: scientific

API changes (from Hackage documentation)

- LLM: AssistantTurn :: Text -> Maybe Text -> [ToolCall] -> Turn
- LLM: TextBlock :: Text -> ContentBlock
- LLM: ToolCallBlock :: ToolCall -> ContentBlock
- LLM: UserTurn :: Text -> Turn
- LLM: data ContentBlock
- LLM.Core: AssistantTurn :: Text -> Maybe Text -> [ToolCall] -> Turn
- LLM.Core: TextBlock :: Text -> ContentBlock
- LLM.Core: ToolCallBlock :: ToolCall -> ContentBlock
- LLM.Core: UserTurn :: Text -> Turn
- LLM.Core: data ContentBlock
- LLM.Core.Types: AssistantTurn :: Text -> Maybe Text -> [ToolCall] -> Turn
- LLM.Core.Types: TextBlock :: Text -> ContentBlock
- LLM.Core.Types: ToolCallBlock :: ToolCall -> ContentBlock
- LLM.Core.Types: UserTurn :: Text -> Turn
- LLM.Core.Types: data ContentBlock
- LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.ContentBlock
- LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.ContentBlock
+ LLM: AssistantMessage :: [ContentPart] -> Turn
+ LLM: CacheEphemeral :: CacheHint
+ LLM: ContentPart :: PartBody -> Maybe CacheHint -> ContentPart
+ LLM: ImageBase64 :: Text -> Text -> ImageSource
+ LLM: ImagePart :: ImageSource -> PartBody
+ LLM: ImageUrl :: Text -> ImageSource
+ LLM: ModelCapabilities :: Bool -> Bool -> Bool -> ModelCapabilities
+ LLM: ProviderOpaque :: Text -> Maybe Text -> Value -> ProviderOpaque
+ LLM: TextPart :: Text -> PartBody
+ LLM: ThinkingContent :: Maybe Text -> Maybe ProviderOpaque -> ThinkingContent
+ LLM: ThinkingPart :: ThinkingContent -> PartBody
+ LLM: ToolCallPart :: ToolCall -> PartBody
+ LLM: UnsupportedCapability :: Text -> LLMError
+ LLM: UserMessage :: [ContentPart] -> Turn
+ LLM: [$sel:capPromptCaching:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM: [$sel:capThinking:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM: [$sel:capVision:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM: [$sel:capabilities:ModelCatalogItem] :: ModelCatalogItem -> Maybe ModelCapabilities
+ LLM: [$sel:imageData:ImageUrl] :: ImageSource -> Text
+ LLM: [$sel:imageMediaType:ImageUrl] :: ImageSource -> Text
+ LLM: [$sel:mcCapabilities:ModelConfig] :: ModelConfig -> ModelCapabilities
+ LLM: [$sel:partBody:ContentPart] :: ContentPart -> PartBody
+ LLM: [$sel:partCacheHint:ContentPart] :: ContentPart -> Maybe CacheHint
+ LLM: [$sel:poModel:ProviderOpaque] :: ProviderOpaque -> Maybe Text
+ LLM: [$sel:poPayload:ProviderOpaque] :: ProviderOpaque -> Value
+ LLM: [$sel:poProvider:ProviderOpaque] :: ProviderOpaque -> Text
+ LLM: [$sel:pricePerMillionCacheRead:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM: [$sel:pricePerMillionCacheWrite:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM: [$sel:thinkingOpaque:ThinkingContent] :: ThinkingContent -> Maybe ProviderOpaque
+ LLM: [$sel:thinkingText:ThinkingContent] :: ThinkingContent -> Maybe Text
+ LLM: [$sel:usageCacheCreationTokens:Usage] :: Usage -> !Int
+ LLM: [$sel:usageCacheReadTokens:Usage] :: Usage -> !Int
+ LLM: assistantTurn :: Text -> Maybe Text -> [ToolCall] -> Turn
+ LLM: cacheEphemeral :: ContentPart -> ContentPart
+ LLM: data CacheHint
+ LLM: data ContentPart
+ LLM: data ImageSource
+ LLM: data ModelCapabilities
+ LLM: data PartBody
+ LLM: data ProviderOpaque
+ LLM: data ThinkingContent
+ LLM: defaultModelCapabilities :: ModelCapabilities
+ LLM: defaultPricingInfo :: Double -> Double -> PricingInfo
+ LLM: imageBase64Part :: Text -> Text -> Either Text ContentPart
+ LLM: imageUrlPart :: Text -> ContentPart
+ LLM: mkChatResponse :: [ContentPart] -> Maybe Usage -> ChatResponse
+ LLM: mkUsage :: Int -> Int -> Usage
+ LLM: pattern UserTurn :: Text -> Turn
+ LLM: textPart :: Text -> ContentPart
+ LLM: thinkingPart :: ThinkingContent -> ContentPart
+ LLM: toolCallPart :: ToolCall -> ContentPart
+ LLM: usageOrdinaryInputTokens :: Usage -> Int
+ LLM: withCacheHint :: CacheHint -> ContentPart -> ContentPart
+ LLM.Core: AssistantMessage :: [ContentPart] -> Turn
+ LLM.Core: CacheEphemeral :: CacheHint
+ LLM.Core: ContentPart :: PartBody -> Maybe CacheHint -> ContentPart
+ LLM.Core: ImageBase64 :: Text -> Text -> ImageSource
+ LLM.Core: ImagePart :: ImageSource -> PartBody
+ LLM.Core: ImageUrl :: Text -> ImageSource
+ LLM.Core: ProviderOpaque :: Text -> Maybe Text -> Value -> ProviderOpaque
+ LLM.Core: TextPart :: Text -> PartBody
+ LLM.Core: ThinkingContent :: Maybe Text -> Maybe ProviderOpaque -> ThinkingContent
+ LLM.Core: ThinkingPart :: ThinkingContent -> PartBody
+ LLM.Core: ToolCallPart :: ToolCall -> PartBody
+ LLM.Core: UnsupportedCapability :: Text -> LLMError
+ LLM.Core: UserMessage :: [ContentPart] -> Turn
+ LLM.Core: [$sel:imageData:ImageUrl] :: ImageSource -> Text
+ LLM.Core: [$sel:imageMediaType:ImageUrl] :: ImageSource -> Text
+ LLM.Core: [$sel:partBody:ContentPart] :: ContentPart -> PartBody
+ LLM.Core: [$sel:partCacheHint:ContentPart] :: ContentPart -> Maybe CacheHint
+ LLM.Core: [$sel:poModel:ProviderOpaque] :: ProviderOpaque -> Maybe Text
+ LLM.Core: [$sel:poPayload:ProviderOpaque] :: ProviderOpaque -> Value
+ LLM.Core: [$sel:poProvider:ProviderOpaque] :: ProviderOpaque -> Text
+ LLM.Core: [$sel:pricePerMillionCacheRead:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM.Core: [$sel:pricePerMillionCacheWrite:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM.Core: [$sel:thinkingOpaque:ThinkingContent] :: ThinkingContent -> Maybe ProviderOpaque
+ LLM.Core: [$sel:thinkingText:ThinkingContent] :: ThinkingContent -> Maybe Text
+ LLM.Core: [$sel:usageCacheCreationTokens:Usage] :: Usage -> !Int
+ LLM.Core: [$sel:usageCacheReadTokens:Usage] :: Usage -> !Int
+ LLM.Core: cacheEphemeral :: ContentPart -> ContentPart
+ LLM.Core: coalesceAdjacentTextParts :: [ContentPart] -> [ContentPart]
+ LLM.Core: conversationHasImages :: [Turn] -> Bool
+ LLM.Core: data CacheHint
+ LLM.Core: data ContentPart
+ LLM.Core: data ImageSource
+ LLM.Core: data PartBody
+ LLM.Core: data ProviderOpaque
+ LLM.Core: data ThinkingContent
+ LLM.Core: defaultPricingInfo :: Double -> Double -> PricingInfo
+ LLM.Core: imageBase64Part :: Text -> Text -> Either Text ContentPart
+ LLM.Core: imageUrlPart :: Text -> ContentPart
+ LLM.Core: mkChatResponse :: [ContentPart] -> Maybe Usage -> ChatResponse
+ LLM.Core: mkImageBase64 :: Text -> Text -> Either Text ImageSource
+ LLM.Core: mkUsage :: Int -> Int -> Usage
+ LLM.Core: pattern UserTurn :: Text -> Turn
+ LLM.Core: projectReasoning :: [ContentPart] -> Maybe Text
+ LLM.Core: projectText :: [ContentPart] -> Text
+ LLM.Core: supportedImageMediaTypes :: [Text]
+ LLM.Core: textPart :: Text -> ContentPart
+ LLM.Core: thinkingPart :: ThinkingContent -> ContentPart
+ LLM.Core: toolCallPart :: ToolCall -> ContentPart
+ LLM.Core: turnToolCalls :: [ContentPart] -> [ToolCall]
+ LLM.Core: usageOrdinaryInputTokens :: Usage -> Int
+ LLM.Core: validateTurn :: Turn -> Either Text ()
+ LLM.Core: withCacheHint :: CacheHint -> ContentPart -> ContentPart
+ LLM.Core.Types: AssistantMessage :: [ContentPart] -> Turn
+ LLM.Core.Types: CacheEphemeral :: CacheHint
+ LLM.Core.Types: ContentPart :: PartBody -> Maybe CacheHint -> ContentPart
+ LLM.Core.Types: ImageBase64 :: Text -> Text -> ImageSource
+ LLM.Core.Types: ImagePart :: ImageSource -> PartBody
+ LLM.Core.Types: ImageUrl :: Text -> ImageSource
+ LLM.Core.Types: ProviderOpaque :: Text -> Maybe Text -> Value -> ProviderOpaque
+ LLM.Core.Types: TextPart :: Text -> PartBody
+ LLM.Core.Types: ThinkingContent :: Maybe Text -> Maybe ProviderOpaque -> ThinkingContent
+ LLM.Core.Types: ThinkingPart :: ThinkingContent -> PartBody
+ LLM.Core.Types: ToolCallPart :: ToolCall -> PartBody
+ LLM.Core.Types: UnsupportedCapability :: Text -> LLMError
+ LLM.Core.Types: UserMessage :: [ContentPart] -> Turn
+ LLM.Core.Types: [$sel:imageData:ImageUrl] :: ImageSource -> Text
+ LLM.Core.Types: [$sel:imageMediaType:ImageUrl] :: ImageSource -> Text
+ LLM.Core.Types: [$sel:partBody:ContentPart] :: ContentPart -> PartBody
+ LLM.Core.Types: [$sel:partCacheHint:ContentPart] :: ContentPart -> Maybe CacheHint
+ LLM.Core.Types: [$sel:poModel:ProviderOpaque] :: ProviderOpaque -> Maybe Text
+ LLM.Core.Types: [$sel:poPayload:ProviderOpaque] :: ProviderOpaque -> Value
+ LLM.Core.Types: [$sel:poProvider:ProviderOpaque] :: ProviderOpaque -> Text
+ LLM.Core.Types: [$sel:thinkingOpaque:ThinkingContent] :: ThinkingContent -> Maybe ProviderOpaque
+ LLM.Core.Types: [$sel:thinkingText:ThinkingContent] :: ThinkingContent -> Maybe Text
+ LLM.Core.Types: cacheEphemeral :: ContentPart -> ContentPart
+ LLM.Core.Types: coalesceAdjacentTextParts :: [ContentPart] -> [ContentPart]
+ LLM.Core.Types: conversationHasImages :: [Turn] -> Bool
+ LLM.Core.Types: data CacheHint
+ LLM.Core.Types: data ContentPart
+ LLM.Core.Types: data ImageSource
+ LLM.Core.Types: data PartBody
+ LLM.Core.Types: data ProviderOpaque
+ LLM.Core.Types: data ThinkingContent
+ LLM.Core.Types: imageBase64Part :: Text -> Text -> Either Text ContentPart
+ LLM.Core.Types: imageUrlPart :: Text -> ContentPart
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.CacheHint
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.ContentPart
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.ImageSource
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.PartBody
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.ProviderOpaque
+ LLM.Core.Types: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Core.Types.ThinkingContent
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.CacheHint
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.ContentPart
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.ImageSource
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.PartBody
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.ProviderOpaque
+ LLM.Core.Types: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Core.Types.ThinkingContent
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.CacheHint
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.ContentPart
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.ImageSource
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.PartBody
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.ProviderOpaque
+ LLM.Core.Types: instance GHC.Classes.Eq LLM.Core.Types.ThinkingContent
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.CacheHint
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.ContentPart
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.ImageSource
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.PartBody
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.ProviderOpaque
+ LLM.Core.Types: instance GHC.Generics.Generic LLM.Core.Types.ThinkingContent
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.CacheHint
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.ContentPart
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.ImageSource
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.PartBody
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.ProviderOpaque
+ LLM.Core.Types: instance GHC.Show.Show LLM.Core.Types.ThinkingContent
+ LLM.Core.Types: mkChatResponse :: [ContentPart] -> Maybe Usage -> ChatResponse
+ LLM.Core.Types: mkImageBase64 :: Text -> Text -> Either Text ImageSource
+ LLM.Core.Types: opaqueForProvider :: Text -> Maybe ProviderOpaque -> Maybe ProviderOpaque
+ LLM.Core.Types: pattern UserTurn :: Text -> Turn
+ LLM.Core.Types: projectReasoning :: [ContentPart] -> Maybe Text
+ LLM.Core.Types: projectText :: [ContentPart] -> Text
+ LLM.Core.Types: stripForeignOpaque :: Text -> ContentPart -> ContentPart
+ LLM.Core.Types: supportedImageMediaTypes :: [Text]
+ LLM.Core.Types: textPart :: Text -> ContentPart
+ LLM.Core.Types: thinkingPart :: ThinkingContent -> ContentPart
+ LLM.Core.Types: toolCallPart :: ToolCall -> ContentPart
+ LLM.Core.Types: turnToolCalls :: [ContentPart] -> [ToolCall]
+ LLM.Core.Types: validateTurn :: Turn -> Either Text ()
+ LLM.Core.Types: withCacheHint :: CacheHint -> ContentPart -> ContentPart
+ LLM.Core.Usage: [$sel:pricePerMillionCacheRead:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM.Core.Usage: [$sel:pricePerMillionCacheWrite:PricingInfo] :: PricingInfo -> Maybe Double
+ LLM.Core.Usage: [$sel:usageCacheCreationTokens:Usage] :: Usage -> !Int
+ LLM.Core.Usage: [$sel:usageCacheReadTokens:Usage] :: Usage -> !Int
+ LLM.Core.Usage: defaultPricingInfo :: Double -> Double -> PricingInfo
+ LLM.Core.Usage: mkUsage :: Int -> Int -> Usage
+ LLM.Core.Usage: usageOrdinaryInputTokens :: Usage -> Int
+ LLM.Generate: ModelCapabilities :: Bool -> Bool -> Bool -> ModelCapabilities
+ LLM.Generate: [$sel:capPromptCaching:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate: [$sel:capThinking:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate: [$sel:capVision:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate: [$sel:mcCapabilities:ModelConfig] :: ModelConfig -> ModelCapabilities
+ LLM.Generate: data ModelCapabilities
+ LLM.Generate: defaultModelCapabilities :: ModelCapabilities
+ LLM.Generate.GenerateUtils: validateModelCapabilities :: ModelConfig -> [Turn] -> Either LLMError ()
+ LLM.Generate.ModelConfig: ModelCapabilities :: Bool -> Bool -> Bool -> ModelCapabilities
+ LLM.Generate.ModelConfig: [$sel:capPromptCaching:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate.ModelConfig: [$sel:capThinking:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate.ModelConfig: [$sel:capVision:ModelCapabilities] :: ModelCapabilities -> Bool
+ LLM.Generate.ModelConfig: [$sel:mcCapabilities:ModelConfig] :: ModelConfig -> ModelCapabilities
+ LLM.Generate.ModelConfig: data ModelCapabilities
+ LLM.Generate.ModelConfig: defaultModelCapabilities :: ModelCapabilities
+ LLM.Generate.ModelConfig: instance Data.Aeson.Types.FromJSON.FromJSON LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Generate.ModelConfig: instance Data.Aeson.Types.ToJSON.ToJSON LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Generate.ModelConfig: instance GHC.Classes.Eq LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Generate.ModelConfig: instance GHC.Classes.Ord LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Generate.ModelConfig: instance GHC.Generics.Generic LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Generate.ModelConfig: instance GHC.Show.Show LLM.Generate.ModelConfig.ModelCapabilities
+ LLM.Load: [$sel:capabilities:ModelCatalogItem] :: ModelCatalogItem -> Maybe ModelCapabilities
+ LLM.Load.ModelCatalog: [$sel:capabilities:ModelCatalogItem] :: ModelCatalogItem -> Maybe ModelCapabilities
+ LLM.Providers.Claude: claudeBuildBody :: Bool -> ChatRequest -> Value
+ LLM.Providers.Claude: effortToBudgetTokens :: Text -> Int
+ LLM.Providers.Claude: encodeTurn :: Text -> Turn -> [Value]
+ LLM.Providers.Claude: parseClaudeStream :: Text -> IO ByteString -> (StreamEvent -> IO ()) -> IO LLMTextResult
+ LLM.Providers.Gemini: encodeTurn :: Text -> Turn -> [Value]
+ LLM.Providers.Gemini: signatureForModel :: Text -> Maybe ProviderOpaque -> Maybe Text
- LLM: ChatResponse :: Text -> [ContentBlock] -> Maybe Usage -> Maybe Text -> ChatResponse
+ LLM: ChatResponse :: Text -> [ContentPart] -> Maybe Usage -> Maybe Text -> ChatResponse
- LLM: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe Int -> Int -> Int -> ModelCatalogItem
+ LLM: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe ModelCapabilities -> Maybe Int -> Int -> Int -> ModelCatalogItem
- LLM: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
+ LLM: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> ModelCapabilities -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
- LLM: PricingInfo :: Double -> Double -> PricingInfo
+ LLM: PricingInfo :: Double -> Double -> Maybe Double -> Maybe Double -> PricingInfo
- LLM: ToolCall :: Text -> Text -> Value -> Maybe Value -> ToolCall
+ LLM: ToolCall :: Text -> Text -> Value -> Maybe ProviderOpaque -> ToolCall
- LLM: Usage :: !Int -> !Int -> !Double -> Usage
+ LLM: Usage :: !Int -> !Int -> !Int -> !Int -> !Double -> Usage
- LLM: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentBlock]
+ LLM: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentPart]
- LLM: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe Value
+ LLM: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe ProviderOpaque
- LLM.Core: ChatResponse :: Text -> [ContentBlock] -> Maybe Usage -> Maybe Text -> ChatResponse
+ LLM.Core: ChatResponse :: Text -> [ContentPart] -> Maybe Usage -> Maybe Text -> ChatResponse
- LLM.Core: PricingInfo :: Double -> Double -> PricingInfo
+ LLM.Core: PricingInfo :: Double -> Double -> Maybe Double -> Maybe Double -> PricingInfo
- LLM.Core: ToolCall :: Text -> Text -> Value -> Maybe Value -> ToolCall
+ LLM.Core: ToolCall :: Text -> Text -> Value -> Maybe ProviderOpaque -> ToolCall
- LLM.Core: Usage :: !Int -> !Int -> !Double -> Usage
+ LLM.Core: Usage :: !Int -> !Int -> !Int -> !Int -> !Double -> Usage
- LLM.Core: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentBlock]
+ LLM.Core: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentPart]
- LLM.Core: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe Value
+ LLM.Core: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe ProviderOpaque
- LLM.Core.Types: ChatResponse :: Text -> [ContentBlock] -> Maybe Usage -> Maybe Text -> ChatResponse
+ LLM.Core.Types: ChatResponse :: Text -> [ContentPart] -> Maybe Usage -> Maybe Text -> ChatResponse
- LLM.Core.Types: ToolCall :: Text -> Text -> Value -> Maybe Value -> ToolCall
+ LLM.Core.Types: ToolCall :: Text -> Text -> Value -> Maybe ProviderOpaque -> ToolCall
- LLM.Core.Types: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentBlock]
+ LLM.Core.Types: [$sel:respContent:ChatResponse] :: ChatResponse -> [ContentPart]
- LLM.Core.Types: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe Value
+ LLM.Core.Types: [$sel:tcProviderMeta:ToolCall] :: ToolCall -> Maybe ProviderOpaque
- LLM.Core.Usage: PricingInfo :: Double -> Double -> PricingInfo
+ LLM.Core.Usage: PricingInfo :: Double -> Double -> Maybe Double -> Maybe Double -> PricingInfo
- LLM.Core.Usage: Usage :: !Int -> !Int -> !Double -> Usage
+ LLM.Core.Usage: Usage :: !Int -> !Int -> !Int -> !Int -> !Double -> Usage
- LLM.Generate: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
+ LLM.Generate: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> ModelCapabilities -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
- LLM.Generate.ModelConfig: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
+ LLM.Generate.ModelConfig: ModelConfig :: LLMGateway -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe ThinkingMode -> ModelCapabilities -> Maybe Int -> Maybe Int -> Int -> Int -> ModelConfig
- LLM.Load: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe Int -> Int -> Int -> ModelCatalogItem
+ LLM.Load: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe ModelCapabilities -> Maybe Int -> Int -> Int -> ModelCatalogItem
- LLM.Load.ModelCatalog: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe Int -> Int -> Int -> ModelCatalogItem
+ LLM.Load.ModelCatalog: ModelCatalogItem :: Text -> Text -> Text -> PricingInfo -> Int -> Maybe Double -> Maybe Int -> Maybe Text -> Maybe ModelCapabilities -> Maybe Int -> Int -> Int -> ModelCatalogItem

Files

CHANGELOG.md view
@@ -7,6 +7,75 @@  ## [Unreleased] +## [0.2.0.0] - 2026-10-06++### Changed++- **Breaking:** conversation turns use ordered content parts.+  `UserMessage` / `AssistantMessage` hold `[ContentPart]`; `UserTurn` is a+  bidirectional pattern synonym for a single text part. Replace+  `AssistantTurn text reasoning calls` with `assistantTurn` (canonical order)+  or `AssistantMessage respContent` for authoritative replay.+- **Breaking:** `ChatResponse.respContent` is now `[ContentPart]` (authoritative).+  `respText` / `respReasoning` remain convenience projections.+- **Breaking:** `ToolCall.tcProviderMeta` is `Maybe ProviderOpaque` (tagged+  provider + optional model + opaque payload), not bare `Value`.+- **Breaking:** removed `ContentBlock`; use `ContentPart` / `PartBody`.+- **Breaking:** `Turn` / `ToolCall` JSON shapes changed (role/content based).+  Persisted histories must be migrated; old and new shapes are not mixed.+- Claude adapter: thinking request mapping (`thinking.type=enabled` ++  `budget_tokens` from catalog effort), ordered thinking/text/tool_use parse+  and stream, signed-block replay around tool rounds. Temperature is omitted+  when thinking is enabled (Anthropic incompatibility).+- Gemini adapter: catalog thinking maps to `thinkingConfig`; tool-call thought+  signatures use `ProviderOpaque` and still replay only for a matching model.+- OpenAI / DeepSeek / Ollama adapters encode and parse ordered parts; DeepSeek+  reasoning continues via `reasoning_content` on ordered `ThinkingPart`s.+- Foreign opaque thinking / tool metadata is stripped at encode time on+  provider fallback (history unchanged).+- **Breaking:** `ModelConfig` gains `mcCapabilities` (`ModelCapabilities`).+  Manual record construction must set it (use `defaultModelCapabilities`).+- Example catalog: `capabilities` on vision/thinking models; removed+  `temperature` from `haiku_4_5` (Claude thinking temperature footgun).++- **Breaking:** `Usage` adds `usageCacheReadTokens` / `usageCacheCreationTokens`.+  `usageInputTokens` is total input including cache read/write tokens (providers+  normalize wire semantics so totals are not double-counted). Prefer `mkUsage`.+- **Breaking:** `PricingInfo` adds optional `pricePerMillionCacheRead` /+  `pricePerMillionCacheWrite`; absent rates fall back to the ordinary input rate.+  Prefer `defaultPricingInfo` for catalogs without cache rates.+- Cost estimation: ordinary input × input rate + cache read × cache-read rate ++  cache creation × cache-write rate + output × output rate.+- **Breaking:** `ContentPart` gains `partCacheHint :: Maybe CacheHint`. Manual+  construction and pattern matches must account for the new field; prefer+  `textPart` / `cacheEphemeral`. `UserTurn` still matches only a single+  unannotated text part.++### Added++- Image input: `ImageSource`, `ImagePart`, `imageUrlPart`, `imageBase64Part` /+  `mkImageBase64` with MIME and base64 validation. Encoded for Claude, Gemini,+  and OpenAI Chat Completions (DeepSeek/Ollama reuse the OpenAI shape when+  `capabilities.vision` is declared).+- Catalog `capabilities` object (`thinking`, `vision`, `promptCaching`; missing+  flags default to `false`). Carried on `ModelCatalogItem` / `ModelConfig`.+- Fallback candidates are validated before I/O: images require `vision`;+  enabled thinking requires `thinking`. Unsupported capability yields+  `UnsupportedCapability` and continues the fallback chain.+- `ProviderOpaque`, `ThinkingContent`, `ContentPart`, `PartBody`, `textPart`,+  `thinkingPart`, `toolCallPart`, `mkChatResponse`, `projectText`,+  `projectReasoning`, `turnToolCalls`, `validateTurn`, `stripForeignOpaque`.+- Claude thinking fixture and unit tests for ordered replay / foreign opaque+  omission; Gemini signature matching tests.+- Image request-shape tests (Claude/Gemini/OpenAI) and vision fallback tests.+- `mkUsage`, `defaultPricingInfo`, `usageOrdinaryInputTokens`; provider parsers+  report cache read/creation counters (Claude, OpenAI, DeepSeek, Gemini).+- Optional Claude prompt-cache breakpoints: `CacheHint` / `CacheEphemeral` on+  `ContentPart.partCacheHint`, helpers `withCacheHint` / `cacheEphemeral`.+  Claude emits wire `cache_control: {type: ephemeral}` (default TTL) on marked+  parts without reordering; other providers ignore hints and leave content+  unchanged. Hints round-trip in conversation JSON and survive agent tool rounds.+ ## [0.1.1.0] - 2026-08-23  ### Changed@@ -89,3 +158,4 @@ [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+[0.2.0.0]: https://github.com/aische/llm-simple/compare/v0.1.1.0...v0.2.0.0
Readme.md view
@@ -4,7 +4,7 @@  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.+**Status:** early 0.2.x release. APIs may change.  ## Features @@ -61,7 +61,7 @@ | `modelConfigName` | Name used in code to look up this config                                 | | `providerName`    | Provider key defined in `providers.json`                                   | | `modelName`       | Provider-specific model identifier                                       |-| `pricing`         | `pricePerMillionInput` / `pricePerMillionOutput` for usage cost tracking |+| `pricing`         | `pricePerMillionInput` / `pricePerMillionOutput` for usage cost tracking; optional `pricePerMillionCacheRead` / `pricePerMillionCacheWrite` (fall back to input rate) | | `maxTokens`       | Max tokens per request                                                   | | `temperature`     | Sampling temperature (optional)                                          | | `requestTimeout`  | Request timeout in ms (optional)                                         |@@ -69,9 +69,50 @@ | `retryCount`      | Number of retries on failure                                             | | `jitterBackoff`   | Backoff jitter in ms between retries                                     | | `thinking`        | Extended thinking effort level (optional, provider-dependent)            |+| `capabilities`    | Optional object: `thinking`, `vision`, `promptCaching` (default `false`) |  A provider is only available if its API key is set (except Ollama, which is always available). +Capability flags are declarations used before each fallback candidate is+called: image parts require `vision`; enabled thinking requires `thinking`.+Unsupported candidates fail with `UnsupportedCapability` and the next+fallback is tried. Cache hints may be ignored by providers that do not+support prompt caching (they do not change prompt semantics).++### Image input++User messages may mix text and images:++```haskell+import LLM (imageUrlPart, imageBase64Part, textPart, UserMessage)++let Right png = imageBase64Part "image/png" "<base64>"+    msg = UserMessage [imageUrlPart "https://example.com/photo.jpg", png, textPart "describe"]+```++`imageBase64Part` validates MIME type (`image/jpeg|png|gif|webp|heic|heif`)+and base64 payload locally. Use a model with `"capabilities": {"vision": true}`.++### Prompt cache breakpoints++Mark stable content with `cacheEphemeral` (or `withCacheHint CacheEphemeral`).+Claude serializes that as `cache_control: {"type":"ephemeral"}` on the content+block (provider default TTL). Other providers omit the marker and send the same+content. Hints persist in conversation JSON and through agent tool rounds.++```haskell+import LLM (cacheEphemeral, textPart, UserMessage)++let msg =+      UserMessage+        [ cacheEphemeral (textPart "large stable context…"),+          textPart "question about the context"+        ]+```++`UserTurn "text"` still matches only a single unannotated text part; use+`UserMessage` when parts carry cache hints.+ ### Provider catalog  Providers are defined in `providers.json` (bundled with the package as a@@ -127,7 +168,7 @@ import Control.Exception (SomeException, catch) import Heptapod (generate) import LLM.Agent (Agent (..), RuntimeArgs (..), generateText, noEventObserver)-import LLM.Core (Turn (UserTurn))+import LLM.Core (pattern UserTurn) import LLM.Generate (ModelWithFallbacks (..), Hooks (..), llmHooks, noHooks) import LLM.Load (fsTools, loadModelsOrThrow) 
app/Main.hs view
@@ -12,7 +12,7 @@     noEventObserver,   ) import LLM.Core-  ( Turn (UserTurn),+  ( pattern UserTurn,   ) import LLM.Generate   ( ModelWithFallbacks (..),
llm-simple.cabal view
@@ -2,7 +2,7 @@  name:               llm-simple -version:            0.1.1.0+version:            0.2.0.0  synopsis:     Multi-provider LLM library with agent tool loops and filesystem tools@@ -16,7 +16,7 @@     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.+    Status: early 0.2.x release — APIs may change.  license:            BSD-3-Clause @@ -65,6 +65,7 @@         OverloadedRecordDot         DuplicateRecordFields         NoFieldSelectors+        PatternSynonyms         StrictData         GADTs         TypeFamilies@@ -245,5 +246,7 @@         heptapod       >=1.1   && <1.2,         llm-simple,         mtl            >=2.3   && <2.4,-        retry          >=0.9   && <0.10+        retry          >=0.9   && <0.10,+        scientific     >=0.3   && <0.4,+        vector         >=0.13  && <0.14     build-tool-depends: hspec-discover:hspec-discover
model-catalog.json view
@@ -12,7 +12,10 @@         "requestTimeout": 10000,         "throttleDelay": 1000,         "retryCount": 3,-        "jitterBackoff": 1000+        "jitterBackoff": 1000,+        "capabilities": {+            "vision": true+        }     },     {         "modelConfigName": "gpt_5_6_terra",@@ -27,7 +30,10 @@         "requestTimeout": 10000,         "throttleDelay": 1000,         "retryCount": 3,-        "jitterBackoff": 1000+        "jitterBackoff": 1000,+        "capabilities": {+            "vision": true+        }     },     {         "modelConfigName": "llama_3_2",@@ -72,7 +78,11 @@         "requestTimeout": 10000,         "throttleDelay": 1000,         "retryCount": 3,-        "jitterBackoff": 1000+        "jitterBackoff": 1000,+        "capabilities": {+            "thinking": true,+            "vision": true+        }     },     {         "modelConfigName": "haiku_4_5",@@ -83,11 +93,15 @@             "pricePerMillionOutput": 5         },         "maxTokens": 4096,-        "temperature": 0.5,         "requestTimeout": 30000,         "throttleDelay": 5000,         "retryCount": 3,-        "jitterBackoff": 5000+        "jitterBackoff": 5000,+        "capabilities": {+            "thinking": true,+            "vision": true,+            "promptCaching": true+        }     },     {         "modelConfigName": "deepseek4flash",@@ -102,6 +116,9 @@         "requestTimeout": 20000,         "throttleDelay": 3000,         "retryCount": 3,-        "jitterBackoff": 1000+        "jitterBackoff": 1000,+        "capabilities": {+            "thinking": true+        }     } ]
src/LLM.hs view
@@ -28,7 +28,7 @@ --                       agContextWindow = Nothing } --   rt <- mkRuntime  -- your RuntimeArgs --   result <- generateText agent (ModelWithFallbacks model []) tools rt---                           [UserTurn \"hello\"]+--                           [UserTurn \"hello\"]  -- pattern synonym --   ... -- @ --@@ -51,19 +51,37 @@ module LLM   ( -- * Core conversation types     Turn (..),+    pattern UserTurn,+    assistantTurn,+    ContentPart (..),+    PartBody (..),+    CacheHint (..),+    ImageSource (..),+    ThinkingContent (..),+    ProviderOpaque (..),+    textPart,+    thinkingPart,+    toolCallPart,+    imageUrlPart,+    imageBase64Part,+    withCacheHint,+    cacheEphemeral,     ToolCall (..),     ToolResult (..),     ToolDef (..),     TypedTool (..),     ChatResponse (..),-    ContentBlock (..),+    mkChatResponse,     LLMGateway (..),     LLMError (..),     Usage (..),     PricingInfo (..),     emptyUsage,+    mkUsage,     addUsage,     estimateCost,+    usageOrdinaryInputTokens,+    defaultPricingInfo,     ThinkingMode (..),     LLMHooks (..),     AbortSignal,@@ -74,6 +92,8 @@     streamTextWithFallbacks,     genObject,     genObjectUntyped,+    ModelCapabilities (..),+    defaultModelCapabilities,     ModelConfig (..),     ModelWithFallbacks (..),     GenRequest (..),@@ -135,11 +155,16 @@ import LLM.Core   ( AbortSignal,     ChatResponse (..),-    ContentBlock (..),+    CacheHint (..),+    ContentPart (..),+    ImageSource (..),     LLMError (..),     LLMGateway (..),     LLMHooks (..),+    PartBody (..),     PricingInfo (..),+    ProviderOpaque (..),+    ThinkingContent (..),     ThinkingMode (..),     ToolCall (..),     ToolDef (..),@@ -148,9 +173,22 @@     TypedTool (..),     Usage (..),     addUsage,+    assistantTurn,+    cacheEphemeral,+    defaultPricingInfo,     emptyUsage,     estimateCost,     getToolCalls,+    imageBase64Part,+    imageUrlPart,+    mkChatResponse,+    mkUsage,+    pattern UserTurn,+    textPart,+    thinkingPart,+    toolCallPart,+    usageOrdinaryInputTokens,+    withCacheHint,   ) import LLM.Generate   ( GenRequest (..),@@ -160,12 +198,14 @@     GenerateTextResult (..),     GeneratableObject,     Hooks (..),+    ModelCapabilities (..),     ModelConfig (..),     ModelWithFallbacks (..),     StreamChunk (..),     RoundTextRole (..),     debugHooks,     defaultDebugHooks,+    defaultModelCapabilities,     genObject,     genObjectUntyped,     generateTextWithFallbacks,
src/LLM/Agent/Generate.hs view
@@ -124,32 +124,32 @@                       toolCalls = getToolCalls resp                       roundUsage = fromMaybe emptyUsage resp.respUsage                       newUsage = currentUsage <> roundUsage+                      -- Authoritative ordered parts from the provider response.+                      assistantMsg = AssistantMessage resp.respContent                    case toolCalls of                     [] -> do-                      let finalTurn = AssistantTurn txt resp.respReasoning []-                          finalTurnsAcc = newTurnsAcc ++ [finalTurn]+                      let finalTurnsAcc = newTurnsAcc ++ [assistantMsg]                           successResult = GenerateTextResult rt.rtGenerationId finalTurnsAcc txt newUsage-                      emitEvent rt (MessageFinalized finalTurn)+                      emitEvent rt (MessageFinalized assistantMsg)                       emitEvent rt (GenerationFinished successResult)                       pure $ Right successResult                     _ -> do-                      let assistantTurn = AssistantTurn txt resp.respReasoning toolCalls-                          toolContext = createToolContext agent currentTurns newUsage rt+                      let toolContext = createToolContext agent currentTurns newUsage rt                           tools = getResolvedTools id agent toolMap rt-                      emitEvent rt (MessageCreated assistantTurn)+                      emitEvent rt (MessageCreated assistantMsg)                       emitEvent rt (ToolRoundStarted loopCount)                        toolResultsE <- executeToolsWithAbort rt.rtAbortSignal rt.rtHooks toolContext tools toolCalls                        case toolResultsE of                         Left err -> do-                          let errResult = GenerateErrorResult err (newTurnsAcc ++ [assistantTurn]) newUsage+                          let errResult = GenerateErrorResult err (newTurnsAcc ++ [assistantMsg]) newUsage                           emitEvent rt (GenerationFailed err errResult)                           pure $ Left errResult                         Right toolResults -> do                           let toolTurn = ToolTurn toolResults                           emitEvent rt (MessageCreated toolTurn)                           emitEvent rt (ToolRoundFinished loopCount)-                          let turnsToAdd = [assistantTurn, toolTurn]+                          let turnsToAdd = [assistantMsg, toolTurn]                           go (currentTurns ++ turnsToAdd) (newTurnsAcc ++ turnsToAdd) newUsage (loopCount + 1)
src/LLM/Agent/ToolUtils.hs view
@@ -151,7 +151,7 @@       | idx < 0 = 0       | remaining <= 0 = idx + 1       | otherwise = case conv !! idx of-          UserTurn _ -> go (idx - 1) (remaining - 1)+          UserMessage _ -> go (idx - 1) (remaining - 1)           _ -> go (idx - 1) remaining  -- | Build a 'GenRequest' from agent configuration and runtime state.
src/LLM/Agent/Tools/HistoryTool.hs view
@@ -6,7 +6,15 @@ import Data.Text qualified as T import GHC.Generics (Generic) import LLM.Agent.Types (ToolContext (..))-import LLM.Core.Types (ToolCall (tcName), ToolResult (trContent, trName), Turn (..), TypedTool (..))+import LLM.Core.Types+  ( ToolCall (tcName),+    ToolResult (trContent, trName),+    Turn (..),+    TypedTool (..),+    projectReasoning,+    projectText,+    turnToolCalls,+  )  newtype HistoryToolArgs = HistoryToolArgs   { _historyChunk :: Int@@ -53,7 +61,7 @@ countUserTurns = length . filter isUserTurn  isUserTurn :: Turn -> Bool-isUserTurn (UserTurn _) = True+isUserTurn (UserMessage _) = True isUserTurn _ = False  -- | Split a conversation into pages of @n@ user messages each, working@@ -88,7 +96,7 @@       | idx < 0 = 0       | remaining <= 0 = idx + 1       | otherwise = case conv !! idx of-          UserTurn _ -> go (idx - 1) (remaining - 1)+          UserMessage _ -> go (idx - 1) (remaining - 1)           _ -> go (idx - 1) remaining  -- | Extract a slice [start, end) from a list.@@ -100,14 +108,18 @@ formatChunk = T.intercalate "\n" . map formatTurn  formatTurn :: Turn -> Text-formatTurn (UserTurn t) = "[User] " <> t-formatTurn (AssistantTurn t mReasoning calls) =-  "[Assistant] "-    <> t-    <> maybe "" (\r -> " [reasoning: " <> T.take 200 r <> "]") mReasoning-    <> if null calls-      then ""-      else " [called: " <> T.intercalate ", " (map (\x -> x.tcName) calls) <> "]"+formatTurn (UserMessage parts) =+  "[User] " <> projectText parts+formatTurn (AssistantMessage parts) =+  let t = projectText parts+      mReasoning = projectReasoning parts+      calls = turnToolCalls parts+   in "[Assistant] "+        <> t+        <> maybe "" (\r -> " [reasoning: " <> T.take 200 r <> "]") mReasoning+        <> if null calls+          then ""+          else " [called: " <> T.intercalate ", " (map (\x -> x.tcName) calls) <> "]" formatTurn (ToolTurn results) =   "[Tool results] "     <> T.intercalate ", " [r.trName <> ": " <> T.take 200 r.trContent | r <- results]
src/LLM/Core.hs view
@@ -3,17 +3,39 @@   ( LLMGateway (..),     ChatRequest (..),     ChatResponse (..),+    mkChatResponse,     LLMTextResult,     LLMObjectResult,     LLMResult,     LLMError (..),     Turn (..),+    pattern UserTurn,+    ContentPart (..),+    PartBody (..),+    CacheHint (..),+    ImageSource (..),+    ThinkingContent (..),+    ProviderOpaque (..),+    textPart,+    thinkingPart,+    toolCallPart,+    imageUrlPart,+    imageBase64Part,+    withCacheHint,+    cacheEphemeral,+    mkImageBase64,+    supportedImageMediaTypes,+    projectText,+    projectReasoning,+    turnToolCalls,+    conversationHasImages,+    coalesceAdjacentTextParts,+    validateTurn,     ToolCall (..),     ToolResult (..),     LLMHooks (..),     TypedTool (..),     ToolDef (..),-    ContentBlock (..),     StreamEvent (..),     ThinkingMode (..),     MessageEncodeOptions (..),@@ -23,8 +45,11 @@     Usage (..),     PricingInfo (..),     emptyUsage,+    mkUsage,     addUsage,     estimateCost,+    usageOrdinaryInputTokens,+    defaultPricingInfo,     AbortSignal,     newAbortSignal,     abort,
src/LLM/Core/Types.hs view
@@ -1,21 +1,54 @@+{-# LANGUAGE PatternSynonyms #-}+ module LLM.Core.Types-  ( Turn (..),+  ( -- * Conversation turns+    Turn (..),+    pattern UserTurn,     assistantTurn,-    ContentBlock (..),+    ContentPart (..),+    PartBody (..),+    CacheHint (..),+    ImageSource (..),+    ThinkingContent (..),+    ProviderOpaque (..),+    textPart,+    thinkingPart,+    toolCallPart,+    imageUrlPart,+    imageBase64Part,+    withCacheHint,+    cacheEphemeral,+    mkImageBase64,+    supportedImageMediaTypes,+    projectText,+    projectReasoning,+    turnToolCalls,+    conversationHasImages,+    coalesceAdjacentTextParts,+    validateTurn,+    opaqueForProvider,+    stripForeignOpaque,++    -- * Chat     ChatRequest (..),     ChatResponse (..),+    mkChatResponse,     LLMError (..),     LLMTextResult,     LLMObjectResult,     LLMResult,++    -- * Tools     ToolDef (..),     ToolCall (..),     mkToolCall,-    LLMGateway (..),     ToolResult (..),-    StreamEvent (..),     TypedTool (..),++    -- * Provider surface+    LLMGateway (..),     LLMHooks (..),+    StreamEvent (..),     ThinkingMode (..),     MessageEncodeOptions (..),     defaultMessageEncodeOptions,@@ -23,8 +56,22 @@   ) where -import Data.Aeson (FromJSON, ToJSON, Value)+import Data.Aeson+  ( FromJSON (..),+    ToJSON (..),+    Value (..),+    object,+    withObject,+    withText,+    (.:),+    (.:?),+    (.=),+  )+import Data.Aeson.Types (Parser)+import Data.Char (isSpace, isAsciiUpper, isAsciiLower, isDigit)+import Data.Maybe (mapMaybe) import Data.Text (Text)+import Data.Text qualified as T import GHC.Generics (Generic) import LLM.Core.Usage (Usage) @@ -60,10 +107,10 @@     onLLMResponseError :: Text -> Text -> IO ()   } --- | DeepSeek thinking mode configuration.+-- | Thinking / reasoning mode configuration shared across providers. data ThinkingMode = ThinkingMode   { tmEnabled :: Bool,-    tmEffort :: Maybe Text -- e.g. @high@ or @max@+    tmEffort :: Maybe Text -- e.g. @high@, @max@, or a provider-specific token budget   }   deriving (Show, Eq) @@ -79,16 +126,362 @@ deepSeekMessageEncodeOptions :: MessageEncodeOptions deepSeekMessageEncodeOptions = MessageEncodeOptions {meoIncludeReasoning = True} --- | A single turn in a conversation+-- | Opaque, provider-owned payload that must round-trip for replay.+--+-- Used for Claude thinking signatures, Gemini thought signatures, and any+-- similar provider-bound state. Consumers must treat 'poPayload' as opaque.+data ProviderOpaque = ProviderOpaque+  { poProvider :: Text,+    poModel :: Maybe Text,+    poPayload :: Value+  }+  deriving (Show, Eq, Generic)++instance ToJSON ProviderOpaque where+  toJSON po =+    object $+      [ "provider" .= po.poProvider,+        "payload" .= po.poPayload+      ]+        ++ ["model" .= m | Just m <- [po.poModel]]++instance FromJSON ProviderOpaque where+  parseJSON = withObject "ProviderOpaque" $ \o ->+    ProviderOpaque+      <$> o .: "provider"+      <*> o .:? "model"+      <*> o .: "payload"++-- | Displayable and/or opaque thinking content for one assistant part.+data ThinkingContent = ThinkingContent+  { thinkingText :: Maybe Text,+    thinkingOpaque :: Maybe ProviderOpaque+  }+  deriving (Show, Eq, Generic, FromJSON, ToJSON)++-- | Image input by HTTPS URL or base64 payload.+data ImageSource+  = ImageUrl Text+  | ImageBase64 {imageMediaType :: Text, imageData :: Text}+  deriving (Show, Eq, Generic)++instance ToJSON ImageSource where+  toJSON (ImageUrl url) =+    object ["type" .= ("url" :: Text), "url" .= url]+  toJSON (ImageBase64 mediaType data_) =+    object+      [ "type" .= ("base64" :: Text),+        "media_type" .= mediaType,+        "data" .= data_+      ]++instance FromJSON ImageSource where+  parseJSON = withObject "ImageSource" $ \o -> do+    typ <- o .: "type" :: Parser Text+    case typ of+      "url" -> ImageUrl <$> o .: "url"+      "base64" ->+        ImageBase64+          <$> o .: "media_type"+          <*> o .: "data"+      _ -> fail $ "Unknown image source type: " <> T.unpack typ++-- | MIME types accepted by 'mkImageBase64' / 'imageBase64Part'.+supportedImageMediaTypes :: [Text]+supportedImageMediaTypes =+  [ "image/jpeg",+    "image/png",+    "image/gif",+    "image/webp",+    "image/heic",+    "image/heif"+  ]++-- | Validate MIME type and base64 payload for an inline image.+mkImageBase64 :: Text -> Text -> Either Text ImageSource+mkImageBase64 mediaType rawData+  | mediaType `notElem` supportedImageMediaTypes =+      Left $+        "unsupported image media type: "+          <> mediaType+          <> "; expected one of: "+          <> T.intercalate ", " supportedImageMediaTypes+  | T.null cleaned =+      Left "image base64 data must not be empty"+  | not (isBase64Text cleaned) =+      Left "image data is not valid base64"+  | otherwise =+      Right $ ImageBase64 mediaType cleaned+  where+    cleaned = T.filter (not . isSpace) rawData++isBase64Text :: Text -> Bool+isBase64Text t =+  let n = T.length t+   in n > 0+        && n `mod` 4 == 0+        && T.all isBase64Char t+  where+    isBase64Char c =+      isAsciiUpper c+        || isAsciiLower c+        || isDigit c+        || c == '+'+        || c == '/'+        || c == '='++-- | Body of one ordered content part.+data PartBody+  = TextPart Text+  | ImagePart ImageSource+  | ThinkingPart ThinkingContent+  | ToolCallPart ToolCall+  deriving (Show, Eq, Generic)++instance ToJSON PartBody where+  toJSON (TextPart t) = object ["type" .= ("text" :: Text), "text" .= t]+  toJSON (ImagePart src) = object ["type" .= ("image" :: Text), "image" .= src]+  toJSON (ThinkingPart tc) =+    object $+      ["type" .= ("thinking" :: Text)]+        ++ ["text" .= t | Just t <- [tc.thinkingText]]+        ++ ["opaque" .= o | Just o <- [tc.thinkingOpaque]]+  toJSON (ToolCallPart tc) =+    object+      [ "type" .= ("tool_call" :: Text),+        "tool_call" .= tc+      ]++instance FromJSON PartBody where+  parseJSON = withObject "PartBody" $ \o -> do+    typ <- o .: "type" :: Parser Text+    case typ of+      "text" -> TextPart <$> o .: "text"+      "image" -> ImagePart <$> o .: "image"+      "thinking" -> do+        mText <- o .:? "text"+        mOpaque <- o .:? "opaque"+        pure $ ThinkingPart (ThinkingContent mText mOpaque)+      "tool_call" -> ToolCallPart <$> o .: "tool_call"+      _ -> fail $ "Unknown part type: " <> T.unpack typ++-- | Portable prompt-cache breakpoint intent.+--+-- Unsupported providers ignore the hint without dropping the underlying+-- content. Claude serializes 'CacheEphemeral' as wire @cache_control@.+data CacheHint = CacheEphemeral+  deriving (Show, Eq, Generic)++instance ToJSON CacheHint where+  toJSON CacheEphemeral = String "ephemeral"++instance FromJSON CacheHint where+  parseJSON = withText "CacheHint" $ \t ->+    case t of+      "ephemeral" -> pure CacheEphemeral+      _ -> fail $ "Unknown cache hint: " <> T.unpack t++-- | One ordered content part in a user or assistant message.+data ContentPart = ContentPart+  { partBody :: PartBody,+    -- | Optional cache breakpoint. Portable intent; see 'CacheHint'.+    partCacheHint :: Maybe CacheHint+  }+  deriving (Show, Eq, Generic)++instance ToJSON ContentPart where+  toJSON (ContentPart body mHint) =+    object $+      ("partBody" .= body) : ["partCacheHint" .= h | Just h <- [mHint]]++instance FromJSON ContentPart where+  parseJSON = withObject "ContentPart" $ \o ->+    ContentPart+      <$> o .: "partBody"+      <*> o .:? "partCacheHint"++-- | Content part with no cache hint.+mkPart :: PartBody -> ContentPart+mkPart body = ContentPart body Nothing++textPart :: Text -> ContentPart+textPart t = mkPart (TextPart t)++thinkingPart :: ThinkingContent -> ContentPart+thinkingPart tc = mkPart (ThinkingPart tc)++toolCallPart :: ToolCall -> ContentPart+toolCallPart tc = mkPart (ToolCallPart tc)++-- | User image part from a publicly reachable URL.+imageUrlPart :: Text -> ContentPart+imageUrlPart url = mkPart (ImagePart (ImageUrl url))++-- | User image part from base64 data; validates MIME type and payload.+imageBase64Part :: Text -> Text -> Either Text ContentPart+imageBase64Part mediaType data_ =+  mkPart . ImagePart <$> mkImageBase64 mediaType data_++-- | Attach a cache hint to a content part.+withCacheHint :: CacheHint -> ContentPart -> ContentPart+withCacheHint hint cp = cp {partCacheHint = Just hint}++-- | Mark a content part with Claude's default ephemeral cache breakpoint.+cacheEphemeral :: ContentPart -> ContentPart+cacheEphemeral = withCacheHint CacheEphemeral++-- | A single turn in a conversation.+--+-- Prefer 'UserTurn' / 'assistantTurn' for simple text construction. Match+-- 'UserMessage' / 'AssistantMessage' when inspecting arbitrary ordered parts. data Turn-  = UserTurn Text-  | AssistantTurn Text (Maybe Text) [ToolCall] -- content, reasoning_content, tool calls+  = UserMessage [ContentPart]+  | AssistantMessage [ContentPart]   | ToolTurn [ToolResult]-  deriving (Show, Eq, Generic, FromJSON, ToJSON)+  deriving (Show, Eq, Generic) +instance ToJSON Turn where+  toJSON (UserMessage parts) =+    object ["role" .= ("user" :: Text), "content" .= parts]+  toJSON (AssistantMessage parts) =+    object ["role" .= ("assistant" :: Text), "content" .= parts]+  toJSON (ToolTurn results) =+    object ["role" .= ("tool" :: Text), "results" .= results]++instance FromJSON Turn where+  parseJSON = withObject "Turn" $ \o -> do+    role <- o .: "role" :: Parser Text+    case role of+      "user" -> UserMessage <$> o .: "content"+      "assistant" -> AssistantMessage <$> o .: "content"+      "tool" -> ToolTurn <$> o .: "results"+      _ -> fail $ "Unknown turn role: " <> T.unpack role++-- | Bidirectional pattern for a single unannotated user text part.+--+-- Matches only when there is no cache hint. Use 'UserMessage' for annotated+-- or multi-part content.+pattern UserTurn :: Text -> Turn+pattern UserTurn text = UserMessage [ContentPart (TextPart text) Nothing]++{-# COMPLETE UserMessage, AssistantMessage, ToolTurn #-}++-- | Migration helper: emit thinking (if any), then text, then tool calls.+--+-- Does not recover arbitrary provider block order; use 'AssistantMessage'+-- with 'respContent' when replaying authoritative ordered parts. assistantTurn :: Text -> Maybe Text -> [ToolCall] -> Turn-assistantTurn = AssistantTurn+assistantTurn text mReasoning calls =+  AssistantMessage $+    [ thinkingPart (ThinkingContent (Just r) Nothing)+      | Just r <- [mReasoning],+        not (T.null r)+    ]+      ++ [textPart text | not (T.null text)]+      ++ map toolCallPart calls +-- | Concatenate text parts in order.+projectText :: [ContentPart] -> Text+projectText = T.concat . mapMaybe go+  where+    go (ContentPart (TextPart t) _) = Just t+    go _ = Nothing++-- | First non-empty thinking text, if any.+projectReasoning :: [ContentPart] -> Maybe Text+projectReasoning = go+  where+    go [] = Nothing+    go (ContentPart (ThinkingPart tc) _ : rest) =+      case tc.thinkingText of+        Just t | not (T.null t) -> Just t+        _ -> go rest+    go (_ : rest) = go rest++-- | Tool calls in part order.+turnToolCalls :: [ContentPart] -> [ToolCall]+turnToolCalls = mapMaybe go+  where+    go (ContentPart (ToolCallPart tc) _) = Just tc+    go _ = Nothing++-- | Merge runs of adjacent text parts (e.g. streamed token deltas).+--+-- Only coalesces when both parts share the same cache hint, so breakpoints+-- are not lost.+coalesceAdjacentTextParts :: [ContentPart] -> [ContentPart]+coalesceAdjacentTextParts = go+  where+    go [] = []+    go (ContentPart (TextPart t1) h1 : ContentPart (TextPart t2) h2 : rest)+      | h1 == h2 =+          go (ContentPart (TextPart (t1 <> t2)) h1 : rest)+    go (p : rest) = p : go rest++-- | Whether any turn in the conversation contains an image part.+conversationHasImages :: [Turn] -> Bool+conversationHasImages = any turnHasImage+  where+    turnHasImage (UserMessage parts) = any isImagePart parts+    turnHasImage (AssistantMessage parts) = any isImagePart parts+    turnHasImage (ToolTurn _) = False+    isImagePart (ContentPart (ImagePart _) _) = True+    isImagePart _ = False++-- | Validate role/part combinations for a turn.+--+-- User messages may contain text and image parts; assistant messages may+-- contain text, thinking, and tool-call parts. Returns 'Left' with an error+-- message when the combination is invalid. Cache hints do not affect validity.+validateTurn :: Turn -> Either Text ()+validateTurn (UserMessage parts) =+  mapM_ userPart parts+  where+    userPart (ContentPart (TextPart _) _) = Right ()+    userPart (ContentPart (ImagePart _) _) = Right ()+    userPart (ContentPart (ThinkingPart _) _) =+      Left "user messages may not contain thinking parts"+    userPart (ContentPart (ToolCallPart _) _) =+      Left "user messages may not contain tool-call parts"+validateTurn (AssistantMessage parts) =+  mapM_ assistantPart parts+  where+    assistantPart (ContentPart (TextPart _) _) = Right ()+    assistantPart (ContentPart (ThinkingPart _) _) = Right ()+    assistantPart (ContentPart (ToolCallPart _) _) = Right ()+    assistantPart (ContentPart (ImagePart _) _) =+      Left "assistant messages may not contain image parts"+validateTurn (ToolTurn _) = Right ()++-- | Keep opaque metadata only when it belongs to @provider@.+opaqueForProvider :: Text -> Maybe ProviderOpaque -> Maybe ProviderOpaque+opaqueForProvider provider (Just o)+  | o.poProvider == provider = Just o+opaqueForProvider _ _ = Nothing++-- | Drop foreign opaque state from thinking parts and tool-call metadata.+--+-- Used by provider encoders during fallback so stored history is not mutated.+-- Cache hints are preserved.+stripForeignOpaque :: Text -> ContentPart -> ContentPart+stripForeignOpaque provider (ContentPart (ThinkingPart tc) hint) =+  ContentPart+    ( ThinkingPart+        tc+          { thinkingOpaque = opaqueForProvider provider tc.thinkingOpaque+          }+    )+    hint+stripForeignOpaque provider (ContentPart (ToolCallPart tc) hint) =+  ContentPart+    ( ToolCallPart+        tc+          { tcProviderMeta = opaqueForProvider provider tc.tcProviderMeta+          }+    )+    hint+stripForeignOpaque _ p = p+ -- | A tool definition sent to the model data ToolDef = ToolDef   { toolName :: Text,@@ -113,21 +506,34 @@  -- | A tool invocation returned by the model. ----- 'tcProviderMeta' is an opaque, provider-owned JSON bag that must round-trip--- back to the provider on subsequent requests when this tool call is replayed--- as part of the conversation history. It exists because some providers--- (currently Gemini 2.5 thinking models, with their @thoughtSignature@) bind--- internal state to a specific tool-call part and reject the request if it--- isn't echoed back verbatim. Each provider is responsible for the shape of--- its own metadata; consumers should treat it as opaque.+-- 'tcProviderMeta' is opaque, provider-owned state that must round-trip back+-- to the same provider (and usually the same model) when this tool call is+-- replayed. Gemini thought signatures are the current example. data ToolCall = ToolCall   { tcId :: Text, -- provider-specific call id     tcName :: Text,     tcArguments :: Value,-    tcProviderMeta :: Maybe Value+    tcProviderMeta :: Maybe ProviderOpaque   }-  deriving (Show, Eq, Generic, FromJSON, ToJSON)+  deriving (Show, Eq, Generic) +instance ToJSON ToolCall where+  toJSON tc =+    object $+      [ "id" .= tc.tcId,+        "name" .= tc.tcName,+        "arguments" .= tc.tcArguments+      ]+        ++ ["provider_meta" .= m | Just m <- [tc.tcProviderMeta]]++instance FromJSON ToolCall where+  parseJSON = withObject "ToolCall" $ \o ->+    ToolCall+      <$> o .: "id"+      <*> o .: "name"+      <*> o .: "arguments"+      <*> o .:? "provider_meta"+ -- | Smart constructor for a 'ToolCall' with no provider metadata. Use this -- everywhere except when a provider parser is attaching its own metadata. mkToolCall :: Text -> Text -> Value -> ToolCall@@ -150,6 +556,8 @@   | EmptyResponse -- valid JSON, but no content in it   | ToolLoopExceeded Int -- hit the max tool rounds limit   | Aborted -- user cancelled the request+  | -- | Model lacks a required catalog capability (e.g. vision) for this request.+    UnsupportedCapability Text   deriving (Show, Eq, Generic, ToJSON, FromJSON)  -- | A request to an LLM provider@@ -164,20 +572,30 @@   }   deriving (Show, Eq) --- | A content block in a response — either text or a tool call-data ContentBlock-  = TextBlock Text-  | ToolCallBlock ToolCall-  deriving (Show, Eq)---- | A response from an LLM provider+-- | A response from an LLM provider.+--+-- 'respContent' is authoritative ordered content. 'respText' and+-- 'respReasoning' are convenience projections and may be lossy. data ChatResponse = ChatResponse   { respText :: Text,-    respContent :: [ContentBlock],+    respContent :: [ContentPart],     respUsage :: Maybe Usage,     respReasoning :: Maybe Text   }   deriving (Show, Eq)++-- | Build a 'ChatResponse' with text/reasoning projections derived from parts.+--+-- Adjacent text parts are coalesced so streamed deltas become one part.+mkChatResponse :: [ContentPart] -> Maybe Usage -> ChatResponse+mkChatResponse parts usage =+  let coalesced = coalesceAdjacentTextParts parts+   in ChatResponse+        { respText = projectText coalesced,+          respContent = coalesced,+          respUsage = usage,+          respReasoning = projectReasoning coalesced+        }  -- | Events emitted during streaming data StreamEvent
src/LLM/Core/Usage.hs view
@@ -2,22 +2,58 @@   ( Usage (..),     PricingInfo (..),     emptyUsage,+    mkUsage,     addUsage,     estimateCost,+    usageOrdinaryInputTokens,+    defaultPricingInfo,   ) where -import Data.Aeson (FromJSON, ToJSON)+import Data.Aeson (FromJSON (..), ToJSON (..), Value, object, withObject, (.!=), (.:), (.:?), (.=))+import Data.Aeson.Types (Object, Parser)+import Data.Maybe (fromMaybe) import GHC.Generics (Generic) --- | Token usage from a single API call+-- | Token usage from a single API call.+--+-- 'usageInputTokens' is the total input token count, including tokens that were+-- cache reads or cache writes. Provider parsers normalize differing wire+-- semantics so this total is never double-counted.+--+-- Cost estimation treats+-- @usageInputTokens - usageCacheReadTokens - usageCacheCreationTokens@ as+-- ordinary (uncached) input. data Usage = Usage   { usageInputTokens :: !Int,     usageOutputTokens :: !Int,+    usageCacheReadTokens :: !Int,+    usageCacheCreationTokens :: !Int,     usageTotalCost :: !Double   }-  deriving (Show, Eq, Generic, ToJSON, FromJSON)+  deriving (Show, Eq, Generic) +instance ToJSON Usage where+  toJSON :: Usage -> Value+  toJSON u =+    object+      [ "usageInputTokens" .= u.usageInputTokens,+        "usageOutputTokens" .= u.usageOutputTokens,+        "usageCacheReadTokens" .= u.usageCacheReadTokens,+        "usageCacheCreationTokens" .= u.usageCacheCreationTokens,+        "usageTotalCost" .= u.usageTotalCost+      ]++instance FromJSON Usage where+  parseJSON :: Value -> Parser Usage+  parseJSON = withObject "Usage" $ \o ->+    Usage+      <$> o .: "usageInputTokens"+      <*> o .: "usageOutputTokens"+      <*> o .:? "usageCacheReadTokens" .!= 0+      <*> o .:? "usageCacheCreationTokens" .!= 0+      <*> o .:? "usageTotalCost" .!= 0+ instance Semigroup Usage where   (<>) :: Usage -> Usage -> Usage   (<>) = addUsage@@ -27,24 +63,90 @@   mempty = emptyUsage  emptyUsage :: Usage-emptyUsage = Usage {usageInputTokens = 0, usageOutputTokens = 0, usageTotalCost = 0}+emptyUsage =+  Usage+    { usageInputTokens = 0,+      usageOutputTokens = 0,+      usageCacheReadTokens = 0,+      usageCacheCreationTokens = 0,+      usageTotalCost = 0+    } +-- | Construct usage with zero cache counters and zero cost.+mkUsage :: Int -> Int -> Usage+mkUsage input output =+  Usage+    { usageInputTokens = input,+      usageOutputTokens = output,+      usageCacheReadTokens = 0,+      usageCacheCreationTokens = 0,+      usageTotalCost = 0+    }+ addUsage :: Usage -> Usage -> Usage addUsage a b =   Usage     { usageInputTokens = a.usageInputTokens + b.usageInputTokens,       usageOutputTokens = a.usageOutputTokens + b.usageOutputTokens,+      usageCacheReadTokens = a.usageCacheReadTokens + b.usageCacheReadTokens,+      usageCacheCreationTokens = a.usageCacheCreationTokens + b.usageCacheCreationTokens,       usageTotalCost = a.usageTotalCost + b.usageTotalCost     } --- | Pricing in dollars per million tokens+-- | Uncached input tokens used for ordinary input pricing.+usageOrdinaryInputTokens :: Usage -> Int+usageOrdinaryInputTokens u =+  max 0 (u.usageInputTokens - u.usageCacheReadTokens - u.usageCacheCreationTokens)++-- | Pricing in dollars per million tokens.+--+-- Optional cache rates fall back to 'pricePerMillionInput' when absent. data PricingInfo = PricingInfo   { pricePerMillionInput :: Double,-    pricePerMillionOutput :: Double+    pricePerMillionOutput :: Double,+    pricePerMillionCacheRead :: Maybe Double,+    pricePerMillionCacheWrite :: Maybe Double   }-  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)+  deriving (Eq, Ord, Show, Generic) +instance ToJSON PricingInfo where+  toJSON :: PricingInfo -> Value+  toJSON p =+    object $+      [ "pricePerMillionInput" .= p.pricePerMillionInput,+        "pricePerMillionOutput" .= p.pricePerMillionOutput+      ]+        ++ ["pricePerMillionCacheRead" .= r | Just r <- [p.pricePerMillionCacheRead]]+        ++ ["pricePerMillionCacheWrite" .= w | Just w <- [p.pricePerMillionCacheWrite]]++instance FromJSON PricingInfo where+  parseJSON :: Value -> Parser PricingInfo+  parseJSON = withObject "PricingInfo" parsePricingInfo++parsePricingInfo :: Object -> Parser PricingInfo+parsePricingInfo o =+  PricingInfo+    <$> o .: "pricePerMillionInput"+    <*> o .: "pricePerMillionOutput"+    <*> o .:? "pricePerMillionCacheRead"+    <*> o .:? "pricePerMillionCacheWrite"++-- | Pricing with only ordinary input/output rates (no cache-specific rates).+defaultPricingInfo :: Double -> Double -> PricingInfo+defaultPricingInfo input output =+  PricingInfo+    { pricePerMillionInput = input,+      pricePerMillionOutput = output,+      pricePerMillionCacheRead = Nothing,+      pricePerMillionCacheWrite = Nothing+    }+ estimateCost :: PricingInfo -> Usage -> Double estimateCost p u =-  fromIntegral u.usageInputTokens * p.pricePerMillionInput / 1_000_000-    + fromIntegral u.usageOutputTokens * p.pricePerMillionOutput / 1_000_000+  let ordinary = usageOrdinaryInputTokens u+      cacheReadRate = fromMaybe p.pricePerMillionInput p.pricePerMillionCacheRead+      cacheWriteRate = fromMaybe p.pricePerMillionInput p.pricePerMillionCacheWrite+   in fromIntegral ordinary * p.pricePerMillionInput / 1_000_000+        + fromIntegral u.usageCacheReadTokens * cacheReadRate / 1_000_000+        + fromIntegral u.usageCacheCreationTokens * cacheWriteRate / 1_000_000+        + fromIntegral u.usageOutputTokens * p.pricePerMillionOutput / 1_000_000
src/LLM/Core/Utils.hs view
@@ -21,11 +21,19 @@ import Data.Text qualified as T import LLM.Core.Types   ( ChatResponse (..),-    ContentBlock (..),+    ContentPart (..),+    ImageSource (..),     LLMError (..),     LLMResult,+    PartBody (..),+    ProviderOpaque (..),+    ThinkingContent (..),     ToolCall (..),     ToolResult (..),+    mkChatResponse,+    projectReasoning,+    thinkingPart,+    turnToolCalls,   ) import LLM.Core.Usage (Usage (..)) import System.Timeout (timeout)@@ -40,10 +48,7 @@  -- | Extract tool calls from a response getToolCalls :: ChatResponse -> [ToolCall]-getToolCalls r = concatMap go r.respContent-  where-    go (ToolCallBlock tc) = [tc]-    go _ = []+getToolCalls r = turnToolCalls r.respContent  -- | Whether an error is worth retrying isRetryable :: LLMError -> Bool@@ -82,56 +87,120 @@ streamResponseJson r =   object     [ "text" .= r.respText,-      "content" .= map blockToJson r.respContent,+      "content" .= map partToJson r.respContent,       "usage" .= fmap usageToJson r.respUsage,       "reasoning" .= r.respReasoning     ]   where-    blockToJson (TextBlock t) = object ["type" .= ("text" :: Text), "text" .= t]-    blockToJson (ToolCallBlock tc) =+    partToJson (ContentPart (TextPart t) hint) =       object $+        ["type" .= ("text" :: Text), "text" .= t]+          ++ cacheHintPair hint+    partToJson (ContentPart (ImagePart src) hint) =+      object $+        ["type" .= ("image" :: Text), "image" .= imageToJson src]+          ++ cacheHintPair hint+    partToJson (ContentPart (ThinkingPart tc) hint) =+      object $+        ["type" .= ("thinking" :: Text)]+          ++ ["text" .= t | Just t <- [tc.thinkingText]]+          ++ ["opaque" .= opaqueToJson o | Just o <- [tc.thinkingOpaque]]+          ++ cacheHintPair hint+    partToJson (ContentPart (ToolCallPart tc) hint) =+      object $         [ "type" .= ("tool_call" :: Text),           "id" .= tc.tcId,           "name" .= tc.tcName,           "arguments" .= tc.tcArguments         ]-          ++ ["provider_meta" .= m | Just m <- [tc.tcProviderMeta]]+          ++ ["provider_meta" .= opaqueToJson m | Just m <- [tc.tcProviderMeta]]+          ++ cacheHintPair hint+    cacheHintPair hint =+      ["cache_hint" .= h | Just h <- [hint]]+    imageToJson (ImageUrl url) =+      object ["type" .= ("url" :: Text), "url" .= url]+    imageToJson (ImageBase64 mediaType data_) =+      object+        [ "type" .= ("base64" :: Text),+          "media_type" .= mediaType,+          "data" .= data_+        ]+    opaqueToJson o =+      object $+        [ "provider" .= o.poProvider,+          "payload" .= o.poPayload+        ]+          ++ ["model" .= m | Just m <- [o.poModel]]     usageToJson u =       object         [ "input_tokens" .= u.usageInputTokens,-          "output_tokens" .= u.usageOutputTokens+          "output_tokens" .= u.usageOutputTokens,+          "cache_read_tokens" .= u.usageCacheReadTokens,+          "cache_creation_tokens" .= u.usageCacheCreationTokens         ]  parseChatResponse :: Value -> Parser ChatResponse parseChatResponse = AE.withObject "ChatResponse" $ \v -> do-  text <- v AE..: "text"-  content <- v AE..: "content" >>= mapM parseContentBlock+  content <- v AE..: "content" >>= mapM parseContentPart   usage <- v AE..:? "usage" >>= mapM parseUsage-  reasoning <- v AE..:? "reasoning"-  pure-    ChatResponse-      { respText = text,-        respContent = content,-        respUsage = usage,-        respReasoning = reasoning-      }+  -- Synthetic stream summaries store reasoning beside content blocks.+  mReasoning <- v AE..:? "reasoning"+  let contentWithReasoning =+        case (projectReasoning content, mReasoning) of+          (Nothing, Just rc)+            | not (T.null rc) ->+                thinkingPart (ThinkingContent (Just rc) Nothing) : content+          _ -> content+  pure $ mkChatResponse contentWithReasoning usage   where-    parseContentBlock = AE.withObject "ContentBlock" $ \o -> do+    parseContentPart = AE.withObject "ContentPart" $ \o -> do       t <- o AE..: "type"-      case (t :: Text) of-        "text" -> TextBlock <$> o AE..: "text"+      mHint <- o AE..:? "cache_hint"+      body <- case (t :: Text) of+        "text" -> TextPart <$> o AE..: "text"+        "image" -> ImagePart <$> (o AE..: "image" >>= parseImageSource)+        "thinking" -> do+          mText <- o AE..:? "text"+          mOpaque <- o AE..:? "opaque" >>= mapM parseOpaque+          pure $ ThinkingPart (ThinkingContent mText mOpaque)         "tool_call" -> do           tcId <- o AE..: "id"           tcName <- o AE..: "name"           tcArgs <- o AE..: "arguments"-          tcMeta <- o AE..:? "provider_meta"-          pure $ ToolCallBlock $ ToolCall tcId tcName tcArgs tcMeta-        _ -> fail "Unknown content block type"+          tcMeta <- o AE..:? "provider_meta" >>= mapM parseOpaque+          pure $ ToolCallPart (ToolCall tcId tcName tcArgs tcMeta)+        _ -> fail "Unknown content part type"+      pure $ ContentPart body mHint +    parseImageSource = AE.withObject "ImageSource" $ \o -> do+      typ <- o AE..: "type" :: Parser Text+      case typ of+        "url" -> ImageUrl <$> o AE..: "url"+        "base64" ->+          ImageBase64+            <$> o AE..: "media_type"+            <*> o AE..: "data"+        _ -> fail "Unknown image source type"++    parseOpaque = AE.withObject "ProviderOpaque" $ \o ->+      ProviderOpaque+        <$> o AE..: "provider"+        <*> o AE..:? "model"+        <*> o AE..: "payload"+     parseUsage = AE.withObject "Usage" $ \o -> do       input <- o AE..: "input_tokens"       output <- o AE..: "output_tokens"-      pure $ Usage input output 0.0+      cacheRead <- fromMaybe 0 <$> o AE..:? "cache_read_tokens"+      cacheCreate <- fromMaybe 0 <$> o AE..:? "cache_creation_tokens"+      pure $+        Usage+          { usageInputTokens = input,+            usageOutputTokens = output,+            usageCacheReadTokens = cacheRead,+            usageCacheCreationTokens = cacheCreate,+            usageTotalCost = 0.0+          }  printValue :: Value -> IO () printValue val = L8.putStrLn (encode val)
src/LLM/Generate.hs view
@@ -13,6 +13,8 @@     streamTextWithFallbacks,     genObject,     genObjectUntyped,+    ModelCapabilities (..),+    defaultModelCapabilities,     ModelConfig (..),     ModelWithFallbacks (..),     GenRequest (..),
src/LLM/Generate/GenerateUtils.hs view
@@ -4,6 +4,7 @@     usageWithModelCost,     callWithRetryTimeout,     withModelFallbacks,+    validateModelCapabilities,     llmHooks,   ) where@@ -16,12 +17,16 @@     LLMError (..),     LLMGateway (gwName),     LLMHooks (..),+    ThinkingMode (..),+    Turn,+    conversationHasImages,   ) import LLM.Core.Usage (Usage (..), estimateCost) import LLM.Core.Utils (withRetry, withTimeout) import LLM.Generate.Logger (Hooks (..), LogLevel (..)) import LLM.Generate.ModelConfig-  ( ModelConfig (..),+  ( ModelCapabilities (..),+    ModelConfig (..),     ModelWithFallbacks (..),     mfwToModelConfigs,     modelRetryPolicy,@@ -47,6 +52,37 @@ usageWithModelCost :: ModelConfig -> Usage -> Usage usageWithModelCost mc u = u {usageTotalCost = estimateCost mc.mcPricing u} +-- | Check that a candidate model declares every capability the request needs.+--+-- Unsupported vision is a candidate failure (do not drop images). Thinking+-- configuration requires a declared thinking capability. Cache hints are not+-- validated here; unsupported providers ignore them.+validateModelCapabilities :: ModelConfig -> [Turn] -> Either LLMError ()+validateModelCapabilities mc turns = do+  whenNeedsVision+  whenNeedsThinking+  where+    caps = mc.mcCapabilities+    whenNeedsVision+      | conversationHasImages turns,+        not caps.capVision =+          Left $+            UnsupportedCapability $+              "model "+                <> mc.mcModel+                <> " does not support vision"+      | otherwise = Right ()+    whenNeedsThinking =+      case mc.mcThinking of+        Just ThinkingMode {tmEnabled = True}+          | not caps.capThinking ->+              Left $+                UnsupportedCapability $+                  "model "+                    <> mc.mcModel+                    <> " does not support thinking"+        _ -> Right ()+ callWithRetryTimeout ::   GenRequest ->   ModelConfig ->@@ -73,16 +109,24 @@           maybe GErrAllModelsFailed GErrLLM mLast     loop (mc : rest) _ = do       gr.grHooks.onLog Info (formatTryingModel mc)-      r <- invokePerModel mc-      case r of-        Left Aborted -> pure $ Left GErrAborted+      case validateModelCapabilities mc gr.grMessages of         Left err ->           case rest of             [] -> pure $ Left (GErrLLM err)             _ -> do               gr.grHooks.onLog Warn (formatModelFallback mc err)               loop rest (Just err)-        Right a -> pure $ Right a+        Right () -> do+          r <- invokePerModel mc+          case r of+            Left Aborted -> pure $ Left GErrAborted+            Left err ->+              case rest of+                [] -> pure $ Left (GErrLLM err)+                _ -> do+                  gr.grHooks.onLog Warn (formatModelFallback mc err)+                  loop rest (Just err)+            Right a -> pure $ Right a  formatTryingModel :: ModelConfig -> Text formatTryingModel mc =
src/LLM/Generate/ModelConfig.hs view
@@ -1,5 +1,7 @@ module LLM.Generate.ModelConfig-  ( ModelConfig (..),+  ( ModelCapabilities (..),+    defaultModelCapabilities,+    ModelConfig (..),     ModelWithFallbacks (..),     mfwToModelConfigs,     modelRetryPolicy,@@ -7,10 +9,46 @@ where  import Control.Retry (RetryPolicyM, fullJitterBackoff, limitRetries)+import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.!=), (.:?), (.=)) import Data.Text (Text)+import GHC.Generics (Generic) import LLM.Core.Types (LLMGateway, ThinkingMode) import LLM.Core.Usage (PricingInfo) +-- | Declared model capabilities from the catalog.+--+-- Missing flags decode as 'False'. These are declarations, not automatic+-- API discovery; request validation consults them before I/O.+data ModelCapabilities = ModelCapabilities+  { capThinking :: Bool,+    capVision :: Bool,+    capPromptCaching :: Bool+  }+  deriving (Show, Eq, Ord, Generic)++defaultModelCapabilities :: ModelCapabilities+defaultModelCapabilities =+  ModelCapabilities+    { capThinking = False,+      capVision = False,+      capPromptCaching = False+    }++instance ToJSON ModelCapabilities where+  toJSON caps =+    object+      [ "thinking" .= caps.capThinking,+        "vision" .= caps.capVision,+        "promptCaching" .= caps.capPromptCaching+      ]++instance FromJSON ModelCapabilities where+  parseJSON = withObject "capabilities" $ \o ->+    ModelCapabilities+      <$> o .:? "thinking" .!= False+      <*> o .:? "vision" .!= False+      <*> o .:? "promptCaching" .!= False+ -- | Provider connection and per-model tuning parameters. -- -- Usually constructed via 'LLM.Load.loadModelOrThrow' from a JSON catalog entry,@@ -28,6 +66,8 @@     mcTemperature :: Maybe Double,     -- | Thinking / reasoning mode (DeepSeek, etc.), when supported.     mcThinking :: Maybe ThinkingMode,+    -- | Declared capabilities (vision, thinking, prompt caching).+    mcCapabilities :: ModelCapabilities,     -- | Whole-request timeout in milliseconds ('Nothing' = no timeout).     mcRequestTimeout :: Maybe Int,     -- | Delay in milliseconds before each API call (rate limiting).
src/LLM/Load.hs view
@@ -10,6 +10,8 @@ --         "pricePerMillionInput": 0.0, --         "pricePerMillionOutput": 0.0 --     },+--     -- optional: pricePerMillionCacheRead / pricePerMillionCacheWrite+--     -- (fall back to pricePerMillionInput when absent) --     "maxTokens": 1024, --     "temperature": 0.5, --     "requestTimeout": 10000,
src/LLM/Load/LoadModels.hs view
@@ -18,8 +18,12 @@ import Data.Map (Map) import Data.Map qualified as Map import Data.Text (Text)+import Data.Maybe (fromMaybe) import LLM.Core.Types (ThinkingMode (..))-import LLM.Generate.ModelConfig (ModelConfig (..))+import LLM.Generate.ModelConfig+  ( ModelConfig (..),+    defaultModelCapabilities,+  ) import LLM.Load.LoadGateways (GatewayMap, loadGatewaysFromCatalogOrThrow) import LLM.Load.ModelCatalog (ModelCatalogItem (..), loadModelCatalog) import LLM.Load.ProviderCatalog (loadProviderCatalogForModelCatalog)@@ -45,6 +49,7 @@                 mcMaxTokens = item.maxTokens,                 mcTemperature = item.temperature,                 mcThinking = fmap (\x -> ThinkingMode {tmEnabled = True, tmEffort = Just x}) item.thinking,+                mcCapabilities = fromMaybe defaultModelCapabilities item.capabilities,                 mcRequestTimeout = item.requestTimeout,                 mcThrottleDelay = item.throttleDelay,                 mcRetryCount = item.retryCount,
src/LLM/Load/ModelCatalog.hs view
@@ -8,6 +8,7 @@ import Data.Text qualified as T import GHC.Generics (Generic) import LLM.Core.Usage (PricingInfo)+import LLM.Generate.ModelConfig (ModelCapabilities) import LLM.Load.Types (LoadConfigError (..)) import LLM.Load.Utils (decodeJsonFile) @@ -20,6 +21,8 @@     temperature :: Maybe Double,     requestTimeout :: Maybe Int,     thinking :: Maybe Text,+    -- | Optional capability flags; missing object or keys default to false.+    capabilities :: Maybe ModelCapabilities,     throttleDelay :: Maybe Int,     retryCount :: Int,     jitterBackoff :: Int
src/LLM/Providers/Claude.hs view
@@ -4,21 +4,32 @@     claudeProvider,     claudeProviderWith,     parseClaudeResponse,+    parseClaudeStream,     parseClaudeUsage,+    claudeBuildBody,+    encodeTurn,+    effortToBudgetTokens,   ) where  import Data.Aeson   ( KeyValue ((.=)),-    Value (String),+    Value (Object, String),     decodeStrict',     encode,     object,+    toJSON,     withObject,+    (.!=),     (.:),+    (.:?),   )-import Data.Aeson.Types (Parser, parseMaybe)+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (Object, Pair, Parser, parseMaybe)+import Data.ByteString qualified as BS+import Data.Foldable (for_) import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding (encodeUtf8)@@ -28,26 +39,38 @@ import LLM.Core.ProviderUtils (handleStreamResponse, lenientConfig, stripJsonFences) import LLM.Core.SSE (SSEEvent (sseData, sseEvent), readSSEEvents) import LLM.Core.Types-  ( ChatRequest+  ( CacheHint (..),+    ChatRequest       ( reqConversation,         reqMaxTokens,         reqModel,         reqSystem,         reqTemperature,+        reqThinking,         reqTools       ),-    ChatResponse (ChatResponse),-    ContentBlock (..),+    ContentPart (..),+    ImageSource (..),     LLMError (EmptyResponse),     LLMGateway,     LLMResult,     LLMTextResult,+    PartBody (..),+    ProviderOpaque (..),     StreamEvent (..),+    ThinkingContent (..),+    ThinkingMode (..),     ToolCall (..),-    mkToolCall,     ToolDef (toolDescription, toolName, toolParameters),     ToolResult (trCallId, trContent),     Turn (..),+    mkChatResponse,+    mkToolCall,+    stripForeignOpaque,+    textPart,+    thinkingPart,+    toolCallPart,+    pattern UserTurn,   ) import LLM.Core.Usage (Usage (..), emptyUsage) import Network.HTTP.Client qualified as HC@@ -67,6 +90,9 @@     (/:),   ) +claudeProviderName :: Text+claudeProviderName = "claude"+ -- | Create a LLMGateway for the Claude provider at api.anthropic.com. claudeGateway :: Text -> LLMGateway claudeGateway apiKey = toGateway $ claudeProvider apiKey@@ -85,15 +111,16 @@ claudeProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider claudeProviderWith baseUrl baseOpts apiKey =   LLMProvider-    { providerName = "claude",+    { providerName = claudeProviderName,       buildBody = claudeBuildBody,       sendRequest = sendRequest,       sendStreamRequest = \body callback ->         runReq lenientConfig $ do           let url = baseUrl /: "v1" /: "messages"               opts = baseOpts <> claudeAuthOpts apiKey+              modelHint = fromMaybe "" (parseMaybe (withObject "body" (.: "model")) body)           reqBr POST url (ReqBodyJson body) opts $ \resp ->-            handleStreamResponse resp (`parseClaudeStream` callback),+            handleStreamResponse resp (\br -> parseClaudeStream modelHint (HC.brRead br) callback),       parseResponse = pure . parseClaudeResponse,       buildObjectBody = \r schema ->         let schemaText = TL.toStrict . decodeUtf8 $ encode schema@@ -111,87 +138,424 @@         resp <- req POST url (ReqBodyJson body) jsonResponse opts         pure (responseStatusCode resp, responseBody resp) --- Internal helpers- claudeAuthOpts :: Text -> Option scheme claudeAuthOpts apiKey =   header "x-api-key" (encodeUtf8 apiKey)     <> header "anthropic-version" "2023-06-01" -parseClaudeStream :: HC.BodyReader -> (StreamEvent -> IO ()) -> IO LLMTextResult-parseClaudeStream reader callback = do-  blocksRef <- newIORef ([] :: [ContentBlock])+-- | Map catalog effort labels to Claude extended-thinking @budget_tokens@.+effortToBudgetTokens :: Text -> Int+effortToBudgetTokens = \case+  "low" -> 1024+  "medium" -> 4096+  "high" -> 10000+  "max" -> 16000+  other ->+    case reads (T.unpack other) of+      [(n, "")] | n >= 1024 -> n+      _ -> 10000++claudeBuildBody :: Bool -> ChatRequest -> Value+claudeBuildBody stream r =+  object $+    [ "model" .= r.reqModel,+      "max_tokens" .= r.reqMaxTokens,+      "messages" .= concatMap (encodeTurn r.reqModel) r.reqConversation+    ]+      ++ ["system" .= sys | Just sys <- [r.reqSystem]]+      ++ temperaturePairs r+      ++ thinkingPairs r+      ++ ["tools" .= map encodeToolDef r.reqTools | not (null r.reqTools)]+      ++ ["stream" .= True | stream]++-- | Anthropic rejects non-default temperature while thinking is active.+-- Omit temperature whenever thinking is enabled.+temperaturePairs :: ChatRequest -> [Pair]+temperaturePairs r =+  case r.reqThinking of+    Just tm | tm.tmEnabled -> []+    _ -> ["temperature" .= t | Just t <- [r.reqTemperature]]++thinkingPairs :: ChatRequest -> [Pair]+thinkingPairs r =+  case r.reqThinking of+    Just tm+      | tm.tmEnabled ->+          let budget = maybe 10000 effortToBudgetTokens tm.tmEffort+           in [ "thinking"+                  .= object+                    [ "type" .= ("enabled" :: Text),+                      "budget_tokens" .= budget+                    ]+              ]+    Just tm+      | not tm.tmEnabled ->+          ["thinking" .= object ["type" .= ("disabled" :: Text)]]+    _ -> []++encodeTurn :: Text -> Turn -> [Value]+encodeTurn _ (UserMessage parts) =+  [ object+      [ "role" .= ("user" :: Text),+        "content" .= encodeUserContent parts+      ]+  ]+encodeTurn currentModel (AssistantMessage parts) =+  [ object+      [ "role" .= ("assistant" :: Text),+        "content" .= mapMaybe (encodeAssistantPart currentModel . stripForeignOpaque claudeProviderName) parts+      ]+  ]+encodeTurn _ (ToolTurn results) =+  [ object+      [ "role" .= ("user" :: Text),+        "content" .= map encodeToolResult results+      ]+  ]++encodeUserContent :: [ContentPart] -> Value+-- Bare string only when a single unannotated text part (fixture-compatible).+encodeUserContent [ContentPart (TextPart t) Nothing] = String t+encodeUserContent parts = toJSON (mapMaybe encodeUserPart parts)+  where+    encodeUserPart (ContentPart (TextPart t) hint) =+      Just $ withCacheControl hint $ object ["type" .= ("text" :: Text), "text" .= t]+    encodeUserPart (ContentPart (ImagePart src) hint) =+      Just $ withCacheControl hint $ encodeImageBlock src+    encodeUserPart _ = Nothing++encodeImageBlock :: ImageSource -> Value+encodeImageBlock (ImageUrl url) =+  object+    [ "type" .= ("image" :: Text),+      "source"+        .= object+          [ "type" .= ("url" :: Text),+            "url" .= url+          ]+    ]+encodeImageBlock (ImageBase64 mediaType data_) =+  object+    [ "type" .= ("image" :: Text),+      "source"+        .= object+          [ "type" .= ("base64" :: Text),+            "media_type" .= mediaType,+            "data" .= data_+          ]+    ]++-- | Attach Anthropic @cache_control@ for the default ephemeral TTL.+withCacheControl :: Maybe CacheHint -> Value -> Value+withCacheControl (Just CacheEphemeral) (Object o) =+  Object $ KM.insert "cache_control" (object ["type" .= ("ephemeral" :: Text)]) o+withCacheControl _ v = v++encodeAssistantPart :: Text -> ContentPart -> Maybe Value+encodeAssistantPart currentModel (ContentPart (ThinkingPart tc) hint) =+  case opaqueForClaude currentModel tc.thinkingOpaque of+    Just payload -> Just $ withCacheControl hint payload+    Nothing ->+      -- Without a Claude signature we must not invent a thinking block.+      Nothing+encodeAssistantPart _ (ContentPart (TextPart t) hint)+  | T.null t = Nothing+  | otherwise =+      Just $+        withCacheControl hint $+          object ["type" .= ("text" :: Text), "text" .= t]+encodeAssistantPart _ (ContentPart (ToolCallPart tc) hint) =+  Just $ withCacheControl hint $ encodeToolUseBlock tc+encodeAssistantPart _ (ContentPart (ImagePart _) _) = Nothing++opaqueForClaude :: Text -> Maybe ProviderOpaque -> Maybe Value+opaqueForClaude _ (Just o)+  | o.poProvider == claudeProviderName = Just o.poPayload+opaqueForClaude _ _ = Nothing++encodeToolDef :: ToolDef -> Value+encodeToolDef td =+  object+    [ "name" .= td.toolName,+      "description" .= td.toolDescription,+      "input_schema" .= td.toolParameters+    ]++encodeToolUseBlock :: ToolCall -> Value+encodeToolUseBlock tc =+  object+    [ "type" .= ("tool_use" :: Text),+      "id" .= tc.tcId,+      "name" .= tc.tcName,+      "input" .= tc.tcArguments+    ]++encodeToolResult :: ToolResult -> Value+encodeToolResult tr =+  object+    [ "type" .= ("tool_result" :: Text),+      "tool_use_id" .= tr.trCallId,+      "content" .= tr.trContent+    ]++parseClaudeResponse :: Value -> LLMTextResult+parseClaudeResponse v = case parseMaybe (go modelVer) v of+  Nothing -> Left EmptyResponse+  Just parts ->+    case parts of+      [] -> Left EmptyResponse+      _ -> Right (mkChatResponse parts (parseClaudeUsage v))+  where+    modelVer = parseMaybe (withObject "ClaudeResponse" (.: "model")) v++    go :: Maybe Text -> Value -> Parser [ContentPart]+    go mv = withObject "ClaudeResponse" $ \o -> do+      content <- o .: "content" :: Parser [Value]+      mapM (parseBlock mv) content++parseBlock :: Maybe Text -> Value -> Parser ContentPart+parseBlock mv = withObject "content_block" $ \o -> do+  typ <- o .: "type" :: Parser Text+  case typ of+    "text" -> textPart <$> o .: "text"+    "thinking" -> do+      thinkingTxt <- o .:? "thinking" :: Parser (Maybe Text)+      signature <- o .:? "signature" :: Parser (Maybe Text)+      let payload =+            object $+              ["type" .= ("thinking" :: Text)]+                ++ ["thinking" .= t | Just t <- [thinkingTxt]]+                ++ ["signature" .= s | Just s <- [signature]]+          opaque =+            ProviderOpaque+              { poProvider = claudeProviderName,+                poModel = mv,+                poPayload = payload+              }+          mText = case thinkingTxt of+            Just t | not (T.null t) -> Just t+            _ -> Nothing+      pure $ thinkingPart (ThinkingContent mText (Just opaque))+    "redacted_thinking" -> do+      data_ <- o .: "data" :: Parser Text+      let payload =+            object+              [ "type" .= ("redacted_thinking" :: Text),+                "data" .= data_+              ]+          opaque =+            ProviderOpaque+              { poProvider = claudeProviderName,+                poModel = mv,+                poPayload = payload+              }+      pure $ thinkingPart (ThinkingContent Nothing (Just opaque))+    "tool_use" -> do+      cid <- o .: "id"+      name <- o .: "name"+      args <- o .: "input"+      pure $ toolCallPart (mkToolCall cid name args)+    _ -> fail $ "Unknown content block type: " <> T.unpack typ++parseClaudeUsage :: Value -> Maybe Usage+parseClaudeUsage = parseMaybe $ withObject "ClaudeResponse" $ \o -> do+  u <- o .: "usage"+  withObject "usage" parseClaudeUsageObject u++parseClaudeUsageObject :: Object -> Parser Usage+parseClaudeUsageObject uo = do+  -- Anthropic: input_tokens is uncached-only; total = uncached + read + creation.+  uncached <- uo .: "input_tokens"+  output <- uo .: "output_tokens"+  cacheRead <- uo .:? "cache_read_input_tokens" .!= 0+  cacheCreate <- uo .:? "cache_creation_input_tokens" .!= 0+  pure+    Usage+      { usageInputTokens = uncached + cacheRead + cacheCreate,+        usageOutputTokens = output,+        usageCacheReadTokens = cacheRead,+        usageCacheCreationTokens = cacheCreate,+        usageTotalCost = 0+      }++parseClaudeObjectResponse :: Value -> IO (LLMResult (Value, Maybe Usage))+parseClaudeObjectResponse v = case parseMaybe go v of+  Nothing -> pure $ Left EmptyResponse+  Just text -> case decodeStrict' (encodeUtf8 (stripJsonFences text)) of+    Nothing -> pure $ Left EmptyResponse+    Just obj -> pure $ Right (obj, parseClaudeUsage v)+  where+    go :: Value -> Parser Text+    go = withObject "ClaudeResponse" $ \o -> do+      content <- o .: "content" :: Parser [Value]+      texts <- mapMaybeM textOf content+      case texts of+        (t : _) -> pure t+        _ -> fail "No content"+    textOf = parseMaybe $ withObject "content_block" $ \o -> do+      typ <- o .: "type" :: Parser Text+      case typ of+        "text" -> o .: "text"+        _ -> fail "not text"+    mapMaybeM f xs = pure (mapMaybe f xs)++-- Streaming ------------------------------------------------------------------++data StreamBlock+  = StreamText Text+  | StreamThinking Text (Maybe Text) -- text, signature+  | StreamRedacted Text -- data+  | StreamTool Text Text Value -- id, name, args++parseClaudeStream :: Text -> IO BS.ByteString -> (StreamEvent -> IO ()) -> IO LLMTextResult+parseClaudeStream modelHint readChunk callback = do+  blocksRef <- newIORef ([] :: [StreamBlock])   usageRef <- newIORef emptyUsage-  -- For accumulating tool_use input JSON across deltas-  toolAccRef <- newIORef (Nothing :: Maybe (Text, Text, Text)) -- (id, name, json_so_far)-  readSSEEvents (HC.brRead reader) $ \sse -> do+  toolAccRef <- newIORef (Nothing :: Maybe (Text, Text, Text))+  thinkingAccRef <- newIORef (Nothing :: Maybe (Text, Maybe Text)) -- text, signature+  redactedRef <- newIORef (Nothing :: Maybe Text)+  modelRef <- newIORef modelHint+  readSSEEvents readChunk $ \sse -> do     case sse.sseEvent of       Just "message_start" ->-        -- Extract input token count from message.usage         case decodeStrict' (encodeUtf8 sse.sseData) of-          Just v -> case parseMaybe parseMessageStartUsage v of-            Just inputToks -> modifyIORef' usageRef $ \u -> u {usageInputTokens = inputToks}-            Nothing -> pure ()+          Just v -> do+            case parseMaybe parseMessageStartUsage v of+              Just startUsage -> modifyIORef' usageRef $ \u ->+                u+                  { usageInputTokens = startUsage.usageInputTokens,+                    usageCacheReadTokens = startUsage.usageCacheReadTokens,+                    usageCacheCreationTokens = startUsage.usageCacheCreationTokens+                  }+              Nothing -> pure ()+            for_+              (parseMaybe parseMessageStartModel v)+              (writeIORef modelRef)           Nothing -> pure ()       Just "content_block_start" ->         case decodeStrict' (encodeUtf8 sse.sseData) of           Just v -> case parseMaybe parseContentBlockStart v of-            Just (cid, name) -> writeIORef toolAccRef (Just (cid, name, ""))-            Nothing -> pure () -- text block start, nothing to do+            Just (StartTool cid name) -> writeIORef toolAccRef (Just (cid, name, ""))+            Just StartThinking -> writeIORef thinkingAccRef (Just ("", Nothing))+            Just (StartRedacted data_) -> writeIORef redactedRef (Just data_)+            Just StartText -> pure ()+            Nothing -> pure ()           Nothing -> pure ()       Just "content_block_delta" ->         case decodeStrict' (encodeUtf8 sse.sseData) of           Just v -> do-            -- Try text delta             case parseMaybe parseTextDelta v of               Just txt -> do-                modifyIORef' blocksRef (TextBlock txt :)+                modifyIORef' blocksRef (StreamText txt :)                 callback (StreamDelta txt)               Nothing -> pure ()-            -- Try tool input delta+            case parseMaybe parseThinkingDelta v of+              Just txt -> do+                modifyIORef' thinkingAccRef $ fmap (\(acc, sig) -> (acc <> txt, sig))+                callback (StreamReasoningDelta txt)+              Nothing -> pure ()+            case parseMaybe parseSignatureDelta v of+              Just sig ->+                modifyIORef' thinkingAccRef $ fmap (\(acc, _) -> (acc, Just sig))+              Nothing -> pure ()             case parseMaybe parseInputJsonDelta v of               Just fragment ->                 modifyIORef' toolAccRef $ fmap (\(cid, name, acc) -> (cid, name, acc <> fragment))               Nothing -> pure ()           Nothing -> pure ()       Just "content_block_stop" -> do+        mThinking <- readIORef thinkingAccRef+        case mThinking of+          Just (txt, mSig) -> do+            modifyIORef' blocksRef (StreamThinking txt mSig :)+            writeIORef thinkingAccRef Nothing+          Nothing -> pure ()+        mRedacted <- readIORef redactedRef+        case mRedacted of+          Just data_ -> do+            modifyIORef' blocksRef (StreamRedacted data_ :)+            writeIORef redactedRef Nothing+          Nothing -> pure ()         mTool <- readIORef toolAccRef         case mTool of-          Just (cid, name, jsonStr) | not (T.null jsonStr) -> do+          Just (cid, name, jsonStr) -> do             let args = case decodeStrict' (encodeUtf8 jsonStr) of-                  Just v -> v+                  Just a -> a                   Nothing -> String jsonStr                 tc = mkToolCall cid name args-            modifyIORef' blocksRef (ToolCallBlock tc :)+            modifyIORef' blocksRef (StreamTool cid name args :)             callback (StreamToolCall tc)             writeIORef toolAccRef Nothing-          _ -> writeIORef toolAccRef Nothing+          Nothing -> pure ()       Just "message_delta" ->         case decodeStrict' (encodeUtf8 sse.sseData) of           Just v -> case parseMaybe parseMessageDeltaUsage v of             Just outputToks -> modifyIORef' usageRef $ \u -> u {usageOutputTokens = outputToks}             Nothing -> pure ()           Nothing -> pure ()-      _ -> pure () -- message_stop, ping, etc.-  blocks <- reverse <$> readIORef blocksRef+      _ -> pure ()+  rawBlocks <- reverse <$> readIORef blocksRef   usage <- readIORef usageRef-  let text = T.concat [t | TextBlock t <- blocks]-  if null blocks+  model <- readIORef modelRef+  let parts = map (streamBlockToPart model) rawBlocks+  if null parts     then pure $ Left EmptyResponse-    else pure $ Right (ChatResponse text blocks (Just usage) Nothing)+    else pure $ Right (mkChatResponse parts (Just usage)) --- Parsers for streaming events-parseMessageStartUsage :: Value -> Parser Int+streamBlockToPart :: Text -> StreamBlock -> ContentPart+streamBlockToPart _ (StreamText t) = textPart t+streamBlockToPart model (StreamThinking txt mSig) =+  let payload =+        object $+          ["type" .= ("thinking" :: Text)]+            ++ ["thinking" .= txt | not (T.null txt)]+            ++ ["signature" .= s | Just s <- [mSig]]+      opaque =+        ProviderOpaque+          { poProvider = claudeProviderName,+            poModel = if T.null model then Nothing else Just model,+            poPayload = payload+          }+      mText = if T.null txt then Nothing else Just txt+   in thinkingPart (ThinkingContent mText (Just opaque))+streamBlockToPart model (StreamRedacted data_) =+  let payload =+        object+          [ "type" .= ("redacted_thinking" :: Text),+            "data" .= data_+          ]+      opaque =+        ProviderOpaque+          { poProvider = claudeProviderName,+            poModel = if T.null model then Nothing else Just model,+            poPayload = payload+          }+   in thinkingPart (ThinkingContent Nothing (Just opaque))+streamBlockToPart _model (StreamTool cid name args) =+  toolCallPart (mkToolCall cid name args)++data BlockStart+  = StartText+  | StartThinking+  | StartRedacted Text+  | StartTool Text Text++parseMessageStartUsage :: Value -> Parser Usage parseMessageStartUsage = withObject "message_start" $ \o -> do   msg <- o .: "message"-  withObject "message" (\mo -> do u <- mo .: "usage"; withObject "usage" (.: "input_tokens") u) msg+  withObject "message" (\mo -> do u <- mo .: "usage"; withObject "usage" parseClaudeUsageObject u) msg +parseMessageStartModel :: Value -> Parser Text+parseMessageStartModel = withObject "message_start" $ \o -> do+  msg <- o .: "message"+  withObject "message" (.: "model") msg+ parseMessageDeltaUsage :: Value -> Parser Int parseMessageDeltaUsage = withObject "message_delta" $ \o -> do   u <- o .: "usage"   withObject "usage" (.: "output_tokens") u -parseContentBlockStart :: Value -> Parser (Text, Text)+parseContentBlockStart :: Value -> Parser BlockStart parseContentBlockStart = withObject "content_block_start" $ \o -> do   cb <- o .: "content_block"   withObject@@ -199,8 +563,11 @@     ( \cbo -> do         typ <- cbo .: "type" :: Parser Text         case typ of-          "tool_use" -> (,) <$> cbo .: "id" <*> cbo .: "name"-          _ -> fail "not tool_use"+          "tool_use" -> StartTool <$> cbo .: "id" <*> cbo .: "name"+          "thinking" -> pure StartThinking+          "redacted_thinking" -> StartRedacted <$> (cbo .:? "data" .!= "")+          "text" -> pure StartText+          _ -> fail "unknown block start"     )     cb @@ -217,6 +584,32 @@     )     d +parseThinkingDelta :: Value -> Parser Text+parseThinkingDelta = withObject "delta_event" $ \o -> do+  d <- o .: "delta"+  withObject+    "delta"+    ( \d' -> do+        typ <- d' .: "type" :: Parser Text+        case typ of+          "thinking_delta" -> d' .: "thinking"+          _ -> fail "not thinking_delta"+    )+    d++parseSignatureDelta :: Value -> Parser Text+parseSignatureDelta = withObject "delta_event" $ \o -> do+  d <- o .: "delta"+  withObject+    "delta"+    ( \d' -> do+        typ <- d' .: "type" :: Parser Text+        case typ of+          "signature_delta" -> d' .: "signature"+          _ -> fail "not signature_delta"+    )+    d+ parseInputJsonDelta :: Value -> Parser Text parseInputJsonDelta = withObject "delta_event" $ \o -> do   d <- o .: "delta"@@ -229,108 +622,3 @@           _ -> fail "not input_json_delta"     )     d--claudeBuildBody :: Bool -> ChatRequest -> Value-claudeBuildBody stream r =-  object $-    [ "model" .= r.reqModel,-      "max_tokens" .= r.reqMaxTokens,-      "messages" .= concatMap encodeTurn r.reqConversation-    ]-      ++ ["system" .= sys | Just sys <- [r.reqSystem]]-      ++ ["temperature" .= t | Just t <- [r.reqTemperature]]-      ++ ["tools" .= map encodeToolDef r.reqTools | not (null r.reqTools)]-      ++ ["stream" .= True | stream]--encodeTurn :: Turn -> [Value]-encodeTurn (UserTurn content) =-  [ object-      [ "role" .= ("user" :: Text),-        "content" .= content-      ]-  ]-encodeTurn (AssistantTurn text _mReasoning calls) =-  [ object-      [ "role" .= ("assistant" :: Text),-        "content" .= (textBlocks ++ toolBlocks)-      ]-  ]-  where-    textBlocks = [object ["type" .= ("text" :: Text), "text" .= text] | not (T.null text)]-    toolBlocks = map encodeToolUseBlock calls-encodeTurn (ToolTurn results) =-  [ object-      [ "role" .= ("user" :: Text),-        "content" .= map encodeToolResult results-      ]-  ]--encodeToolDef :: ToolDef -> Value-encodeToolDef td =-  object-    [ "name" .= td.toolName,-      "description" .= td.toolDescription,-      "input_schema" .= td.toolParameters-    ]--encodeToolUseBlock :: ToolCall -> Value-encodeToolUseBlock tc =-  object-    [ "type" .= ("tool_use" :: Text),-      "id" .= tc.tcId,-      "name" .= tc.tcName,-      "input" .= tc.tcArguments-    ]--encodeToolResult :: ToolResult -> Value-encodeToolResult tr =-  object-    [ "type" .= ("tool_result" :: Text),-      "tool_use_id" .= tr.trCallId,-      "content" .= tr.trContent-    ]--parseClaudeResponse :: Value -> LLMTextResult-parseClaudeResponse v = case parseMaybe go v of-  Nothing -> Left EmptyResponse-  Just blocks -> case blocks of-    [] -> Left EmptyResponse-    _ ->-      let text = T.concat [t | TextBlock t <- blocks]-       in Right (ChatResponse text blocks (parseClaudeUsage v) Nothing)-  where-    go :: Value -> Parser [ContentBlock]-    go = withObject "ClaudeResponse" $ \o -> do-      content <- o .: "content" :: Parser [Value]-      mapM parseBlock content--    parseBlock :: Value -> Parser ContentBlock-    parseBlock = withObject "content_block" $ \o -> do-      typ <- o .: "type" :: Parser Text-      case typ of-        "text" -> TextBlock <$> o .: "text"-        "tool_use" -> do-          cid <- o .: "id"-          name <- o .: "name"-          args <- o .: "input"-          pure $ ToolCallBlock (mkToolCall cid name args)-        _ -> fail $ "Unknown content block type: " <> T.unpack typ--parseClaudeUsage :: Value -> Maybe Usage-parseClaudeUsage = parseMaybe $ withObject "ClaudeResponse" $ \o -> do-  u <- o .: "usage"-  withObject "usage" (\uo -> Usage <$> uo .: "input_tokens" <*> uo .: "output_tokens" <*> pure 0) u--parseClaudeObjectResponse :: Value -> IO (LLMResult (Value, Maybe Usage))-parseClaudeObjectResponse v = case parseMaybe go v of-  Nothing -> pure $ Left EmptyResponse-  Just text -> case decodeStrict' (encodeUtf8 (stripJsonFences text)) of-    Nothing -> pure $ Left EmptyResponse-    Just obj -> pure $ Right (obj, parseClaudeUsage v)-  where-    go :: Value -> Parser Text-    go = withObject "ClaudeResponse" $ \o -> do-      content <- o .: "content" :: Parser [Value]-      case content of-        (block : _) -> withObject "content_block" (.: "text") block-        _ -> fail "No content"
src/LLM/Providers/DeepSeek.hs view
@@ -35,8 +35,8 @@     LLMError (EmptyResponse),     LLMGateway,     ThinkingMode (..),-    Turn (UserTurn),     deepSeekMessageEncodeOptions,+    pattern UserTurn,   ) import LLM.Providers.OpenAI   ( authHeader,
src/LLM/Providers/Gemini.hs view
@@ -5,12 +5,15 @@     geminiProviderWith,     parseGeminiResponse,     parseGeminiUsage,+    encodeTurn,+    signatureForModel,   ) where  import Control.Applicative ((<|>)) import Data.Aeson   ( KeyValue ((.=)),+    Object,     Value (Object, String),     decodeStrict',     object,@@ -22,7 +25,7 @@ import Data.Aeson.KeyMap qualified as KM import Data.Aeson.Types (Pair, Parser, parseMaybe) import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding (encodeUtf8)@@ -37,20 +40,30 @@         reqModel,         reqSystem,         reqTemperature,+        reqThinking,         reqTools       ),-    ChatResponse (ChatResponse),-    ContentBlock (..),+    ContentPart (..),     LLMError (EmptyResponse),     LLMGateway,     LLMObjectResult,     LLMTextResult,+    PartBody (..),+    ProviderOpaque (..),     StreamEvent (..),+    ThinkingContent (..),+    ThinkingMode (..),     ToolCall (..),     ToolDef (toolDescription, toolName, toolParameters),     ToolResult (trContent, trName),     Turn (..),+    ImageSource (..),+    mkChatResponse,     mkToolCall,+    stripForeignOpaque,+    textPart,+    thinkingPart,+    toolCallPart,   ) import LLM.Core.Usage (Usage (..)) import Network.HTTP.Client qualified as HC@@ -71,6 +84,9 @@     (=:),   ) +geminiProviderName :: Text+geminiProviderName = "gemini"+ -- | Create a LLMGateway for the Gemini provider at generativelanguage.googleapis.com. geminiGateway :: Text -> LLMGateway geminiGateway apiKey = toGateway (geminiProvider apiKey)@@ -89,7 +105,7 @@ geminiProviderWith :: Url scheme -> Option scheme -> Text -> LLMProvider geminiProviderWith baseUrl baseOpts apiKey =   LLMProvider-    { providerName = "gemini",+    { providerName = geminiProviderName,       buildBody = const geminiBuildBody,       sendRequest = sendRequest,       sendStreamRequest = \body callback ->@@ -115,6 +131,7 @@                     "responseSchema" .= schema                   ]                     ++ ["temperature" .= t | Just t <- [r.reqTemperature]]+                    ++ thinkingConfigPairs r                 )           ]             ++ [ "system_instruction" .= object ["parts" .= [object ["text" .= sys]]]@@ -129,8 +146,6 @@   where     sendRequest body =       runReq lenientConfig $ do-        -- For non-streaming we need the model name from the body to construct the URL.-        -- We extract it from the request body JSON since the LLMProvider only passes Value.         let model = extractModel body             url =               baseUrl@@ -144,85 +159,67 @@ 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 extractModel v = fromMaybe "gemini-2.0-flash" (parseMaybe (withObject "body" (.: "_model")) v) --- | Remove the internal '_model' field before sending to the API. stripModel :: Value -> Value stripModel (Object o) = Object (KM.delete "_model" o) stripModel v = v  parseGeminiStream :: HC.BodyReader -> (StreamEvent -> IO ()) -> IO LLMTextResult parseGeminiStream reader callback = do-  blocksRef <- newIORef ([] :: [ContentBlock])+  partsRef <- newIORef ([] :: [ContentPart])   usageRef <- newIORef Nothing   readSSEEvents (HC.brRead reader) $ \sse -> do     case decodeStrict' (encodeUtf8 sse.sseData) of       Nothing -> pure ()       Just v -> do-        -- modelVersion is needed so we can later guard against replaying-        -- thought signatures into a different model than the one that-        -- produced them.         let modelVer = parseMaybe parseModelVersion v         case parseMaybe (parseChunkParts modelVer) v of           Just parts -> do-            newBlocks <- mapM (assignToolId callback) parts-            modifyIORef' blocksRef (++ newBlocks)+            newParts <- mapM (assignToolId callback) parts+            modifyIORef' partsRef (++ newParts)           Nothing -> pure ()         case parseMaybe parseUsageMetadata v of           Just u -> writeIORef usageRef (Just u)           Nothing -> pure ()-  blocks <- readIORef blocksRef+  parts <- readIORef partsRef   usage <- readIORef usageRef-  let text = T.concat [t | TextBlock t <- blocks]-  if null blocks+  if null parts     then pure $ Left EmptyResponse-    else pure $ Right (ChatResponse text blocks usage Nothing)+    else pure $ Right (mkChatResponse parts usage)   where-    assignToolId :: (StreamEvent -> IO ()) -> ContentBlock -> IO ContentBlock-    assignToolId cb (TextBlock t) = do+    assignToolId :: (StreamEvent -> IO ()) -> ContentPart -> IO ContentPart+    assignToolId cb (ContentPart (TextPart t) hint) = do       cb (StreamDelta t)-      pure (TextBlock t)-    assignToolId cb (ToolCallBlock tc) = do+      pure (textPart t) {partCacheHint = hint}+    assignToolId cb (ContentPart (ThinkingPart tc) hint) = do+      case tc.thinkingText of+        Just t | not (T.null t) -> cb (StreamReasoningDelta t)+        _ -> pure ()+      pure (thinkingPart tc) {partCacheHint = hint}+    assignToolId cb (ContentPart (ToolCallPart tc) hint) = do       tc' <- normalizeToolCallId tc       cb (StreamToolCall tc')-      pure (ToolCallBlock tc')+      pure (toolCallPart tc') {partCacheHint = hint}+    assignToolId _ (ContentPart (ImagePart src) hint) =+      pure (ContentPart (ImagePart src) hint) -    parseChunkParts :: Maybe Text -> Value -> Parser [ContentBlock]+    parseChunkParts :: Maybe Text -> Value -> Parser [ContentPart]     parseChunkParts modelVer = withObject "GeminiChunk" $ \o -> do       (cand : _) <- o .: "candidates" :: Parser [Value]       withObject         "candidate"         ( \co -> do             cont <- co .: "content"-            withObject "content" (\cco -> cco .: "parts" >>= mapM (parsePartBlock modelVer)) cont+            withObject "content" (\cco -> cco .: "parts" >>= mapM (parsePart modelVer)) cont         )         cand -    parsePartBlock :: Maybe Text -> Value -> Parser ContentBlock-    parsePartBlock modelVer = withObject "part" $ \o -> do-      mSig <- o .:? "thoughtSignature" :: Parser (Maybe Text)-      let tryText = TextBlock <$> (o .: "text")-          tryFunctionCall = do-            fc <- o .: "functionCall"-            withObject-              "functionCall"-              ( \fco -> do-                  name <- fco .: "name"-                  args <- fco .:? "args" .!= object []-                  pure $ ToolCallBlock (attachGeminiMeta modelVer mSig (mkToolCall name name args))-              )-              fc-      tryText <|> tryFunctionCall-     parseUsageMetadata :: Value -> Parser Usage     parseUsageMetadata = withObject "GeminiChunk" $ \o -> do       u <- o .: "usageMetadata"-      withObject-        "usageMetadata"-        (\uo -> Usage <$> uo .: "promptTokenCount" <*> uo .: "candidatesTokenCount" <*> pure 0)-        u+      withObject "usageMetadata" parseGeminiUsageObject u  geminiBuildBody :: ChatRequest -> Value geminiBuildBody r = object $ geminiBuildBodyPairs r@@ -240,25 +237,28 @@          | not (null r.reqTools)        ] --- | Encode a turn for Gemini. The current request model is threaded through--- so that thought signatures captured from a previous response can be--- replayed only when the receiving model matches the one that emitted them.+-- | Encode a turn for Gemini. Foreign opaque thinking / tool metadata is omitted+-- at encode time so fallbacks never replay Claude (or other) state. encodeTurn :: Text -> Turn -> [Value]-encodeTurn _ (UserTurn content) =+encodeTurn _ (UserMessage parts) =   [ object       [ "role" .= ("user" :: Text),-        "parts" .= [object ["text" .= content]]+        "parts" .= mapMaybe encodeUserPart parts       ]   ]-encodeTurn currentModel (AssistantTurn text _mReasoning calls) =+  where+    -- Cache hints are ignored on the Gemini wire; content is unchanged.+    encodeUserPart (ContentPart (TextPart t) _) = Just $ object ["text" .= t]+    encodeUserPart (ContentPart (ImagePart src) _) = Just $ encodeImagePart src+    encodeUserPart _ = Nothing+encodeTurn currentModel (AssistantMessage parts) =   [ object       [ "role" .= ("model" :: Text),-        "parts" .= (textParts ++ callParts)+        "parts" .= mapMaybe (encodeAssistantPart currentModel) cleaned       ]   ]   where-    textParts = [object ["text" .= text] | not (T.null text)]-    callParts = map (encodeFunctionCall currentModel) calls+    cleaned = map (stripForeignOpaque geminiProviderName) parts encodeTurn _ (ToolTurn results) =   [ object       [ "role" .= ("user" :: Text),@@ -266,6 +266,61 @@       ]   ] +encodeImagePart :: ImageSource -> Value+encodeImagePart (ImageUrl url) =+  object+    [ "fileData"+        .= object+          [ "mimeType" .= guessImageMimeFromUrl url,+            "fileUri" .= url+          ]+    ]+encodeImagePart (ImageBase64 mediaType data_) =+  object+    [ "inlineData"+        .= object+          [ "mimeType" .= mediaType,+            "data" .= data_+          ]+    ]++-- | Best-effort MIME guess for Gemini fileData URL parts.+guessImageMimeFromUrl :: Text -> Text+guessImageMimeFromUrl url =+  let path = T.toLower $ T.takeWhile (/= '?') url+   in case () of+        _+          | ".png" `T.isSuffixOf` path -> "image/png"+          | ".gif" `T.isSuffixOf` path -> "image/gif"+          | ".webp" `T.isSuffixOf` path -> "image/webp"+          | ".heic" `T.isSuffixOf` path -> "image/heic"+          | ".heif" `T.isSuffixOf` path -> "image/heif"+          | otherwise -> "image/jpeg"++encodeAssistantPart :: Text -> ContentPart -> Maybe Value+encodeAssistantPart _ (ContentPart (TextPart t) _)+  | T.null t = Nothing+  | otherwise = Just $ object ["text" .= t]+encodeAssistantPart currentModel (ContentPart (ThinkingPart tc) _) =+  case tc.thinkingOpaque of+    Just o+      | o.poProvider == geminiProviderName,+        modelOk currentModel o.poModel ->+          Just o.poPayload+    _ ->+      case tc.thinkingText of+        Just t+          | not (T.null t) ->+              Just $ object ["text" .= t, "thought" .= True]+        _ -> Nothing+encodeAssistantPart currentModel (ContentPart (ToolCallPart tc) _) =+  Just $ encodeFunctionCall currentModel tc+encodeAssistantPart _ (ContentPart (ImagePart _) _) = Nothing++modelOk :: Text -> Maybe Text -> Bool+modelOk _ Nothing = True+modelOk current (Just m) = modelsMatch current m+ encodeToolDef :: ToolDef -> Value encodeToolDef td =   object@@ -274,15 +329,6 @@       "parameters" .= td.toolParameters     ] --- | Encode an assistant tool call back to a Gemini @functionCall@ part.------ Gemini 2.5 thinking models attach an opaque 'thoughtSignature' to each--- function-call part. That signature must be replayed verbatim on subsequent--- requests against the same model, or the API rejects the request with a--- @function call is missing a thought_signature@ error. We stored the--- signature plus the emitting model name in 'tcProviderMeta' at parse time;--- here we re-emit it only when 'currentModel' matches, because signatures--- are bound to the model that produced them. encodeFunctionCall :: Text -> ToolCall -> Value encodeFunctionCall currentModel tc =   object $@@ -304,37 +350,78 @@           ]     ] --- | Generate a unique call ID for Gemini tool calls (which lack native IDs) normalizeToolCallId :: ToolCall -> IO ToolCall normalizeToolCallId tc = do   u <- newUnique   let callId = "call_" <> T.pack (show (hashUnique u))   pure tc {tcId = callId} -normalizeBlock :: ContentBlock -> IO ContentBlock-normalizeBlock (ToolCallBlock tc) = ToolCallBlock <$> normalizeToolCallId tc-normalizeBlock b = pure b+normalizePart :: ContentPart -> IO ContentPart+normalizePart (ContentPart (ToolCallPart tc) hint) =+  (\tc' -> (toolCallPart tc') {partCacheHint = hint}) <$> normalizeToolCallId tc+normalizePart p = pure p  genConfig :: ChatRequest -> Value genConfig r =   object $     ("maxOutputTokens" .= r.reqMaxTokens)       : ["temperature" .= t | Just t <- [r.reqTemperature]]+      ++ thinkingConfigPairs r +-- | Map 'ThinkingMode' to Gemini @thinkingConfig@.+--+-- Level strings (@low@/@medium@/@high@/@minimal@) become @thinkingLevel@+-- (Gemini 3). Numeric effort becomes @thinkingBudget@ (Gemini 2.5). Enabled+-- without effort requests dynamic budget (@-1@).+thinkingConfigPairs :: ChatRequest -> [Pair]+thinkingConfigPairs r =+  case r.reqThinking of+    Nothing -> []+    Just tm+      | not tm.tmEnabled ->+          ["thinkingConfig" .= object ["thinkingBudget" .= (0 :: Int)]]+      | Just e <- tm.tmEffort,+        e `elem` ["minimal", "low", "medium", "high"] ->+          [ "thinkingConfig"+              .= object+                [ "thinkingLevel" .= e,+                  "includeThoughts" .= True+                ]+          ]+      | Just e <- tm.tmEffort,+        Just n <- readMaybeInt e ->+          [ "thinkingConfig"+              .= object+                [ "thinkingBudget" .= n,+                  "includeThoughts" .= True+                ]+          ]+      | otherwise ->+          [ "thinkingConfig"+              .= object+                [ "thinkingBudget" .= (-1 :: Int),+                  "includeThoughts" .= True+                ]+          ]++readMaybeInt :: Text -> Maybe Int+readMaybeInt t =+  case reads (T.unpack t) of+    [(n, "")] -> Just n+    _ -> Nothing+ parseGeminiResponse :: Value -> IO LLMTextResult parseGeminiResponse v = case parseMaybe (go modelVer) v of   Nothing -> pure $ Left EmptyResponse-  Just blocks -> do-    blocks' <- mapM normalizeBlock blocks-    case blocks' of+  Just parts -> do+    parts' <- mapM normalizePart parts+    case parts' of       [] -> pure $ Left EmptyResponse-      _ ->-        let text = T.concat [t | TextBlock t <- blocks']-         in pure $ Right (ChatResponse text blocks' (parseGeminiUsage v) Nothing)+      _ -> pure $ Right (mkChatResponse parts' (parseGeminiUsage v))   where     modelVer = parseMaybe parseModelVersion v -    go :: Maybe Text -> Value -> Parser [ContentBlock]+    go :: Maybe Text -> Value -> Parser [ContentPart]     go mv = withObject "GeminiResponse" $ \o -> do       (cand : _) <- o .: "candidates" :: Parser [Value]       withObject@@ -344,72 +431,100 @@             withObject               "content"               ( \cco -> do-                  parts <- cco .: "parts" :: Parser [Value]-                  mapM (parsePart mv) parts+                  ps <- cco .: "parts" :: Parser [Value]+                  mapM (parsePart mv) ps               )               cont         )         cand -    parsePart :: Maybe Text -> Value -> Parser ContentBlock-    parsePart mv = withObject "part" $ \o -> do-      mSig <- o .:? "thoughtSignature" :: Parser (Maybe Text)-      let tryText = TextBlock <$> (o .: "text")-          tryFunctionCall = do-            fc <- o .: "functionCall"-            withObject-              "functionCall"-              ( \fco -> do-                  name <- fco .: "name"-                  args <- fco .:? "args" .!= object []-                  -- Gemini doesn't provide a call id; use the function name.-                  -- normalizeBlock replaces it with a unique id later.-                  pure $ ToolCallBlock (attachGeminiMeta mv mSig (mkToolCall name name args))-              )-              fc-      tryText <|> tryFunctionCall+parsePart :: Maybe Text -> Value -> Parser ContentPart+parsePart mv = withObject "part" $ \o -> do+  mSig <- o .:? "thoughtSignature" :: Parser (Maybe Text)+  mThought <- o .:? "thought" :: Parser (Maybe Bool)+  let tryText = do+        t <- o .: "text"+        if mThought == Just True+          then do+            let opaque =+                  case mSig of+                    Nothing -> Nothing+                    Just sig ->+                      Just+                        ProviderOpaque+                          { poProvider = geminiProviderName,+                            poModel = mv,+                            poPayload =+                              object $+                                ["text" .= t, "thought" .= True]+                                  ++ ["thoughtSignature" .= sig]+                          }+            pure $ thinkingPart (ThinkingContent (Just t) opaque)+          else+            -- Plain text; preserve signature as opaque thinking sidecar only when+            -- required — Gemini 2.5 may put the signature on the first part.+            pure $ textPart t+      tryFunctionCall = do+        fc <- o .: "functionCall"+        withObject+          "functionCall"+          ( \fco -> do+              name <- fco .: "name"+              args <- fco .:? "args" .!= object []+              pure $ toolCallPart (attachGeminiMeta mv mSig (mkToolCall name name args))+          )+          fc+  tryFunctionCall <|> tryText --- | Extract @modelVersion@ from a Gemini response or chunk. This is the--- canonical model name (e.g. @gemini-2.5-pro-001@) used as the binding key--- for thought signatures. parseModelVersion :: Value -> Parser Text parseModelVersion = withObject "GeminiResponse" (.: "modelVersion") --- | Build the provider-metadata bag stored on a 'ToolCall' for Gemini.--- Returns the original tool call unchanged when there's no signature to--- preserve, so we don't pollute logs with empty metadata. attachGeminiMeta :: Maybe Text -> Maybe Text -> ToolCall -> ToolCall attachGeminiMeta _ Nothing tc = tc attachGeminiMeta mModel (Just sig) tc =-  tc {tcProviderMeta = Just (object (("thoughtSignature" .= sig) : modelField))}-  where-    modelField = ["model" .= m | Just m <- [mModel]]+  tc+    { tcProviderMeta =+        Just+          ProviderOpaque+            { poProvider = geminiProviderName,+              poModel = mModel,+              poPayload = object ["thoughtSignature" .= sig]+            }+    }  -- | Pull a thought signature out of 'tcProviderMeta' iff it was emitted by--- the model we're currently calling. Cross-model replay is unsafe — Gemini--- treats the signature as an opaque per-model token and will reject it.-signatureForModel :: Text -> Maybe Value -> Maybe Text-signatureForModel currentModel (Just (Object o)) = do-  String sig <- KM.lookup "thoughtSignature" o-  case KM.lookup "model" o of-    Just (String m) | not (modelsMatch currentModel m) -> Nothing-    _ -> Just sig+-- the model we're currently calling.+signatureForModel :: Text -> Maybe ProviderOpaque -> Maybe Text+signatureForModel currentModel (Just o)+  | o.poProvider == geminiProviderName,+    modelOk currentModel o.poModel =+      case o.poPayload of+        Object m | Just (String sig) <- KM.lookup "thoughtSignature" m -> Just sig+        _ -> Nothing signatureForModel _ _ = Nothing --- | A signature emitted by @gemini-2.5-pro-001@ is safe to replay against--- @gemini-2.5-pro@ (and vice-versa): the response carries the resolved--- version string while requests typically use an alias. We accept either--- direction being a prefix of the other. modelsMatch :: Text -> Text -> Bool modelsMatch a b = a == b || T.isPrefixOf a b || T.isPrefixOf b a  parseGeminiUsage :: Value -> Maybe Usage parseGeminiUsage = parseMaybe $ withObject "GeminiResponse" $ \o -> do   u <- o .: "usageMetadata"-  withObject-    "usageMetadata"-    (\uo -> Usage <$> uo .: "promptTokenCount" <*> uo .: "candidatesTokenCount" <*> pure 0)-    u+  withObject "usageMetadata" parseGeminiUsageObject u++-- | Gemini @promptTokenCount@ already includes cached content tokens.+parseGeminiUsageObject :: Object -> Parser Usage+parseGeminiUsageObject uo = do+  prompt <- uo .: "promptTokenCount"+  candidates <- uo .: "candidatesTokenCount"+  cacheRead <- uo .:? "cachedContentTokenCount" .!= 0+  pure+    Usage+      { usageInputTokens = prompt,+        usageOutputTokens = candidates,+        usageCacheReadTokens = cacheRead,+        usageCacheCreationTokens = 0,+        usageTotalCost = 0+      }  parseGeminiObjectResponse :: Value -> IO LLMObjectResult parseGeminiObjectResponse v = case parseMaybe go v of
src/LLM/Providers/OpenAI.hs view
@@ -24,6 +24,7 @@     decodeStrict',     encode,     object,+    toJSON,     withObject,     (.!=),     (.:),@@ -33,7 +34,8 @@ import Data.ByteString.Lazy qualified as BSL import Data.Foldable (forM_) import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)-import Data.Maybe (fromMaybe, isNothing)+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import Control.Applicative ((<|>)) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding (decodeUtf8, encodeUtf8)@@ -47,21 +49,30 @@         reqModel,         reqSystem,         reqTemperature,+        reqThinking,         reqTools       ),-    ChatResponse (ChatResponse, respContent, respReasoning, respText, respUsage),-    ContentBlock (..),+    ContentPart (..),     LLMError (EmptyResponse),     LLMGateway,     LLMTextResult,     MessageEncodeOptions (..),+    PartBody (..),     StreamEvent (..),+    ThinkingContent (..),+    ThinkingMode (..),     ToolCall (..),-    mkToolCall,     ToolDef (toolDescription, toolName, toolParameters),     ToolResult (trCallId, trContent),     Turn (..),+    ImageSource (..),     defaultMessageEncodeOptions,+    mkChatResponse,+    mkToolCall,+    stripForeignOpaque,+    textPart,+    thinkingPart,+    toolCallPart,   ) import LLM.Core.Usage (Usage (..)) import Network.HTTP.Client qualified as HC@@ -81,6 +92,9 @@     (/:),   ) +openAIProviderName :: Text+openAIProviderName = "openai"+ -- | Create an OpenAI client for api.openai.com. Takes the API key as a parameter. openAIGateway :: Text -> LLMGateway openAIGateway apiKey = toGateway $ openAIProvider apiKey@@ -164,36 +178,85 @@     "messages" .= buildMessages defaultMessageEncodeOptions r   ]     ++ ["temperature" .= t | Just t <- [r.reqTemperature]]+    ++ effortPairs r     ++ ["tools" .= map encodeToolDef r.reqTools | not (null r.reqTools)]     ++ ["stream" .= True | stream]     ++ ["stream_options" .= object ["include_usage" .= True] | stream] +-- | Chat Completions reasoning_effort, when the catalog requests thinking.+effortPairs :: ChatRequest -> [Pair]+effortPairs r =+  case r.reqThinking of+    Just tm+      | tm.tmEnabled,+        Just e <- tm.tmEffort ->+          ["reasoning_effort" .= e]+    _ -> []+ buildMessages :: MessageEncodeOptions -> ChatRequest -> [Value] buildMessages opts r =   maybe [] (\sys -> [object ["role" .= ("system" :: Text), "content" .= sys]]) r.reqSystem     ++ concatMap (encodeTurn opts) r.reqConversation  encodeTurn :: MessageEncodeOptions -> Turn -> [Value]-encodeTurn _ (UserTurn content) =+encodeTurn _ (UserMessage parts) =   [ object       [ "role" .= ("user" :: Text),-        "content" .= content+        "content" .= encodeUserContent parts       ]   ]-encodeTurn opts (AssistantTurn text mReasoning calls) =-  [ object $-      ["role" .= ("assistant" :: Text)]-        ++ ["content" .= text | not (T.null text)]-        ++ [ "reasoning_content" .= rc-             | opts.meoIncludeReasoning,-               Just rc <- [mReasoning],-               not (T.null rc)-           ]-        ++ ["tool_calls" .= map encodeToolCall calls | not (null calls)]-  ]+encodeTurn opts (AssistantMessage parts) =+  let cleaned = map (stripForeignOpaque openAIProviderName) parts+      -- Cache hints are ignored on the OpenAI wire; content is unchanged.+      text = T.concat [t | ContentPart (TextPart t) _ <- cleaned]+      mReasoning =+        listToMaybe+          [ t+            | ContentPart (ThinkingPart tc) _ <- cleaned,+              Just t <- [tc.thinkingText],+              not (T.null t)+          ]+      calls = [tc | ContentPart (ToolCallPart tc) _ <- cleaned]+   in [ object $+          ["role" .= ("assistant" :: Text)]+            ++ ["content" .= text | not (T.null text)]+            ++ [ "reasoning_content" .= rc+                 | opts.meoIncludeReasoning,+                   Just rc <- [mReasoning]+               ]+            ++ ["tool_calls" .= map encodeToolCall calls | not (null calls)]+      ] encodeTurn _ (ToolTurn results) =   map encodeToolResult results +-- | Single text stays a string (recorded-fixture compatible); mixed or image+-- content uses the OpenAI multimodal content-part array.+-- Cache hints are omitted from the wire JSON.+encodeUserContent :: [ContentPart] -> Value+encodeUserContent [ContentPart (TextPart t) _] = String t+encodeUserContent parts = toJSON (mapMaybe encodeUserPart parts)+  where+    encodeUserPart (ContentPart (TextPart t) _) =+      Just $ object ["type" .= ("text" :: Text), "text" .= t]+    encodeUserPart (ContentPart (ImagePart src) _) =+      Just $ encodeImagePart src+    encodeUserPart _ = Nothing++encodeImagePart :: ImageSource -> Value+encodeImagePart (ImageUrl url) =+  object+    [ "type" .= ("image_url" :: Text),+      "image_url" .= object ["url" .= url]+    ]+encodeImagePart (ImageBase64 mediaType data_) =+  object+    [ "type" .= ("image_url" :: Text),+      "image_url"+        .= object+          [ "url" .= ("data:" <> mediaType <> ";base64," <> data_)+          ]+    ]+ encodeToolDef :: ToolDef -> Value encodeToolDef td =   object@@ -231,20 +294,12 @@ parseOpenAIResponse :: Value -> LLMTextResult parseOpenAIResponse v = case parseMaybe go v of   Nothing -> Left EmptyResponse-  Just (mReasoning, blocks) ->-    if null blocks && isNothing mReasoning+  Just parts ->+    if null parts       then Left EmptyResponse-      else-        let text = T.concat [t | TextBlock t <- blocks]-         in Right-              ChatResponse-                { respText = text,-                  respContent = blocks,-                  respUsage = parseOpenAIUsage v,-                  respReasoning = mReasoning-                }+      else Right (mkChatResponse parts (parseOpenAIUsage v))   where-    go :: Value -> Parser (Maybe Text, [ContentBlock])+    go :: Value -> Parser [ContentPart]     go = withObject "OpenAIResponse" $ \o -> do       (choice : _) <- o .: "choices" :: Parser [Value]       withObject@@ -255,18 +310,23 @@         )         choice -    parseMessage :: Object -> Parser (Maybe Text, [ContentBlock])+    parseMessage :: Object -> Parser [ContentPart]     parseMessage mo = do       mReasoning <- mo .:? "reasoning_content" :: Parser (Maybe Text)       contentBlocks <- do         mc <- mo .:? "content" :: Parser (Maybe Text)-        pure [TextBlock t | Just t <- [mc], not (T.null t)]+        pure [textPart t | Just t <- [mc], not (T.null t)]       toolBlocks <- do         tcs <- mo .:? "tool_calls" .!= [] :: Parser [Value]         mapM parseToolCall tcs-      pure (mReasoning, contentBlocks ++ toolBlocks)+      let thinkingBlocks =+            [ thinkingPart (ThinkingContent (Just rc) Nothing)+              | Just rc <- [mReasoning],+                not (T.null rc)+            ]+      pure (thinkingBlocks ++ contentBlocks ++ toolBlocks) -    parseToolCall :: Value -> Parser ContentBlock+    parseToolCall :: Value -> Parser ContentPart     parseToolCall = withObject "tool_call" $ \tc -> do       cid <- tc .: "id"       fn <- tc .: "function"@@ -278,23 +338,56 @@             let args = case decodeStrict' (encodeUtf8 argsStr) of                   Just v' -> v'                   Nothing -> String argsStr-            pure $ ToolCallBlock (mkToolCall cid name args)+            pure $ toolCallPart (mkToolCall cid name args)         )         fn  parseOpenAIUsage :: Value -> Maybe Usage parseOpenAIUsage = parseMaybe $ withObject "OpenAIResponse" $ \o -> do   u <- o .: "usage"-  withObject "usage" (\uo -> Usage <$> uo .: "prompt_tokens" <*> uo .: "completion_tokens" <*> pure 0) u+  withObject "usage" parseOpenAIUsageObject u +-- | Shared OpenAI Chat Completions / DeepSeek usage shape.+--+-- @prompt_tokens@ is already the total input (including cache hits). Cache+-- reads come from DeepSeek's @prompt_cache_hit_tokens@ or OpenAI's+-- @prompt_tokens_details.cached_tokens@. Optional @cache_write_tokens@ map to+-- creation counters.+parseOpenAIUsageObject :: Object -> Parser Usage+parseOpenAIUsageObject uo = do+  prompt <- uo .: "prompt_tokens"+  completion <- uo .: "completion_tokens"+  cacheReadDeepSeek <- uo .:? "prompt_cache_hit_tokens"+  details <- uo .:? "prompt_tokens_details" :: Parser (Maybe Value)+  (cacheReadDetails, cacheWrite) <- case details of+    Nothing -> pure (Nothing, Nothing)+    Just d ->+      withObject+        "prompt_tokens_details"+        ( \dto ->+            (,)+              <$> dto .:? "cached_tokens"+              <*> dto .:? "cache_write_tokens"+        )+        d+  let cacheRead = fromMaybe 0 (cacheReadDeepSeek <|> cacheReadDetails)+      cacheCreate = fromMaybe 0 cacheWrite+  pure+    Usage+      { usageInputTokens = prompt,+        usageOutputTokens = completion,+        usageCacheReadTokens = cacheRead,+        usageCacheCreationTokens = cacheCreate,+        usageTotalCost = 0+      }+ -- Streaming  parseOpenAIStream :: HC.BodyReader -> (StreamEvent -> IO ()) -> IO LLMTextResult parseOpenAIStream reader callback = do-  blocksRef <- newIORef ([] :: [ContentBlock])+  blocksRef <- newIORef ([] :: [ContentPart])   reasoningRef <- newIORef Nothing   usageRef <- newIORef Nothing-  -- Track in-flight tool calls: index -> (id, name, accumulated args)   toolAccRef <- newIORef ([] :: [(Int, Text, Text, Text)])   readSSEEvents (HC.brRead reader) $ \sse -> do     let raw = sse.sseData@@ -303,33 +396,27 @@       else case decodeStrict' (encodeUtf8 raw) of         Nothing -> pure ()         Just v -> do-          -- Reasoning deltas           case parseMaybe parseStreamReasoningDelta v of             Just (Just txt) | not (T.null txt) -> do               modifyIORef' reasoningRef (Just . maybe txt (<> txt))               callback (StreamReasoningDelta txt)             _ -> pure ()-          -- Text deltas           case parseMaybe parseStreamTextDelta v of             Just txt | not (T.null txt) -> do-              modifyIORef' blocksRef (TextBlock txt :)+              modifyIORef' blocksRef (textPart txt :)               callback (StreamDelta txt)             _ -> pure ()-          -- Tool call deltas           case parseMaybe parseStreamToolDelta v of             Just (idx, mId, mName, argChunk) -> do               modifyIORef' toolAccRef $ \acc ->                 case lookup idx [(i, (i, cid, n, a)) | (i, cid, n, a) <- acc] of                   Nothing ->-                    -- New tool call                     let cid = fromMaybe "" mId                         n = fromMaybe "" mName                      in acc ++ [(idx, cid, n, argChunk)]                   Just (_, cid, n, a) ->-                    -- Accumulate arguments                     [(if i == idx then (i, cid, n, a <> argChunk) else entry) | entry@(i, _, _, _) <- acc]             Nothing -> pure ()-          -- Finish reason: emit accumulated tool calls           case parseMaybe parseFinishReason v of             Just "tool_calls" -> do               tools <- readIORef toolAccRef@@ -338,38 +425,33 @@                       Just a -> a                       Nothing -> String argsStr                     tc = mkToolCall cid name args-                modifyIORef' blocksRef (ToolCallBlock tc :)+                modifyIORef' blocksRef (toolCallPart tc :)                 callback (StreamToolCall tc)               writeIORef toolAccRef []             _ -> pure ()-          -- Usage (in the final chunk when stream_options.include_usage is set)           case parseMaybe parseStreamUsage v of             Just u -> writeIORef usageRef (Just u)             Nothing -> pure ()-  -- Flush any remaining tool calls   tools <- readIORef toolAccRef   forM_ tools $ \(_, cid, name, argsStr) -> do     let args = case decodeStrict' (encodeUtf8 argsStr) of           Just a -> a           Nothing -> String argsStr         tc = mkToolCall cid name args-    modifyIORef' blocksRef (ToolCallBlock tc :)+    modifyIORef' blocksRef (toolCallPart tc :)     callback (StreamToolCall tc)-  blocks <- reverse <$> readIORef blocksRef+  textBlocks <- reverse <$> readIORef blocksRef   mReasoning <- readIORef reasoningRef   usage <- readIORef usageRef-  let text = T.concat [t | TextBlock t <- blocks]-  if null blocks && isNothing mReasoning+  let thinkingBlocks =+        [ thinkingPart (ThinkingContent (Just rc) Nothing)+          | Just rc <- [mReasoning],+            not (T.null rc)+        ]+      parts = thinkingBlocks ++ textBlocks+  if null parts     then pure $ Left EmptyResponse-    else-      pure $-        Right-          ChatResponse-            { respText = text,-              respContent = blocks,-              respUsage = usage,-              respReasoning = mReasoning-            }+    else pure $ Right (mkChatResponse parts usage)  parseStreamReasoningDelta :: Value -> Parser (Maybe Text) parseStreamReasoningDelta = withObject "chunk" $ \o -> do@@ -421,4 +503,4 @@ parseStreamUsage :: Value -> Parser Usage parseStreamUsage = withObject "chunk" $ \o -> do   u <- o .: "usage"-  withObject "usage" (\uo -> Usage <$> uo .: "prompt_tokens" <*> uo .: "completion_tokens" <*> pure 0) u+  withObject "usage" parseOpenAIUsageObject u
test/LLM/ChatSpec.hs view
@@ -3,11 +3,12 @@ module LLM.ChatSpec (spec) where  import Data.Aeson (object, (.=))+import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import Data.Map qualified as Map import Data.Text (Text) import Heptapod (generate) import LLM.Agent.Events (noEventObserver)-import LLM.Agent.Generate (generateText)+import LLM.Agent.Generate (generateText, streamText) import LLM.Agent.Types   ( Agent (..),     RuntimeArgs (..),@@ -16,21 +17,28 @@   ) import LLM.Core.Abort (AbortSignal, abort, newAbortSignal) import LLM.Core.Types-  ( ChatRequest (..),+  ( CacheHint (..),+    ChatRequest (..),     ChatResponse (..),-    ContentBlock (TextBlock, ToolCallBlock),+    ContentPart (..),+    PartBody (..),+    textPart, toolCallPart, pattern UserTurn,+    Turn (..),+    cacheEphemeral,+    imageUrlPart,     LLMError (..),     LLMGateway (..),     LLMHooks (..),     ToolDef (ToolDef, toolDescription, toolName, toolParameters, toolReadonly),-    Turn (..),     mkToolCall,   )-import LLM.Core.Usage (PricingInfo (..), Usage (Usage))+import LLM.Core.Usage (PricingInfo (..), defaultPricingInfo, mkUsage) import LLM.Generate.Logger (noHooks) import LLM.Generate.ModelConfig-  ( ModelConfig (..),+  ( ModelCapabilities (..),+    ModelConfig (..),     ModelWithFallbacks (..),+    defaultModelCapabilities,   ) import LLM.Generate.Types   ( GenerateError (..),@@ -43,6 +51,7 @@     expectationFailure,     it,     shouldBe,+    shouldSatisfy,   )  -- | A mock gateway that returns a fixed response@@ -72,10 +81,10 @@     { gwName = "mock-tool",       gwGenerateText = \_ req ->         if any isToolTurn req.reqConversation-          then pure $ Right (ChatResponse "The weather is sunny." [TextBlock "The weather is sunny."] (Just (Usage 80 15 0)) Nothing)+          then pure $ Right (ChatResponse "The weather is sunny." [textPart "The weather is sunny."] (Just (mkUsage 80 15)) Nothing)           else             let tc = mkToolCall "call_1" "get_weather" (object ["location" .= ("London" :: Text)])-             in pure $ Right (ChatResponse "" [ToolCallBlock tc] (Just (Usage 50 10 0)) Nothing),+             in pure $ Right (ChatResponse "" [toolCallPart tc] (Just (mkUsage 50 10)) Nothing),       gwStreamText = \_ _ _ -> pure $ Right (ChatResponse "" [] Nothing Nothing),       gwGenerateObject = \_ _ _ -> pure $ Right (object [], Nothing)     }@@ -84,7 +93,7 @@     isToolTurn _ = False  zeroPricing :: PricingInfo-zeroPricing = PricingInfo 0 0+zeroPricing = defaultPricingInfo 0 0  -- | Wrap a gateway in a ModelConfig with test defaults mockModel :: LLMGateway -> ModelConfig@@ -96,6 +105,7 @@       mcMaxTokens = 1024,       mcTemperature = Nothing,       mcThinking = Nothing,+      mcCapabilities = defaultModelCapabilities,       mcRequestTimeout = Nothing,       mcThrottleDelay = Nothing,       mcRetryCount = 0,@@ -154,19 +164,64 @@   rt <- mkRuntime mSig   generateText agent models toolMap rt turns +runStreamGenerate ::+  Agent ->+  ModelWithFallbacks ->+  ToolMap Text ->+  Maybe AbortSignal ->+  [Turn] ->+  IO (Either GenerateErrorResult GenerateTextResult)+runStreamGenerate agent models toolMap mSig turns = do+  rt <- mkRuntime mSig+  streamText (\_ -> pure ()) agent models toolMap rt turns++-- | Gateway that records each request conversation, then behaves like 'mockToolGateway'.+capturingToolGateway :: IORef [[Turn]] -> LLMGateway+capturingToolGateway ref =+  let respond req = do+        modifyIORef' ref (req.reqConversation :)+        if any isToolTurn req.reqConversation+          then pure $ Right (ChatResponse "The weather is sunny." [textPart "The weather is sunny."] (Just (mkUsage 80 15)) Nothing)+          else+            let tc = mkToolCall "call_1" "get_weather" (object ["location" .= ("London" :: Text)])+             in pure $ Right (ChatResponse "" [toolCallPart tc] (Just (mkUsage 50 10)) Nothing)+   in LLMGateway+        { gwName = "mock-tool-capture",+          gwGenerateText = \_ -> respond,+          gwStreamText = \_ req _onEvent -> respond req,+          gwGenerateObject = \_ _ _ -> pure $ Right (object [], Nothing)+        }+  where+    isToolTurn (ToolTurn _) = True+    isToolTurn _ = False++hasCachedUserPrefix :: [Turn] -> Bool+hasCachedUserPrefix =+  any+    ( \case+        UserMessage parts ->+          any+            ( \case+                ContentPart (TextPart _) (Just CacheEphemeral) -> True+                _ -> False+            )+            parts+        _ -> False+    )+ spec :: Spec spec = describe "Chat" $ do   let toolMap = Map.fromList [("get_weather", weatherTool)]   describe "generateText" $ do     it "returns text for a simple response" $ do-      let gw = mockGateway (ChatResponse "Hi there!" [TextBlock "Hi there!"] (Just (Usage 10 5 0)) Nothing)+      let gw = mockGateway (ChatResponse "Hi there!" [textPart "Hi there!"] (Just (mkUsage 10 5)) Nothing)           models = ModelWithFallbacks (mockModel gw) []       result <- runGenerate defaultAgent models toolMap Nothing [UserTurn "hello"]       case result of         Right r -> do           r.gtrText `shouldBe` "Hi there!"-          length r.gtrNewMessages `shouldBe` 1 -- AssistantTurn-          r.gtrUsage `shouldBe` Usage 10 5 0+          length r.gtrNewMessages `shouldBe` 1 -- assistantTurn+          r.gtrUsage `shouldBe` mkUsage 10 5         Left err -> expectationFailure $ show err      it "propagates errors" $ do@@ -184,18 +239,37 @@       case result of         Right r -> do           r.gtrText `shouldBe` "The weather is sunny."-          -- AssistantTurn(tool call) + ToolTurn + AssistantTurn(final)+          -- assistantTurn(tool call) + ToolTurn + assistantTurn(final)           length r.gtrNewMessages `shouldBe` 3-          r.gtrUsage `shouldBe` Usage 130 25 0 -- 50+80 input, 10+15 output+          r.gtrUsage `shouldBe` mkUsage 130 25 -- 50+80 input, 10+15 output         Left err -> expectationFailure $ show err +    it "preserves cache hints on user parts across non-streaming tool rounds" $ do+      ref <- newIORef []+      let agent = defaultAgent {agTools = ["get_weather"]}+          models = ModelWithFallbacks (mockModel (capturingToolGateway ref)) []+          msgs =+            [ UserMessage+                [ cacheEphemeral (textPart "static system-like context"),+                  textPart "weather in london?"+                ]+            ]+      result <- runGenerate agent models toolMap Nothing msgs+      case result of+        Right r -> do+          r.gtrText `shouldBe` "The weather is sunny."+          convs <- reverse <$> readIORef ref+          length convs `shouldBe` 2+          mapM_ (`shouldSatisfy` hasCachedUserPrefix) convs+        Left err -> expectationFailure $ show err+     it "respects maxToolRounds" $ do       let infiniteToolGateway =             LLMGateway               { gwName = "mock-infinite",                 gwGenerateText = \_ _ ->                   let tc = mkToolCall "call_1" "get_weather" (object [])-                   in pure $ Right (ChatResponse "" [ToolCallBlock tc] Nothing Nothing),+                   in pure $ Right (ChatResponse "" [toolCallPart tc] Nothing Nothing),                 gwStreamText = \_ _ _ -> pure $ Right (ChatResponse "" [] Nothing Nothing),                 gwGenerateObject = \_ _ _ -> pure $ Right (object [], Nothing)               }@@ -208,7 +282,7 @@      it "falls back to next model on retryable error" $ do       let failGw = mockErrorGateway (HttpError 503 "service unavailable")-          okGw = mockGateway (ChatResponse "Fallback worked!" [TextBlock "Fallback worked!"] (Just (Usage 10 5 0)) Nothing)+          okGw = mockGateway (ChatResponse "Fallback worked!" [textPart "Fallback worked!"] (Just (mkUsage 10 5)) Nothing)           models = ModelWithFallbacks (mockModel failGw) [mockModel okGw]       result <- runGenerate defaultAgent models toolMap Nothing [UserTurn "hello"]       case result of@@ -217,13 +291,44 @@      it "falls back on non-retryable error too" $ do       let failGw = mockErrorGateway (HttpError 400 "bad request")-          okGw = mockGateway (ChatResponse "Fallback worked!" [TextBlock "Fallback worked!"] (Just (Usage 10 5 0)) Nothing)+          okGw = mockGateway (ChatResponse "Fallback worked!" [textPart "Fallback worked!"] (Just (mkUsage 10 5)) Nothing)           models = ModelWithFallbacks (mockModel failGw) [mockModel okGw]       result <- runGenerate defaultAgent models toolMap Nothing [UserTurn "hello"]       case result of         Right r -> r.gtrText `shouldBe` "Fallback worked!"         Left err -> expectationFailure $ "Expected fallback success, got: " <> show err +    it "skips a non-vision model and succeeds with a vision-capable fallback" $ do+      let boomGw =+            LLMGateway+              { gwName = "should-not-run",+                gwGenerateText = \_ _ -> pure (Left (HttpError 500 "should not be called")),+                gwStreamText = \_ _ _ -> pure (Left (HttpError 500 "should not be called")),+                gwGenerateObject = \_ _ _ -> pure (Left (HttpError 500 "should not be called"))+              }+          okGw = mockGateway (ChatResponse "I see a cat." [textPart "I see a cat."] (Just (mkUsage 10 5)) Nothing)+          noVision = mockModel boomGw+          withVision =+            (mockModel okGw)+              { mcCapabilities = defaultModelCapabilities {capVision = True}+              }+          models = ModelWithFallbacks noVision [withVision]+          msgs = [UserMessage [imageUrlPart "https://example.com/cat.png", textPart "what is this?"]]+      result <- runGenerate defaultAgent models toolMap Nothing msgs+      case result of+        Right r -> r.gtrText `shouldBe` "I see a cat."+        Left err -> expectationFailure $ "Expected vision fallback success, got: " <> show err++    it "returns UnsupportedCapability when no vision-capable model remains" $ do+      let noVision1 = mockModel (mockErrorGateway (HttpError 500 "unused"))+          noVision2 = mockModel (mockErrorGateway (HttpError 500 "unused"))+          models = ModelWithFallbacks noVision1 [noVision2]+          msgs = [UserMessage [imageUrlPart "https://example.com/cat.png", textPart "what?"]]+      result <- runGenerate defaultAgent models toolMap Nothing msgs+      case result of+        Left GenerateErrorResult {gerError = GErrLLM (UnsupportedCapability _)} -> pure ()+        other -> expectationFailure $ "Expected UnsupportedCapability, got: " <> show other+     it "returns error from last model when all fail" $ do       let failGw1 = mockErrorGateway (HttpError 503 "service unavailable")           failGw2 = mockErrorGateway (HttpError 400 "bad request")@@ -234,7 +339,7 @@         other -> expectationFailure $ "Expected HttpError 400 from last model, got: " <> show other      it "returns Aborted when signal is fired before the call" $ do-      let gw = mockGateway (ChatResponse "Hi!" [TextBlock "Hi!"] Nothing Nothing)+      let gw = mockGateway (ChatResponse "Hi!" [textPart "Hi!"] Nothing Nothing)           models = ModelWithFallbacks (mockModel gw) []       sig <- newAbortSignal       abort sig@@ -264,7 +369,7 @@                 gwGenerateText = \_ _ ->                   let tc1 = mkToolCall "c1" "slow" (object [])                       tc2 = mkToolCall "c2" "slow" (object [])-                   in pure $ Right (ChatResponse "" [ToolCallBlock tc1, ToolCallBlock tc2] Nothing Nothing),+                   in pure $ Right (ChatResponse "" [toolCallPart tc1, toolCallPart tc2] Nothing Nothing),                 gwStreamText = \_ _ _ -> pure $ Right (ChatResponse "" [] Nothing Nothing),                 gwGenerateObject = \_ _ _ -> pure $ Right (object [], Nothing)               }@@ -277,8 +382,8 @@         other -> expectationFailure $ "Expected GErrAborted during tools, got: " <> show other      it "does not fall back on Aborted" $ do-      let gw = mockGateway (ChatResponse "Hi!" [TextBlock "Hi!"] Nothing Nothing)-          okGw = mockGateway (ChatResponse "Fallback" [TextBlock "Fallback"] Nothing Nothing)+      let gw = mockGateway (ChatResponse "Hi!" [textPart "Hi!"] Nothing Nothing)+          okGw = mockGateway (ChatResponse "Fallback" [textPart "Fallback"] Nothing Nothing)           models = ModelWithFallbacks (mockModel gw) [mockModel okGw]       sig <- newAbortSignal       abort sig@@ -286,3 +391,23 @@       case result of         Left GenerateErrorResult {gerError = GErrAborted} -> pure ()         other -> expectationFailure $ "Expected GErrAborted (no fallback), got: " <> show other++  describe "streamText" $ do+    it "preserves cache hints on user parts across streaming tool rounds" $ do+      ref <- newIORef []+      let agent = defaultAgent {agTools = ["get_weather"]}+          models = ModelWithFallbacks (mockModel (capturingToolGateway ref)) []+          msgs =+            [ UserMessage+                [ cacheEphemeral (textPart "static system-like context"),+                  textPart "weather in london?"+                ]+            ]+      result <- runStreamGenerate agent models toolMap Nothing msgs+      case result of+        Right r -> do+          r.gtrText `shouldBe` "The weather is sunny."+          convs <- reverse <$> readIORef ref+          length convs `shouldBe` 2+          mapM_ (`shouldSatisfy` hasCachedUserPrefix) convs+        Left err -> expectationFailure $ show err
test/LLM/ClaudeSpec.hs view
@@ -2,20 +2,58 @@  module LLM.ClaudeSpec (spec) where -import Data.Aeson (eitherDecodeFileStrict')+import Data.Aeson+  ( Value (Array, Number, Object, String),+    eitherDecodeFileStrict',+    object,+    (.=),+  )+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Maybe (mapMaybe)+import Data.Scientific (toBoundedInteger, toRealFloat)+import Data.Text (Text)+import Data.Vector qualified as V import LLM.Core.Types-  ( ChatResponse (respText),-    ToolCall (tcId, tcName),+  ( ChatRequest (..),+    ChatResponse (respContent, respReasoning, respText),+    ContentPart (..),+    PartBody (..),+    ProviderOpaque (..),+    StreamEvent (..),+    ThinkingContent (..),+    ThinkingMode (..),+    ToolCall (..),+    ToolResult (..),+    Turn (..),+    mkToolCall,+    textPart,+    thinkingPart,+    toolCallPart,+    imageUrlPart,+    imageBase64Part,+    cacheEphemeral,+    pattern UserTurn,   )-import LLM.Core.Usage (Usage (Usage))+import LLM.Core.Usage (Usage (..), mkUsage) import LLM.Core.Utils (getToolCalls, hasToolCalls)-import LLM.Providers.Claude (parseClaudeResponse, parseClaudeUsage)+import LLM.Providers.Claude+  ( claudeBuildBody,+    encodeTurn,+    effortToBudgetTokens,+    parseClaudeResponse,+    parseClaudeStream,+    parseClaudeUsage,+  ) import Test.Hspec   ( Spec,     describe,     expectationFailure,     it,     shouldBe,+    shouldContain,   )  spec :: Spec@@ -40,11 +78,337 @@           tc.tcId `shouldBe` "toolu_01A09q90qw90lq917835lq9"         Left err -> expectationFailure $ "Parse failed: " <> show err +    it "preserves thinking -> text -> tool_use order and opaque signature" $ do+      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-thinking-tool-use.json"+      case parseClaudeResponse val of+        Right resp -> do+          resp.respReasoning `shouldBe` Just "I should call the weather tool."+          resp.respText `shouldBe` "Checking the weather."+          case resp.respContent of+            [ ContentPart (ThinkingPart tc) Nothing,+              ContentPart (TextPart "Checking the weather.") Nothing,+              ContentPart (ToolCallPart tool) Nothing+              ] -> do+                tc.thinkingText `shouldBe` Just "I should call the weather tool."+                case tc.thinkingOpaque of+                  Just o -> do+                    o.poProvider `shouldBe` "claude"+                    o.poModel `shouldBe` Just "claude-haiku-4-5-20251001"+                    lookupText "signature" o.poPayload `shouldBe` Just "sig_thinking_abc123"+                  Nothing -> expectationFailure "expected thinking opaque"+                tool.tcId `shouldBe` "toolu_weather_1"+            other -> expectationFailure $ "unexpected parts: " <> show other+        Left err -> expectationFailure $ "Parse failed: " <> show err++  describe "parseClaudeStream" $ do+    it "preserves thinking -> text -> tool_use order and opaque signature" $ do+      sse <- BS.readFile "test/fixtures/claude-thinking-tool-use.sse"+      eventsRef <- newIORef ([] :: [StreamEvent])+      reader <- mkBodyReader sse+      result <- parseClaudeStream "claude-haiku-4-5-20251001" reader $ \ev ->+        modifyIORef' eventsRef (ev :)+      case result of+        Right resp -> do+          resp.respReasoning `shouldBe` Just "I should call the weather tool."+          resp.respText `shouldBe` "Checking the weather."+          case resp.respContent of+            [ ContentPart (ThinkingPart tc) Nothing,+              ContentPart (TextPart "Checking the weather.") Nothing,+              ContentPart (ToolCallPart tool) Nothing+              ] -> do+                tc.thinkingText `shouldBe` Just "I should call the weather tool."+                case tc.thinkingOpaque of+                  Just o -> do+                    o.poProvider `shouldBe` "claude"+                    o.poModel `shouldBe` Just "claude-haiku-4-5-20251001"+                    lookupText "signature" o.poPayload `shouldBe` Just "sig_thinking_abc123"+                  Nothing -> expectationFailure "expected thinking opaque"+                tool.tcId `shouldBe` "toolu_weather_1"+                tool.tcName `shouldBe` "get_weather"+                let encoded =+                      encodeTurn+                        "claude-haiku-4-5-20251001"+                        (AssistantMessage resp.respContent)+                    content = messageContent (head encoded)+                contentTypes content `shouldBe` ["thinking", "text", "tool_use"]+                case content of+                  (Object o : _) ->+                    lookupText "signature" (Object o) `shouldBe` Just "sig_thinking_abc123"+                  _ -> expectationFailure "expected thinking object"+                let withResult =+                      encodeTurn+                        "claude-haiku-4-5-20251001"+                        ( ToolTurn+                            [ ToolResult+                                { trCallId = "toolu_weather_1",+                                  trName = "get_weather",+                                  trContent = "sunny"+                                }+                            ]+                        )+                length withResult `shouldBe` 1+            other -> expectationFailure $ "unexpected parts: " <> show other+          events <- reverse <$> readIORef eventsRef+          events+            `shouldContain` [ StreamReasoningDelta "I should call the weather tool.",+                              StreamDelta "Checking the weather."+                            ]+          case [tc | StreamToolCall tc <- events] of+            [tc] -> tc.tcId `shouldBe` "toolu_weather_1"+            other -> expectationFailure $ "expected one StreamToolCall, got: " <> show other+        Left err -> expectationFailure $ "Stream parse failed: " <> show err++  describe "encodeTurn replay" $ do+    it "replays signed thinking blocks before tool_use for the same model" $ do+      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-thinking-tool-use.json"+      case parseClaudeResponse val of+        Right resp -> do+          let encoded = encodeTurn "claude-haiku-4-5-20251001" (AssistantMessage resp.respContent)+              content = messageContent (head encoded)+          contentTypes content `shouldBe` ["thinking", "text", "tool_use"]+          case content of+            (Object o : _) ->+              lookupText "signature" (Object o) `shouldBe` Just "sig_thinking_abc123"+            _ -> expectationFailure "expected thinking object"+          let withResult =+                encodeTurn+                  "claude-haiku-4-5-20251001"+                  ( ToolTurn+                      [ ToolResult+                          { trCallId = "toolu_weather_1",+                            trName = "get_weather",+                            trContent = "sunny"+                          }+                      ]+                  )+          length withResult `shouldBe` 1+        Left err -> expectationFailure $ show err++    it "omits foreign thinking opaque and foreign tool meta on encode" $ do+      let foreignThinking =+            thinkingPart+              ThinkingContent+                { thinkingText = Just "gemini thoughts",+                  thinkingOpaque =+                    Just+                      ProviderOpaque+                        { poProvider = "gemini",+                          poModel = Just "gemini-2.5-flash",+                          poPayload = object ["thoughtSignature" .= ("sig" :: Text)]+                        }+                }+          foreignTool =+            let base = mkToolCall "c1" "get_weather" (object [])+             in toolCallPart+                  base+                    { tcProviderMeta =+                        Just+                          ProviderOpaque+                            { poProvider = "gemini",+                              poModel = Just "gemini-2.5-flash",+                              poPayload = object ["thoughtSignature" .= ("sig" :: Text)]+                            }+                    }+          turn =+            AssistantMessage+              [ foreignThinking,+                textPart "hello",+                foreignTool+              ]+          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))+      contentTypes content `shouldBe` ["text", "tool_use"]+      case content of+        [Object textO, Object toolO] -> do+          lookupText "text" (Object textO) `shouldBe` Just "hello"+          lookupText "name" (Object toolO) `shouldBe` Just "get_weather"+          KM.lookup "thoughtSignature" toolO `shouldBe` Nothing+        _ -> expectationFailure "expected text + tool_use only"++  describe "thinking request mapping" $ do+    it "maps effort to budget_tokens and omits temperature when thinking is on" $ do+      let req =+            ChatRequest+              { reqModel = "claude-haiku-4-5-20251001",+                reqConversation = [UserTurn "hi"],+                reqSystem = Nothing,+                reqMaxTokens = 4096,+                reqTemperature = Just 0.5,+                reqTools = [],+                reqThinking = Just ThinkingMode {tmEnabled = True, tmEffort = Just "high"}+              }+          body = claudeBuildBody False req+      nestedText ["thinking", "type"] body `shouldBe` Just "enabled"+      nestedInt ["thinking", "budget_tokens"] body `shouldBe` Just (effortToBudgetTokens "high")+      lookupKey "temperature" body `shouldBe` Nothing++    it "keeps temperature when thinking is off" $ do+      let req =+            ChatRequest+              { reqModel = "claude-haiku-4-5-20251001",+                reqConversation = [UserTurn "hi"],+                reqSystem = Nothing,+                reqMaxTokens = 4096,+                reqTemperature = Just 0.5,+                reqTools = [],+                reqThinking = Nothing+              }+          body = claudeBuildBody False req+      lookupNumber "temperature" body `shouldBe` Just 0.5++  describe "image request encoding" $ do+    it "encodes URL and base64 image parts" $ do+      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="+      let turn =+            UserMessage+              [ imageUrlPart "https://example.com/cat.jpg",+                b64,+                textPart "describe"+              ]+          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))+      contentTypes content `shouldBe` ["image", "image", "text"]+      case content of+        [Object urlO, Object b64O, Object textO] -> do+          nestedText ["source", "type"] (Object urlO) `shouldBe` Just "url"+          nestedText ["source", "url"] (Object urlO)+            `shouldBe` Just "https://example.com/cat.jpg"+          nestedText ["source", "type"] (Object b64O) `shouldBe` Just "base64"+          nestedText ["source", "media_type"] (Object b64O) `shouldBe` Just "image/png"+          nestedText ["source", "data"] (Object b64O) `shouldBe` Just "aGVsbG8="+          lookupText "text" (Object textO) `shouldBe` Just "describe"+        _ -> expectationFailure "expected image/image/text blocks"++  describe "cache_control breakpoints" $ do+    it "places cache_control on the marked block without reordering" $ do+      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="+      let turn =+            UserMessage+              [ imageUrlPart "https://example.com/cat.jpg",+                cacheEphemeral b64,+                cacheEphemeral (textPart "describe")+              ]+          content = messageContent (head (encodeTurn "claude-haiku-4-5-20251001" turn))+      contentTypes content `shouldBe` ["image", "image", "text"]+      case content of+        [Object urlO, Object b64O, Object textO] -> do+          KM.lookup "cache_control" urlO `shouldBe` Nothing+          nestedText ["cache_control", "type"] (Object b64O) `shouldBe` Just "ephemeral"+          nestedText ["cache_control", "type"] (Object textO) `shouldBe` Just "ephemeral"+          nestedText ["source", "data"] (Object b64O) `shouldBe` Just "aGVsbG8="+          lookupText "text" (Object textO) `shouldBe` Just "describe"+        _ -> expectationFailure "expected image/image/text blocks"++    it "uses a content-block array for a single cached text part" $ do+      let turn = UserMessage [cacheEphemeral (textPart "static context")]+          msg = head (encodeTurn "claude-haiku-4-5-20251001" turn)+      case lookupKey "content" msg of+        Just (Array arr) -> do+          V.length arr `shouldBe` 1+          nestedText ["cache_control", "type"] (V.head arr) `shouldBe` Just "ephemeral"+          lookupText "text" (V.head arr) `shouldBe` Just "static context"+        Just (String _) ->+          expectationFailure "cached single text must not encode as a bare string"+        _ -> expectationFailure "expected content array"+   describe "parseClaudeUsage" $ do     it "extracts token counts" $ do       Right val <- eitherDecodeFileStrict' "test/fixtures/claude-text.json"-      parseClaudeUsage val `shouldBe` Just (Usage 25 10 0)+      parseClaudeUsage val `shouldBe` Just (mkUsage 25 10)      it "extracts token counts from tool_use response" $ do       Right val <- eitherDecodeFileStrict' "test/fixtures/claude-tool-use.json"-      parseClaudeUsage val `shouldBe` Just (Usage 50 35 0)+      parseClaudeUsage val `shouldBe` Just (mkUsage 50 35)++    it "normalizes cache read/creation into total input without double-counting" $ do+      let val =+            object+              [ "usage"+                  .= object+                    [ "input_tokens" .= (50 :: Int),+                      "output_tokens" .= (20 :: Int),+                      "cache_read_input_tokens" .= (100_000 :: Int),+                      "cache_creation_input_tokens" .= (1_200 :: Int)+                    ]+              ]+      parseClaudeUsage val+        `shouldBe` Just+          Usage+            { usageInputTokens = 101_250,+              usageOutputTokens = 20,+              usageCacheReadTokens = 100_000,+              usageCacheCreationTokens = 1_200,+              usageTotalCost = 0+            }++    it "treats missing or zero cache fields as zero on recorded fixtures" $ do+      Right val <- eitherDecodeFileStrict' "test/fixtures/claude-conversation-generated.json"+      case val of+        Array arr ->+          case V.toList arr of+            (Object first : _) ->+              case KM.lookup "response" first of+                Just resp ->+                  parseClaudeUsage resp+                    `shouldBe` Just (mkUsage 623 54)+                _ -> expectationFailure "missing response"+            _ -> expectationFailure "empty conversation array"+        _ -> expectationFailure "expected conversation array"++-- | Yield the full SSE payload once, then empty chunks (EOF).+mkBodyReader :: BS.ByteString -> IO (IO BS.ByteString)+mkBodyReader bs = do+  ref <- newIORef (Just bs)+  pure $ do+    m <- readIORef ref+    case m of+      Just chunk -> do+        writeIORef ref Nothing+        pure chunk+      Nothing -> pure BS.empty++messageContent :: Value -> [Value]+messageContent (Object o) =+  case KM.lookup "content" o of+    Just (Array a) -> V.toList a+    _ -> []+messageContent _ = []++contentTypes :: [Value] -> [Text]+contentTypes = mapMaybe typ+  where+    typ (Object o) = case KM.lookup "type" o of+      Just (String t) -> Just t+      _ -> Nothing+    typ _ = Nothing++lookupText :: Text -> Value -> Maybe Text+lookupText key (Object o) =+  KM.lookup (K.fromText key) o >>= \case+    String t -> Just t+    _ -> Nothing+lookupText _ _ = Nothing++lookupNumber :: Text -> Value -> Maybe Double+lookupNumber key (Object o) =+  KM.lookup (K.fromText key) o >>= \case+    Number sci -> Just (toRealFloat sci)+    _ -> Nothing+lookupNumber _ _ = Nothing++lookupKey :: Text -> Value -> Maybe Value+lookupKey key (Object o) = KM.lookup (K.fromText key) o+lookupKey _ _ = Nothing++nestedText :: [Text] -> Value -> Maybe Text+nestedText [key] v = lookupText key v+nestedText (key : rest) (Object o) =+  KM.lookup (K.fromText key) o >>= nestedText rest+nestedText _ _ = Nothing++nestedInt :: [Text] -> Value -> Maybe Int+nestedInt [key] (Object o) =+  KM.lookup (K.fromText key) o >>= \case+    Number sci -> toBoundedInteger sci+    _ -> Nothing+nestedInt (key : rest) (Object o) =+  KM.lookup (K.fromText key) o >>= nestedInt rest+nestedInt _ _ = Nothing
test/LLM/DeepSeekSpec.hs view
@@ -1,28 +1,36 @@ module LLM.DeepSeekSpec (spec) where -import Data.Aeson (Value (Object, String), object, (.=))+import Data.Aeson (Value (Array, Object, String), eitherDecodeFileStrict', object, (.=)) import Data.Aeson.Key qualified as K import Data.Aeson.KeyMap qualified as KM import Data.Text (Text)+import Data.Vector qualified as V import LLM.Core.Types   ( ChatRequest (..),-    ChatResponse (respReasoning, respText),+    ChatResponse (respContent, respReasoning, respText),+    ContentPart (..),+    PartBody (..),+    ThinkingContent (..),     ThinkingMode (..),     Turn (..),+    assistantTurn,+    cacheEphemeral,     deepSeekMessageEncodeOptions,     defaultMessageEncodeOptions,     mkToolCall,+    textPart,   )+import LLM.Core.Usage (Usage (..)) import LLM.Providers.DeepSeek (deepSeekBuildBodyPairs)-import LLM.Providers.OpenAI (encodeTurn, parseOpenAIResponse)-import Test.Hspec (Spec, describe, it, shouldBe)+import LLM.Providers.OpenAI (encodeTurn, parseOpenAIResponse, parseOpenAIUsage)+import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)  spec :: Spec spec = describe "DeepSeek thinking mode" $ do   describe "message encoding" $ do     it "includes reasoning_content when replaying assistant tool turns" $ do       let turn =-            AssistantTurn+            assistantTurn               "Let me check."               (Just "I should call the weather tool.")               [mkToolCall "call_1" "get_weather" (object ["location" .= ("London" :: Text)])]@@ -30,10 +38,18 @@       lookupText "reasoning_content" msg `shouldBe` Just "I should call the weather tool."      it "omits reasoning_content for OpenAI-compatible default encoding" $ do-      let turn = AssistantTurn "Hello" (Just "thinking") []+      let turn = assistantTurn "Hello" (Just "thinking") []           msg = head (encodeTurn defaultMessageEncodeOptions turn)       lookupText "reasoning_content" msg `shouldBe` Nothing +    it "omits cache hints from the wire while keeping content identical" $ do+      let plain = UserMessage [textPart "static context"]+          hinted = UserMessage [cacheEphemeral (textPart "static context")]+          plainMsg = head (encodeTurn deepSeekMessageEncodeOptions plain)+          hintedMsg = head (encodeTurn deepSeekMessageEncodeOptions hinted)+      hintedMsg `shouldBe` plainMsg+      lookupText "content" hintedMsg `shouldBe` Just "static context"+   describe "request body" $ do     it "disables thinking by default" $ do       let body = object (deepSeekBuildBodyPairs False sampleRequest)@@ -75,14 +91,41 @@         Right resp -> do           resp.respReasoning `shouldBe` Just "Let me think."           resp.respText `shouldBe` "The answer is 42."+          case resp.respContent of+            [ ContentPart (ThinkingPart (ThinkingContent (Just "Let me think.") Nothing)) Nothing,+              ContentPart (TextPart "The answer is 42.") Nothing+              ] -> pure ()+            other -> fail $ "unexpected ordered parts: " <> show other         Left err -> fail $ show err +  describe "parseOpenAIUsage (DeepSeek cache fields)" $ do+    it "maps prompt_cache_hit_tokens without double-counting prompt_tokens" $ do+      Right val <- eitherDecodeFileStrict' "test/fixtures/deepseek-conversation-generated.json"+      case val of+        Array arr ->+          case V.toList arr of+            (Object first : _) ->+              case KM.lookup "response" first of+                Just resp ->+                  parseOpenAIUsage resp+                    `shouldBe` Just+                      Usage+                        { usageInputTokens = 336,+                          usageOutputTokens = 68,+                          usageCacheReadTokens = 256,+                          usageCacheCreationTokens = 0,+                          usageTotalCost = 0+                        }+                _ -> expectationFailure "missing response"+            _ -> expectationFailure "empty conversation array"+        _ -> expectationFailure "expected conversation array"+ sampleRequest :: ChatRequest sampleRequest =   ChatRequest     { reqModel = "deepseek-v4-pro",       reqConversation =-        [ AssistantTurn "Hi" (Just "CoT") [mkToolCall "c1" "get_date" (object [])],+        [ assistantTurn "Hi" (Just "CoT") [mkToolCall "c1" "get_date" (object [])],           ToolTurn []         ],       reqSystem = Nothing,
test/LLM/GeminiSpec.hs view
@@ -2,11 +2,27 @@  module LLM.GeminiSpec (spec) where -import Data.Aeson (eitherDecodeFileStrict')-import LLM.Core.Types (ChatResponse (respText), ToolCall (tcName))-import LLM.Core.Usage (Usage (Usage))+import Data.Aeson (Value (Array, Object, String), eitherDecodeFileStrict', object, (.=))+import Data.Aeson.KeyMap qualified as KM+import Data.Text (Text)+import Data.Vector qualified as V+import LLM.Core.Types+  ( ChatResponse (respText),+    ProviderOpaque (..),+    ThinkingContent (..),+    ToolCall (..),+    Turn (..),+    mkToolCall,+    textPart,+    thinkingPart,+    toolCallPart,+    imageUrlPart,+    imageBase64Part,+    cacheEphemeral,+  )+import LLM.Core.Usage (Usage (..), mkUsage) import LLM.Core.Utils (getToolCalls, hasToolCalls)-import LLM.Providers.Gemini (parseGeminiResponse, parseGeminiUsage)+import LLM.Providers.Gemini (encodeTurn, parseGeminiResponse, parseGeminiUsage, signatureForModel) import Test.Hspec   ( Spec,     describe,@@ -37,7 +53,157 @@           tc.tcName `shouldBe` "get_weather"         Left err -> expectationFailure $ "Parse failed: " <> show err +  describe "thought signature round-trip" $ do+    it "replays signature only for a matching model" $ do+      let tc =+            (mkToolCall "call_1" "get_weather" (object ["location" .= ("London" :: Text)]))+              { tcProviderMeta =+                  Just+                    ProviderOpaque+                      { poProvider = "gemini",+                        poModel = Just "gemini-2.5-pro-001",+                        poPayload = object ["thoughtSignature" .= ("sig-xyz" :: Text)]+                      }+              }+          turn = AssistantMessage [toolCallPart tc]+          matching = head (encodeTurn "gemini-2.5-pro" turn)+          mismatch = head (encodeTurn "gemini-2.5-flash" turn)+      hasThoughtSignature matching `shouldBe` True+      hasThoughtSignature mismatch `shouldBe` False+      signatureForModel "gemini-2.5-pro" tc.tcProviderMeta `shouldBe` Just "sig-xyz"+      signatureForModel "gemini-2.5-flash" tc.tcProviderMeta `shouldBe` Nothing++    it "omits foreign Claude thinking opaque while keeping text and tool calls" $ do+      let turn =+            AssistantMessage+              [ thinkingPart+                  ThinkingContent+                    { thinkingText = Just "claude thoughts",+                      thinkingOpaque =+                        Just+                          ProviderOpaque+                            { poProvider = "claude",+                              poModel = Just "claude-haiku-4-5-20251001",+                              poPayload =+                                object+                                  [ "type" .= ("thinking" :: Text),+                                    "signature" .= ("sig" :: Text)+                                  ]+                            }+                    },+                textPart "hello",+                toolCallPart (mkToolCall "c1" "get_weather" (object []))+              ]+          encoded = head (encodeTurn "gemini-2.5-flash" turn)+          parts = assistantParts encoded+      -- Foreign opaque dropped; plain thinking text may remain as thought part,+      -- plus text and functionCall => at least text + functionCall.+      length parts `shouldBe` 3+      hasFunctionCall encoded `shouldBe` True+      hasThoughtSignature encoded `shouldBe` False++  describe "image request encoding" $ do+    it "encodes URL fileData and base64 inlineData parts" $ do+      Right b64 <- pure $ imageBase64Part "image/jpeg" "aGVsbG8="+      let turn =+            UserMessage+              [ imageUrlPart "https://example.com/photo.png",+                b64,+                textPart "caption"+              ]+          parts = assistantParts (head (encodeTurn "gemini-3.1-flash-lite" turn))+      length parts `shouldBe` 3+      case parts of+        [Object urlP, Object b64P, Object textP] -> do+          KM.member "fileData" urlP `shouldBe` True+          KM.member "inlineData" b64P `shouldBe` True+          KM.lookup "text" textP `shouldBe` Just (String "caption")+          case KM.lookup "fileData" urlP of+            Just (Object fd) -> do+              KM.lookup "fileUri" fd+                `shouldBe` Just (String "https://example.com/photo.png")+              KM.lookup "mimeType" fd `shouldBe` Just (String "image/png")+            _ -> expectationFailure "expected fileData"+          case KM.lookup "inlineData" b64P of+            Just (Object idata) -> do+              KM.lookup "mimeType" idata `shouldBe` Just (String "image/jpeg")+              KM.lookup "data" idata `shouldBe` Just (String "aGVsbG8=")+            _ -> expectationFailure "expected inlineData"+        _ -> expectationFailure "expected three parts"++  describe "cache hint encoding" $ do+    it "omits cache hints from the wire while keeping content identical" $ do+      Right b64 <- pure $ imageBase64Part "image/jpeg" "aGVsbG8="+      let plain =+            UserMessage+              [ imageUrlPart "https://example.com/photo.png",+                b64,+                textPart "caption"+              ]+          hinted =+            UserMessage+              [ cacheEphemeral (imageUrlPart "https://example.com/photo.png"),+                b64,+                cacheEphemeral (textPart "caption")+              ]+          plainMsg = head (encodeTurn "gemini-3.1-flash-lite" plain)+          hintedMsg = head (encodeTurn "gemini-3.1-flash-lite" hinted)+      hintedMsg `shouldBe` plainMsg+   describe "parseGeminiUsage" $ do     it "extracts token counts" $ do       Right val <- eitherDecodeFileStrict' "test/fixtures/gemini-text.json"-      parseGeminiUsage val `shouldBe` Just (Usage 20 8 0)+      parseGeminiUsage val `shouldBe` Just (mkUsage 20 8)++    it "reports cachedContentTokenCount without double-counting promptTokenCount" $ do+      let val =+            object+              [ "usageMetadata"+                  .= object+                    [ "promptTokenCount" .= (1000 :: Int),+                      "candidatesTokenCount" .= (50 :: Int),+                      "cachedContentTokenCount" .= (800 :: Int)+                    ]+              ]+      parseGeminiUsage val+        `shouldBe` Just+          Usage+            { usageInputTokens = 1000,+              usageOutputTokens = 50,+              usageCacheReadTokens = 800,+              usageCacheCreationTokens = 0,+              usageTotalCost = 0+            }++hasThoughtSignature :: Value -> Bool+hasThoughtSignature (Object o) =+  case KM.lookup "parts" o of+    Just (Array arr) ->+      any+        ( \case+            Object p -> KM.member "thoughtSignature" p+            _ -> False+        )+        arr+    _ -> False+hasThoughtSignature _ = False++hasFunctionCall :: Value -> Bool+hasFunctionCall (Object o) =+  case KM.lookup "parts" o of+    Just (Array arr) ->+      any+        ( \case+            Object p -> KM.member "functionCall" p+            _ -> False+        )+        arr+    _ -> False+hasFunctionCall _ = False++assistantParts :: Value -> [Value]+assistantParts (Object o) =+  case KM.lookup "parts" o of+    Just (Array arr) -> V.toList arr+    _ -> []+assistantParts _ = []
test/LLM/GenerateObjectSpec.hs view
@@ -10,12 +10,13 @@ import LLM.Agent.GenerateObject (generateObject, generateObjectUntyped) import LLM.Agent.Types (Agent (..), RuntimeArgs (..)) import LLM.Core.Abort (AbortSignal, abort, newAbortSignal)-import LLM.Core.Types (ChatRequest (..), ChatResponse (..), LLMError (..), LLMGateway (..), LLMHooks (..), Turn (..))-import LLM.Core.Usage (PricingInfo (..), Usage (..))+import LLM.Core.Types (ChatRequest (..), ChatResponse (..), LLMError (..), LLMGateway (..), LLMHooks (..), assistantTurn, pattern UserTurn)+import LLM.Core.Usage (PricingInfo (..), Usage (..), defaultPricingInfo, mkUsage) import LLM.Generate.Logger (noHooks) import LLM.Generate.ModelConfig   ( ModelConfig (..),     ModelWithFallbacks (..),+    defaultModelCapabilities,   ) import LLM.Generate.Types (GenerateError (..), GenerateErrorResult (..)) import LLM.WeatherTool (WeatherToolArgs (..))@@ -32,13 +33,13 @@ spec = describe "GenerateObject" $ do   describe "generateObjectUntyped" $ do     it "returns the provider JSON and usage" $ do-      let gw = objectGateway (object ["location" .= ("Paris" :: Text)]) (Usage 12 3 0)+      let gw = objectGateway (object ["location" .= ("Paris" :: Text)]) (mkUsage 12 3)           models = ModelWithFallbacks (mockModel gw) []       result <- runUntyped models (object ["type" .= ("object" :: Text)])       case result of         Right (value, usage) -> do           value `shouldBe` object ["location" .= ("Paris" :: Text)]-          usage `shouldBe` Usage 12 3 0+          usage `shouldBe` mkUsage 12 3         Left err -> expectationFailure $ show err      it "propagates provider errors" $ do@@ -52,7 +53,7 @@     it "returns Aborted when the signal is already set" $ do       sig <- newAbortSignal       abort sig-      let gw = objectGateway (object []) (Usage 0 0 0)+      let gw = objectGateway (object []) (mkUsage 0 0)           models = ModelWithFallbacks (mockModel gw) []       rt <- mkRuntime (Just sig)       result <- generateObjectUntyped defaultAgent models rt [UserTurn "go"] (object ["type" .= ("object" :: Text)])@@ -62,7 +63,7 @@      it "falls back to the next model on failure" $ do       let failGw = errorObjectGateway (HttpError 500 "boom")-          okGw = objectGateway (object ["ok" .= True]) (Usage 1 1 0)+          okGw = objectGateway (object ["ok" .= True]) (mkUsage 1 1)           models = ModelWithFallbacks (mockModel failGw) [mockModel okGw]       result <- runUntyped models (object ["type" .= ("object" :: Text)])       case result of@@ -71,17 +72,17 @@    describe "generateObject" $ do     it "decodes a typed object from provider JSON" $ do-      let gw = objectGateway (object ["location" .= ("London" :: Text)]) (Usage 4 2 0)+      let gw = objectGateway (object ["location" .= ("London" :: Text)]) (mkUsage 4 2)           models = ModelWithFallbacks (mockModel gw) []       result <- runTyped models       case result of         Right (WeatherToolArgs loc, usage) -> do           loc `shouldBe` "London"-          usage `shouldBe` Usage 4 2 0+          usage `shouldBe` mkUsage 4 2         Left err -> expectationFailure $ show err      it "reports a parse error when provider JSON does not match the codec" $ do-      let gw = objectGateway (object ["wrong" .= (1 :: Int)]) (Usage 0 0 0)+      let gw = objectGateway (object ["wrong" .= (1 :: Int)]) (mkUsage 0 0)           models = ModelWithFallbacks (mockModel gw) []       result <- runTyped models       case result of@@ -91,12 +92,12 @@      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)+      let gw = capturingObjectGateway captured (object ["location" .= ("Paris" :: Text)]) (mkUsage 1 0)           models = ModelWithFallbacks (mockModel gw) []           agent = defaultAgent {agContextWindow = Just 1}           conv =             [ UserTurn "first",-              AssistantTurn "a1" Nothing [],+              assistantTurn "a1" Nothing [],               UserTurn "second"             ]       rt <- mkRuntime Nothing@@ -145,6 +146,7 @@       mcMaxTokens = 256,       mcTemperature = Nothing,       mcThinking = Nothing,+      mcCapabilities = defaultModelCapabilities,       mcRequestTimeout = Nothing,       mcThrottleDelay = Nothing,       mcRetryCount = 0,@@ -152,7 +154,7 @@     }  zeroPricing :: PricingInfo-zeroPricing = PricingInfo 0 0+zeroPricing = defaultPricingInfo 0 0  defaultAgent :: Agent defaultAgent =
test/LLM/GenericConversationTest.hs view
@@ -9,9 +9,14 @@ import LLM.Agent.Types (Agent (..), RuntimeArgs (..)) import LLM.Core.LLMProvider (LLMProvider, toGateway) import LLM.Core.Types (LLMGateway, ThinkingMode (..))-import LLM.Core.Usage (PricingInfo (..))+import LLM.Core.Usage (defaultPricingInfo) import LLM.Generate.Logger (noHooks)-import LLM.Generate.ModelConfig (ModelConfig (..), ModelWithFallbacks (ModelWithFallbacks))+import LLM.Generate.ModelConfig+  ( ModelCapabilities (..),+    ModelConfig (..),+    ModelWithFallbacks (ModelWithFallbacks),+    defaultModelCapabilities,+  ) import LLM.TestKit   ( loadRecordedConversation,     mockProvider,@@ -35,10 +40,14 @@   ModelConfig     { mcGateway = provider,       mcModel = T.pack opts.modelName,-      mcPricing = PricingInfo {pricePerMillionInput = 0.0, pricePerMillionOutput = 0.0},+      mcPricing = defaultPricingInfo 0.0 0.0,       mcMaxTokens = 1024,       mcTemperature = Nothing,       mcThinking = opts.specThinking,+      mcCapabilities =+        defaultModelCapabilities+          { capThinking = maybe False (.tmEnabled) opts.specThinking+          },       mcRequestTimeout = Nothing,       mcThrottleDelay = Nothing,       mcRetryCount = 3,
test/LLM/HistoryToolSpec.hs view
@@ -9,7 +9,7 @@ import LLM.Agent.ToolUtils (toTool, windowOffset) import LLM.Agent.Tools.HistoryTool (historyToolTyped) import LLM.Agent.Types (Agent (..), RuntimeArgs (..), Tool (..), ToolContext (..))-import LLM.Core.Types (Turn (..))+import LLM.Core.Types (Turn (..), assistantTurn, pattern UserTurn) import LLM.Generate.GenerateUtils (llmHooks) import LLM.Generate.Logger (noHooks) import Test.Hspec@@ -58,8 +58,8 @@     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 []+              assistantTurn "hidden answer" Nothing [],+              assistantTurn "visible assistant only" Nothing []             ]           -- Offset past both user+assistant hidden turns; visible slice is           -- assistant-only so countUserTurns == 0.@@ -71,11 +71,11 @@ sampleConversation :: [Turn] sampleConversation =   [ UserTurn "first question",-    AssistantTurn "first answer" Nothing [],+    assistantTurn "first answer" Nothing [],     UserTurn "second question",-    AssistantTurn "second answer" Nothing [],+    assistantTurn "second answer" Nothing [],     UserTurn "third question",-    AssistantTurn "third answer" Nothing []+    assistantTurn "third answer" Nothing []   ]  noWindowAgent :: Agent
test/LLM/LoadSpec.hs view
@@ -3,14 +3,16 @@ import Control.Exception (IOException, SomeException, catch, displayException, fromException, try) import Control.Monad.Except (runExceptT) import Data.Aeson (Value (Array), eitherDecodeFileStrict')+import Data.List (find) 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.Generate.ModelConfig (ModelCapabilities (..), ModelConfig (..), defaultModelCapabilities) import LLM.Load.LoadGateways (buildGateway, loadGateways, loadGatewaysFromCatalog, parseProviderBaseUrl) import LLM.Load.LoadModels (loadModelOrThrow, loadModelOrThrow_, loadModelsOrThrow)+import LLM.Load.ModelCatalog (ModelCatalogItem (..)) import LLM.Load.ProviderCatalog   ( ProviderCatalogItem (..),     ProviderProtocol (..),@@ -63,6 +65,26 @@     it "loads a known model config" $ do       cfg <- loadModelOrThrow ollamaCatalog "llama_3_2"       cfg.mcModel `shouldBe` "llama3.2:latest"+      cfg.mcCapabilities.capVision `shouldBe` False++    it "loads catalog capabilities when present" $ do+      result <- eitherDecodeFileStrict' "./model-catalog.json"+      case result of+        Right (items :: [ModelCatalogItem]) -> do+          let Just haiku = find (\i -> i.modelConfigName == "haiku_4_5") items+          haiku.capabilities+            `shouldBe` Just+              ( ModelCapabilities+                  { capThinking = True,+                    capVision = True,+                    capPromptCaching = True+                  }+              )+        Left err -> expectationFailure err++    it "loads catalogs without capabilities (defaults false)" $ do+      cfg <- loadModelOrThrow ollamaCatalog "mistral"+      cfg.mcCapabilities `shouldBe` defaultModelCapabilities      it "throws when a model config is missing" $ do       result <- try @LoadConfigError (loadModelsOrThrow ollamaCatalog ("llama_3_2" :: Text, "missing_model" :: Text))
test/LLM/OpenAISpec.hs view
@@ -2,20 +2,37 @@  module LLM.OpenAISpec (spec) where -import Data.Aeson (eitherDecodeFileStrict')+import Data.Aeson+  ( Value (Array, Object, String),+    eitherDecodeFileStrict',+    object,+    (.=),+  )+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Vector qualified as V import LLM.Core.Types   ( ChatResponse (respText),     ToolCall (tcId, tcName),+    Turn (..),+    cacheEphemeral,+    defaultMessageEncodeOptions,+    imageBase64Part,+    imageUrlPart,+    textPart,   )-import LLM.Core.Usage (Usage (Usage))+import LLM.Core.Usage (Usage (..), mkUsage) import LLM.Core.Utils (getToolCalls, hasToolCalls)-import LLM.Providers.OpenAI (parseOpenAIResponse, parseOpenAIUsage)+import LLM.Providers.OpenAI (encodeTurn, parseOpenAIResponse, parseOpenAIUsage) import Test.Hspec   ( Spec,     describe,     expectationFailure,     it,     shouldBe,+    shouldSatisfy,   )  spec :: Spec@@ -39,7 +56,130 @@           tc.tcId `shouldBe` "call_abc123"         Left err -> expectationFailure $ "Parse failed: " <> show err +  describe "image request encoding" $ do+    it "encodes URL and base64 data-URL image parts" $ do+      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="+      let turn =+            UserMessage+              [ textPart "What is in this image?",+                imageUrlPart "https://example.com/boardwalk.jpg",+                b64+              ]+          content = userContent (head (encodeTurn defaultMessageEncodeOptions turn))+      contentTypes content `shouldBe` ["text", "image_url", "image_url"]+      case content of+        [Object textO, Object urlO, Object b64O] -> do+          lookupText "text" (Object textO) `shouldBe` Just "What is in this image?"+          nestedText ["image_url", "url"] (Object urlO)+            `shouldBe` Just "https://example.com/boardwalk.jpg"+          nestedText ["image_url", "url"] (Object b64O)+            `shouldBe` Just "data:image/png;base64,aGVsbG8="+        _ -> expectationFailure "expected text + two image_url parts"++  describe "cache hint encoding" $ do+    it "omits cache hints from the wire while keeping content identical" $ do+      Right b64 <- pure $ imageBase64Part "image/png" "aGVsbG8="+      let plain =+            UserMessage+              [ textPart "What is in this image?",+                imageUrlPart "https://example.com/boardwalk.jpg",+                b64+              ]+          hinted =+            UserMessage+              [ cacheEphemeral (textPart "What is in this image?"),+                imageUrlPart "https://example.com/boardwalk.jpg",+                cacheEphemeral b64+              ]+          plainMsg = head (encodeTurn defaultMessageEncodeOptions plain)+          hintedMsg = head (encodeTurn defaultMessageEncodeOptions hinted)+      hintedMsg `shouldBe` plainMsg+      case userContent hintedMsg of+        parts -> do+          contentTypes parts `shouldBe` ["text", "image_url", "image_url"]+          mapM_ (\p -> lookupKey "cache_control" p `shouldBe` Nothing) parts++    it "Ollama (shared OpenAI encoder) also omits cache hints" $ do+      -- Ollama requests use the same encodeTurn + defaultMessageEncodeOptions path.+      let plain = UserMessage [textPart "hello"]+          hinted = UserMessage [cacheEphemeral (textPart "hello")]+      head (encodeTurn defaultMessageEncodeOptions hinted)+        `shouldBe` head (encodeTurn defaultMessageEncodeOptions plain)+   describe "parseOpenAIUsage" $ do     it "extracts token counts" $ do       Right val <- eitherDecodeFileStrict' "test/fixtures/openai-text.json"-      parseOpenAIUsage val `shouldBe` Just (Usage 15 9 0)+      parseOpenAIUsage val `shouldBe` Just (mkUsage 15 9)++    it "reports cached_tokens without double-counting prompt_tokens" $ do+      let val =+            object+              [ "usage"+                  .= object+                    [ "prompt_tokens" .= (2006 :: Int),+                      "completion_tokens" .= (300 :: Int),+                      "prompt_tokens_details"+                        .= object+                          [ "cached_tokens" .= (1920 :: Int),+                            "cache_write_tokens" .= (40 :: Int)+                          ]+                    ]+              ]+      parseOpenAIUsage val+        `shouldBe` Just+          Usage+            { usageInputTokens = 2006,+              usageOutputTokens = 300,+              usageCacheReadTokens = 1920,+              usageCacheCreationTokens = 40,+              usageTotalCost = 0+            }++    it "reads zero cached_tokens from recorded conversation fixtures" $ do+      Right val <- eitherDecodeFileStrict' "test/fixtures/openai-conversation-generated.json"+      case val of+        Array arr ->+          case V.toList arr of+            (Object first : _) ->+              case KM.lookup "response" first of+                Just resp ->+                  case parseOpenAIUsage resp of+                    Just u -> do+                      u.usageCacheReadTokens `shouldBe` 0+                      u.usageInputTokens `shouldSatisfy` (> 0)+                    Nothing -> expectationFailure "failed to parse usage"+                _ -> expectationFailure "missing response"+            _ -> expectationFailure "empty conversation array"+        _ -> expectationFailure "expected conversation array"++userContent :: Value -> [Value]+userContent (Object o) =+  case KM.lookup "content" o of+    Just (Array arr) -> V.toList arr+    _ -> []+userContent _ = []++contentTypes :: [Value] -> [Text]+contentTypes = mapMaybe typ+  where+    typ (Object o) = case KM.lookup "type" o of+      Just (String t) -> Just t+      _ -> Nothing+    typ _ = Nothing++lookupText :: Text -> Value -> Maybe Text+lookupText key (Object o) =+  case KM.lookup (K.fromText key) o of+    Just (String t) -> Just t+    _ -> Nothing+lookupText _ _ = Nothing++lookupKey :: Text -> Value -> Maybe Value+lookupKey key (Object o) = KM.lookup (K.fromText key) o+lookupKey _ _ = Nothing++nestedText :: [Text] -> Value -> Maybe Text+nestedText [key] v = lookupText key v+nestedText (key : rest) (Object o) =+  KM.lookup (K.fromText key) o >>= nestedText rest+nestedText _ _ = Nothing
test/LLM/StreamingSpec.hs view
@@ -7,17 +7,20 @@ import Data.Text (Text) import LLM.Core.Types   ( ChatResponse (..),-    ContentBlock (TextBlock, ToolCallBlock),     LLMGateway (..),     StreamEvent (..),     Turn (..),+    assistantTurn,     mkToolCall,+    pattern UserTurn,+    textPart,+    toolCallPart,   )-import LLM.Core.Usage (PricingInfo (..), Usage (..))+import LLM.Core.Usage (PricingInfo (..), defaultPricingInfo, mkUsage) import LLM.Generate.Generate (streamTextLLM) import LLM.Generate.GenerateUtils (llmHooks) import LLM.Generate.Logger (noHooks)-import LLM.Generate.ModelConfig (ModelConfig (..))+import LLM.Generate.ModelConfig (ModelConfig (..), defaultModelCapabilities) import LLM.Generate.Types   ( GenRequest (..),     RoundTextRole (..),@@ -32,7 +35,7 @@       let gw =             streamGateway               [StreamDelta "hello"]-              (ChatResponse "hello" [TextBlock "hello"] (Just (Usage 1 1 0)) Nothing)+              (ChatResponse "hello" [textPart "hello"] (Just (mkUsage 1 1)) Nothing)       chunks <- runStream gw []       reverse chunks         `shouldBe` [ TextDelta "hello",@@ -40,11 +43,11 @@                    ]      it "routes text to answer after a tool turn" $ do-      let prior = [UserTurn "q", AssistantTurn "" Nothing [mkToolCall "1" "t" (object [])], ToolTurn []]+      let prior = [UserTurn "q", assistantTurn "" Nothing [mkToolCall "1" "t" (object [])], ToolTurn []]           gw =             streamGateway               [StreamDelta "done"]-              (ChatResponse "done" [TextBlock "done"] (Just (Usage 1 1 0)) Nothing)+              (ChatResponse "done" [textPart "done"] (Just (mkUsage 1 1)) Nothing)       chunks <- runStream gw prior       reverse chunks `shouldBe` [AnswerDelta "done"] @@ -53,7 +56,7 @@           gw =             streamGateway               [StreamDelta "searching", StreamToolCall tc]-              (ChatResponse "" [ToolCallBlock tc] (Just (Usage 2 0 0)) Nothing)+              (ChatResponse "" [toolCallPart tc] (Just (mkUsage 2 0)) Nothing)       chunks <- runStream gw [UserTurn "find x"]       reverse chunks         `shouldBe` [ TextDelta "searching",@@ -65,7 +68,7 @@       let gw =             streamGateway               [StreamReasoningDelta "think"]-              (ChatResponse "ok" [TextBlock "ok"] Nothing Nothing)+              (ChatResponse "ok" [textPart "ok"] Nothing Nothing)       chunks <- runStream gw []       reverse chunks `shouldBe` [ReasoningDelta "think", RoundTextRoleCommitted AnswerRole] @@ -99,6 +102,7 @@             mcMaxTokens = 256,             mcTemperature = Nothing,             mcThinking = Nothing,+            mcCapabilities = defaultModelCapabilities,             mcRequestTimeout = Nothing,             mcThrottleDelay = Nothing,             mcRetryCount = 0,@@ -108,4 +112,4 @@   readIORef ref  zeroPricing :: PricingInfo-zeroPricing = PricingInfo 0 0+zeroPricing = defaultPricingInfo 0 0
test/LLM/TestKit.hs view
@@ -32,12 +32,17 @@ import LLM.Agent.ToolUtils (toTool) import LLM.Agent.Types (Agent (..), RuntimeArgs (..), ToolMap) import LLM.Core.LLMProvider (LLMProvider (..))-import LLM.Core.Types (LLMGateway, ThinkingMode (..), Turn (..))-import LLM.Core.Usage (PricingInfo (..), addUsage, emptyUsage)+import LLM.Core.Types (LLMGateway, ThinkingMode (..), Turn (..), pattern UserTurn)+import LLM.Core.Usage (addUsage, defaultPricingInfo, emptyUsage) import LLM.Core.Utils (parseChatResponse) import LLM.Generate.GenerateUtils (llmHooks) import LLM.Generate.Logger (Hooks (..), noHooks)-import LLM.Generate.ModelConfig (ModelConfig (..), ModelWithFallbacks (..))+import LLM.Generate.ModelConfig+  ( ModelCapabilities (..),+    ModelConfig (..),+    ModelWithFallbacks (..),+    defaultModelCapabilities,+  ) import LLM.Generate.Types (GenerateErrorResult (..), GenerateTextResult (..)) import LLM.WeatherTool (weatherToolTyped) import System.Directory (createDirectoryIfMissing)@@ -150,10 +155,14 @@         ModelConfig           { mcGateway = gateway,             mcModel = modelName,-            mcPricing = PricingInfo {pricePerMillionInput = 0.0, pricePerMillionOutput = 0.0},+            mcPricing = defaultPricingInfo 0.0 0.0,             mcMaxTokens = 1024,             mcTemperature = Nothing,             mcThinking = thinking,+            mcCapabilities =+              defaultModelCapabilities+                { capThinking = maybe False (.tmEnabled) thinking+                },             mcRequestTimeout = Nothing,             mcThrottleDelay = Nothing,             mcRetryCount = 3,
test/LLM/TypesSpec.hs view
@@ -1,25 +1,48 @@ module LLM.TypesSpec (spec) where -import Data.Aeson (object, (.=))+import Data.Aeson (eitherDecode, encode, object, (.=))+import Data.Text qualified as T import LLM.Core.Types-  ( ChatResponse (ChatResponse),-    ContentBlock (TextBlock, ToolCallBlock),+  ( CacheHint (..),+    ChatResponse (ChatResponse),+    ContentPart (..),     LLMError (EmptyResponse, HttpError, NetworkError),+    PartBody (..),+    ThinkingContent (..),+    Turn (..),+    cacheEphemeral,+    imageBase64Part,+    imageUrlPart,     mkToolCall,+    pattern UserTurn,+    textPart,+    thinkingPart,+    toolCallPart,+    validateTurn,   ) import LLM.Core.Usage   ( PricingInfo (..),     Usage (..),     addUsage,+    defaultPricingInfo,     emptyUsage,     estimateCost,+    mkUsage,+    usageOrdinaryInputTokens,   ) import LLM.Core.Utils   ( getToolCalls,     hasToolCalls,     isRetryable,   )-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Hspec+  ( Spec,+    describe,+    expectationFailure,+    it,+    shouldBe,+    shouldSatisfy,+  )  spec :: Spec spec = describe "Types" $ do@@ -27,39 +50,184 @@     it "emptyUsage has zero tokens" $ do       emptyUsage.usageInputTokens `shouldBe` 0       emptyUsage.usageOutputTokens `shouldBe` 0+      emptyUsage.usageCacheReadTokens `shouldBe` 0+      emptyUsage.usageCacheCreationTokens `shouldBe` 0 -    it "addUsage sums token counts" $ do-      let u1 = Usage 10 20 0-          u2 = Usage 30 40 0-      addUsage u1 u2 `shouldBe` Usage 40 60 0+    it "addUsage sums token counts including cache counters" $ do+      let u1 =+            Usage+              { usageInputTokens = 10,+                usageOutputTokens = 20,+                usageCacheReadTokens = 3,+                usageCacheCreationTokens = 2,+                usageTotalCost = 0.25+              }+          u2 =+            Usage+              { usageInputTokens = 30,+                usageOutputTokens = 40,+                usageCacheReadTokens = 4,+                usageCacheCreationTokens = 1,+                usageTotalCost = 0.5+              }+      addUsage u1 u2+        `shouldBe` Usage+          { usageInputTokens = 40,+            usageOutputTokens = 60,+            usageCacheReadTokens = 7,+            usageCacheCreationTokens = 3,+            usageTotalCost = 0.75+          }      it "addUsage is associative" $ do-      let u1 = Usage 1 2 0-          u2 = Usage 3 4 0-          u3 = Usage 5 6 0+      let u1 = mkUsage 1 2+          u2 = mkUsage 3 4+          u3 = mkUsage 5 6       addUsage (addUsage u1 u2) u3 `shouldBe` addUsage u1 (addUsage u2 u3) +    it "Semigroup matches addUsage and Monoid uses emptyUsage" $ do+      let u1 = mkUsage 10 1+          u2 = mkUsage 5 2+      (u1 <> u2) `shouldBe` addUsage u1 u2+      (mempty :: Usage) `shouldBe` emptyUsage++    it "JSON round-trips with cache fields" $ do+      let u =+            Usage+              { usageInputTokens = 100,+                usageOutputTokens = 20,+                usageCacheReadTokens = 40,+                usageCacheCreationTokens = 10,+                usageTotalCost = 0.5+              }+      eitherDecode (encode u) `shouldBe` Right u++    it "JSON decode defaults missing cache fields to zero" $ do+      let json = "{\"usageInputTokens\":12,\"usageOutputTokens\":3}"+      eitherDecode json+        `shouldBe` Right (mkUsage 12 3)+   describe "estimateCost" $ do     it "calculates cost in dollars from per-million pricing" $ do-      let pricing = PricingInfo {pricePerMillionInput = 1.0, pricePerMillionOutput = 5.0}-          usage = Usage 1_000_000 1_000_000 0+      let pricing = defaultPricingInfo 1.0 5.0+          usage = mkUsage 1_000_000 1_000_000       estimateCost pricing usage `shouldBe` 6.0      it "returns 0 for zero usage" $ do-      let pricing = PricingInfo {pricePerMillionInput = 1.0, pricePerMillionOutput = 5.0}+      let pricing = defaultPricingInfo 1.0 5.0       estimateCost pricing emptyUsage `shouldBe` 0.0 +    it "prices cache read/write separately when rates are set" $ do+      let pricing =+            PricingInfo+              { pricePerMillionInput = 3.0,+                pricePerMillionOutput = 15.0,+                pricePerMillionCacheRead = Just 0.3,+                pricePerMillionCacheWrite = Just 3.75+              }+          usage =+            Usage+              { usageInputTokens = 1_000_000,+                usageOutputTokens = 0,+                usageCacheReadTokens = 400_000,+                usageCacheCreationTokens = 100_000,+                usageTotalCost = 0+              }+      -- ordinary 500k * 3 + read 400k * 0.3 + write 100k * 3.75 = 1.5 + 0.12 + 0.375+      estimateCost pricing usage `shouldBe` 1.995+      usageOrdinaryInputTokens usage `shouldBe` 500_000++    it "falls back to input rate when cache rates are absent" $ do+      let pricing = defaultPricingInfo 2.0 0.0+          usage =+            Usage+              { usageInputTokens = 1_000_000,+                usageOutputTokens = 0,+                usageCacheReadTokens = 250_000,+                usageCacheCreationTokens = 250_000,+                usageTotalCost = 0+              }+      estimateCost pricing usage `shouldBe` 2.0++  describe "PricingInfo JSON" $ do+    it "decodes catalogs without cache rates" $ do+      let json = "{\"pricePerMillionInput\":1.0,\"pricePerMillionOutput\":5.0}"+      eitherDecode json `shouldBe` Right (defaultPricingInfo 1.0 5.0)++    it "round-trips optional cache rates" $ do+      let pricing =+            PricingInfo+              { pricePerMillionInput = 3.0,+                pricePerMillionOutput = 15.0,+                pricePerMillionCacheRead = Just 0.3,+                pricePerMillionCacheWrite = Just 3.75+              }+      eitherDecode (encode pricing) `shouldBe` Right pricing+   describe "hasToolCalls / getToolCalls" $ do     it "returns False for text-only response" $ do-      let resp = ChatResponse "hello" [TextBlock "hello"] Nothing Nothing+      let resp = ChatResponse "hello" [textPart "hello"] Nothing Nothing       hasToolCalls resp `shouldBe` False       getToolCalls resp `shouldBe` []      it "returns True when tool calls present" $ do       let tc = mkToolCall "id1" "get_weather" (object ["location" .= ("London" :: String)])-          resp = ChatResponse "" [ToolCallBlock tc] Nothing Nothing+          resp = ChatResponse "" [toolCallPart tc] Nothing Nothing       hasToolCalls resp `shouldBe` True       getToolCalls resp `shouldBe` [tc]++  describe "cache hints" $ do+    it "round-trips CacheEphemeral on ContentPart / Turn JSON" $ do+      let part = cacheEphemeral (textPart "cached prefix")+          turn = UserMessage [part, textPart "question"]+      eitherDecode (encode part)+        `shouldBe` Right (ContentPart (TextPart "cached prefix") (Just CacheEphemeral))+      eitherDecode (encode turn) `shouldBe` Right turn++    it "UserTurn does not match a cache-annotated single text part" $ do+      let annotated = UserMessage [cacheEphemeral (textPart "hi")]+      case annotated of+        UserTurn _ -> expectationFailure "UserTurn should not match annotated text"+        UserMessage [ContentPart (TextPart "hi") (Just CacheEphemeral)] -> pure ()+        other -> expectationFailure $ "unexpected: " <> show other++  describe "UserTurn / validateTurn" $ do+    it "UserTurn constructs a single text user message" $ do+      UserTurn "hi" `shouldBe` UserMessage [textPart "hi"]++    it "rejects thinking parts on user messages" $ do+      validateTurn+        ( UserMessage+            [thinkingPart (ThinkingContent (Just "nope") Nothing)]+        )+        `shouldBe` Left "user messages may not contain thinking parts"++    it "allows image parts on user messages" $ do+      validateTurn+        ( UserMessage+            [imageUrlPart "https://example.com/a.png", textPart "look"]+        )+        `shouldBe` Right ()++    it "rejects image parts on assistant messages" $ do+      validateTurn+        (AssistantMessage [imageUrlPart "https://example.com/a.png"])+        `shouldBe` Left "assistant messages may not contain image parts"++  describe "imageBase64Part" $ do+    it "accepts valid png base64" $ do+      imageBase64Part "image/png" "aGVsbG8=" `shouldSatisfy` either (const False) (const True)++    it "rejects unsupported MIME types" $ do+      case imageBase64Part "image/svg+xml" "aGVsbG8=" of+        Left msg -> msg `shouldSatisfy` T.isInfixOf "unsupported image media type"+        Right _ -> fail "expected MIME failure"++    it "rejects empty base64" $ do+      imageBase64Part "image/png" "   " `shouldBe` Left "image base64 data must not be empty"++    it "rejects invalid base64 characters" $ do+      imageBase64Part "image/png" "!!!!" `shouldBe` Left "image data is not valid base64"    describe "isRetryable" $ do     it "retries on 429" $ do