llm-simple-0.3.0.0: test/LLM/CrossProviderSpec.hs
-- | Mixed-provider history regression test.
--
-- Fallback happens per model call, so one agent run can contain assistant
-- turns from several providers. This feeds one realistic mixed history
-- through every provider's 'buildBody' and checks the rules the live APIs
-- enforce (see .notes/fallback-fix-plan.md, items 2–4).
module LLM.CrossProviderSpec (spec) where
import Data.Aeson (Value (Array, Object, String), encode, object, (.=))
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Vector qualified as V
import LLM.Core.LLMProvider (LLMProvider (..))
import LLM.Core.Types
( ChatRequest (..),
ProviderOpaque (..),
ThinkingContent (..),
ThinkingMode (..),
ToolCall (..),
ToolResult (..),
Turn (..),
imageBase64Part,
mkToolCall,
textPart,
thinkingPart,
toolCallPart,
pattern UserTurn,
)
import LLM.Providers.Claude (claudeProvider)
import LLM.Providers.DeepSeek (deepSeekProvider)
import LLM.Providers.Gemini (geminiProvider)
import LLM.Providers.Ollama (ollamaProvider)
import LLM.Providers.OpenAI (openAIProvider)
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldSatisfy)
-- Fake opaque payloads; none may reach another provider's request.
claudeSig, claudeRedacted, geminiThoughtSig, geminiCallSig :: Text
claudeSig = "FAKE_CLAUDE_THINKING_SIGNATURE"
claudeRedacted = "FAKE_CLAUDE_REDACTED_DATA"
geminiThoughtSig = "FAKE_GEMINI_THOUGHT_SIGNATURE"
geminiCallSig = "FAKE_GEMINI_CALL_SIGNATURE"
-- | One run: Claude → Gemini → OpenAI → Claude, then the next user turn.
-- Each assistant turn has the shape that provider's parser produces.
mixedHistory :: [Turn]
mixedHistory =
[ -- 1. user text + base64 png
UserMessage [textPart "What's in this picture, and what's the weather?", png],
-- 2. Claude: thinking + text + tool call
AssistantMessage
[ thinkingPart
ThinkingContent
{ thinkingText = Just "claude thoughts",
thinkingOpaque =
Just
ProviderOpaque
{ poProvider = "claude",
poModel = Just "claude-haiku-4-5-20251001",
poPayload =
object
[ "type" .= ("thinking" :: Text),
"thinking" .= ("claude thoughts" :: Text),
"signature" .= claudeSig
]
}
},
textPart "Let me check the weather.",
toolCallPart (mkToolCall "toolu_01AbCdEf" "get_weather" (object ["location" .= ("London" :: Text)]))
],
-- 3.
ToolTurn [ToolResult "toolu_01AbCdEf" "get_weather" "Sunny, 22°C"],
-- 4. Gemini: thought part + function call with Gemini metadata
-- (the Gemini parser uses the function name as the call id).
AssistantMessage
[ thinkingPart
ThinkingContent
{ thinkingText = Just "gemini thoughts",
thinkingOpaque =
Just
ProviderOpaque
{ poProvider = "gemini",
poModel = Just "gemini-2.5-flash",
poPayload =
object
[ "text" .= ("gemini thoughts" :: Text),
"thought" .= True,
"thoughtSignature" .= geminiThoughtSig
]
}
},
toolCallPart
(mkToolCall "get_weather" "get_weather" (object ["location" .= ("Paris" :: Text)]))
{ tcProviderMeta =
Just
ProviderOpaque
{ poProvider = "gemini",
poModel = Just "gemini-2.5-flash",
poPayload = object ["thoughtSignature" .= geminiCallSig]
}
}
],
-- 5.
ToolTurn [ToolResult "get_weather" "get_weather" "Rainy, 14°C"],
-- 6. OpenAI: tool call only
AssistantMessage [toolCallPart (mkToolCall "call_aBc123" "get_time" (object []))],
-- 7.
ToolTurn [ToolResult "call_aBc123" "get_time" "12:00"],
-- 8. Claude: only redacted thinking
AssistantMessage
[ thinkingPart
ThinkingContent
{ thinkingText = Nothing,
thinkingOpaque =
Just
ProviderOpaque
{ poProvider = "claude",
poModel = Just "claude-haiku-4-5-20251001",
poPayload =
object
[ "type" .= ("redacted_thinking" :: Text),
"data" .= claudeRedacted
]
}
}
],
UserTurn "Thanks. Anything else?"
]
where
png = either (error . show) id (imageBase64Part "image/png" "iVBORw0KGgo=")
mkReq :: Text -> Maybe ThinkingMode -> ChatRequest
mkReq model thinking =
ChatRequest
{ reqModel = model,
reqConversation = mixedHistory,
reqSystem = Just "You are helpful.",
reqMaxTokens = 4096,
reqTemperature = Nothing,
reqTools = [],
reqThinking = thinking
}
thinkingOn, thinkingOff :: Maybe ThinkingMode
thinkingOn = Just ThinkingMode {tmEnabled = True, tmEffort = Nothing}
thinkingOff = Just ThinkingMode {tmEnabled = False, tmEffort = Nothing}
claudeBody, geminiBody, openAIBody, ollamaBody, deepSeekThinkingBody, deepSeekPlainBody :: Value
claudeBody = (claudeProvider "").buildBody False (mkReq "claude-haiku-4-5" Nothing)
geminiBody = (geminiProvider "").buildBody False (mkReq "gemini-3.1-flash-lite" Nothing)
openAIBody = (openAIProvider "").buildBody False (mkReq "gpt-4.1" Nothing)
ollamaBody = ollamaProvider.buildBody False (mkReq "qwen2.5vl" Nothing)
deepSeekThinkingBody = (deepSeekProvider "").buildBody False (mkReq "deepseek-v4-flash" thinkingOn)
deepSeekPlainBody = (deepSeekProvider "").buildBody False (mkReq "deepseek-v4-flash" thinkingOff)
openAIProtocolBodies :: [(String, Value)]
openAIProtocolBodies =
[ ("OpenAI", openAIBody),
("Ollama", ollamaBody),
("DeepSeek (thinking)", deepSeekThinkingBody),
("DeepSeek (no thinking)", deepSeekPlainBody)
]
spec :: Spec
spec = describe "Cross-provider history" $ do
describe "Gemini" $ do
it "sends a thoughtSignature on every functionCall part" $ do
let parts = concatMap (arrayAt "parts") (arrayAt "contents" geminiBody)
calls = filter (isJust . key "functionCall") parts
length calls `shouldBe` 3
mapM_ (\p -> key "thoughtSignature" p `shouldSatisfy` isJust) calls
describe "DeepSeek with thinking" $ do
it "sends reasoning_content on every assistant message" $ do
let msgs = assistantMessages deepSeekThinkingBody
length msgs `shouldBe` 4
mapM_ (\m -> key "reasoning_content" m `shouldSatisfy` isJust) msgs
describe "OpenAI-protocol providers" $
mapM_
( \(name, body) ->
it (name <> ": every assistant message has content or tool_calls") $ do
let msgs = assistantMessages body
length msgs `shouldBe` 4
mapM_ (\m -> m `shouldSatisfy` \v -> isJust (key "content" v) || isJust (key "tool_calls" v)) msgs
)
openAIProtocolBodies
describe "Claude" $ do
it "sends only Claude thinking blocks" $ do
let blocks = concatMap (arrayAt "content") (assistantMessages' "messages" claudeBody)
thinking = filter (\b -> textAt "type" b `elem` [Just "thinking", Just "redacted_thinking"]) blocks
map (\b -> (textAt "type" b, textAt "signature" b, textAt "data" b)) thinking
`shouldBe` [ (Just "thinking", Just claudeSig, Nothing),
(Just "redacted_thinking", Nothing, Just claudeRedacted)
]
it "pairs every tool_use with a tool_result in the next message" $ do
let msgs = arrayAt "messages" claudeBody
blockIds ty field m = [i | b <- arrayAt "content" m, textAt "type" b == Just ty, Just i <- [textAt field b]]
pairs = zip msgs (drop 1 msgs)
checked =
[ (useIds, blockIds "tool_result" "tool_use_id" next)
| (m, next) <- pairs,
textAt "role" m == Just "assistant",
let useIds = blockIds "tool_use" "id" m,
not (null useIds)
]
length checked `shouldBe` 3
mapM_ (\(useIds, resultIds) -> resultIds `shouldBe` useIds) checked
describe "opaque payloads" $ do
let claudeSecrets = [claudeSig, claudeRedacted]
geminiSecrets = [geminiThoughtSig, geminiCallSig]
others = [("Claude", claudeBody), ("Gemini", geminiBody)] ++ openAIProtocolBodies
mapM_
( \(name, body) ->
it (name <> ": contains no other provider's opaque payload") $ do
let foreignSecrets = case name of
"Claude" -> geminiSecrets
"Gemini" -> claudeSecrets
_ -> claudeSecrets ++ geminiSecrets
leaked = filter (`containedIn` body) foreignSecrets
leaked `shouldBe` []
)
others
it "Claude still replays its own signatures (sanity check of the search)" $
if all (`containedIn` claudeBody) claudeSecrets
then pure ()
else expectationFailure "Claude signatures missing from Claude request"
-- JSON helpers
containedIn :: Text -> Value -> Bool
containedIn needle v = encodeUtf8 needle `BS.isInfixOf` BSL.toStrict (encode v)
key :: Text -> Value -> Maybe Value
key k (Object o) = KM.lookup (K.fromText k) o
key _ _ = Nothing
textAt :: Text -> Value -> Maybe Text
textAt k v = case key k v of
Just (String t) -> Just t
_ -> Nothing
arrayAt :: Text -> Value -> [Value]
arrayAt k v = case key k v of
Just (Array a) -> V.toList a
_ -> []
assistantMessages :: Value -> [Value]
assistantMessages = assistantMessages' "messages"
assistantMessages' :: Text -> Value -> [Value]
assistantMessages' field body =
filter (\m -> textAt "role" m == Just "assistant") (arrayAt field body)