packages feed

baikai-openai-0.7.0.0: test/ResponsesSpec.hs

{-# LANGUAGE OverloadedRecordDot #-}

module ResponsesSpec (tests) where

import Baikai hiding (model, schema)
import Baikai.Models.Generated (openai_gpt_6_astra)
import Baikai.Provider.OpenAI.Responses.Request qualified as R
import Control.Lens ((&), (.~))
import Control.Monad (forM_)
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector qualified as V
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Responses request mapping"
    [ testCase "stateless request carries text, image, system, cap and metadata" $ do
        let ctx =
              (systemUser "system instruction" "hello")
                & #messages .~ V.singleton (UserMessage UserPayload {content = V.fromList [UserText (TextContent "hello"), UserImage (ImageContent "abc" "image/png")], timestamp = Nothing})
            opts = emptyOptions & #maxTokens .~ Just 321 & #metadata .~ Map.singleton "test" (String "value")
        req <- mapped model ctx opts
        field "model" req.requestBody @?= Just (String "renamed-responses-model")
        field "instructions" req.requestBody @?= Just (String "system instruction")
        field "store" req.requestBody @?= Just (Bool False)
        field "stream" req.requestBody @?= Just (Bool True)
        field "max_output_tokens" req.requestBody @?= Just (Number 321)
        field "include" req.requestBody @?= Just (Aeson.toJSON (["reasoning.encrypted_content"] :: [Text]))
        let rendered = Text.pack (show (field "input" req.requestBody))
        assertBool "image encoded inline" ("data:image/png;base64,YWJj" `Text.isInfixOf` rendered)
        assertBool "text is present" ("hello" `Text.isInfixOf` rendered)
        field "metadata" req.requestBody @?= Just (object ["test" .= ("value" :: Text)]),
      testCase "assistant plain text has a valid easy-message input shape" $ do
        req <- mapped model (contextOf [assistant "previous answer"]) emptyOptions
        inputItems req @?= [object ["role" .= ("assistant" :: Text), "content" .= ("previous answer" :: Text)]],
      testCase "empty summary and encrypted items persist into the next tool request" $ do
        let decoded = Aeson.eitherDecode (Aeson.encode thought)
        persisted <- either assertFailure pure decoded
        let response =
              emptyResponse
                & #message . #content .~ V.fromList [AssistantThinking persisted, AssistantToolCall (ToolCall "call_7" "lookup" (object ["x" .= (1 :: Int)]))]
                & #message . #stopReason .~ ToolUse
            ctx = contextOf [user "go"] & #tools .~ V.singleton tool
        next <- appendToolResult ctx response (\_ -> pure (toolResultText "found"))
        req <- mapped model next emptyOptions
        let items = inputItems req
        take 1 (drop 1 items) @?= [reasoningItem]
        field "call_id" (items !! 2) @?= Just (String "call_7")
        field "type" (items !! 2) @?= Just (String "function_call")
        field "call_id" (items !! 3) @?= Just (String "call_7")
        field "output" (items !! 3) @?= Just (String "found")
        field "previous_response_id" req.requestBody @?= Nothing
        req.translation @?= R.describeThinking model emptyOptions,
      testCase "every accepted effort survives; minimal adjusts with evidence" $ do
        forM_ [ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh, ThinkingXHigh, ThinkingMax] $ \level -> do
          let opts = emptyOptions & #thinking .~ Just level
              expected = if level == ThinkingMinimal then "low" else renderThinkingLevel level
          req <- mapped model emptyContext opts
          field "reasoning" req.requestBody @?= Just (object ["effort" .= expected])
          req.translation @?= R.describeThinking model opts
          req.translation.adjustments @?= [EffortClamped ThinkingMinimal "low" | level == ThinkingMinimal],
      testCase "sampling restriction is visible even without thinking" $ do
        let opts = emptyOptions & #temperature .~ Just 0.5 & #topP .~ Just 0.8
        req <- mapped model emptyContext opts
        field "temperature" req.requestBody @?= Nothing
        field "top_p" req.requestBody @?= Nothing
        req.translation.adjustments @?= [SamplingDroppedUnsupportedModel ["temperature", "top_p"]]
        supported <- mapped (model & #compat .~ CompatOpenAIResponses defaultOpenAIResponsesCompat) emptyContext opts
        field "temperature" supported.requestBody @?= Just (Number 0.5),
      testCase "function tools keep permissive schemas and supported choices" $ do
        let ctx = emptyContext & #tools .~ V.singleton tool
        forM_ [ToolChoiceAuto, ToolChoiceNone, ToolChoiceRequired, ToolChoiceSpecific "lookup"] $ \choice -> do
          req <- mapped model ctx (emptyOptions & #toolChoice .~ Just choice)
          field "tool_choice" req.requestBody @?= case choice of
            ToolChoiceAuto -> Nothing
            ToolChoiceNone -> Just (String "none")
            ToolChoiceRequired -> Just (String "required")
            ToolChoiceSpecific name -> Just (object ["type" .= ("function" :: Text), "name" .= name])
          case field "tools" req.requestBody of
            Just (Array tools) -> do
              field "type" (V.head tools) @?= Just (String "function")
              field "strict" (V.head tools) @?= Just (Bool False)
              field "parameters" (V.head tools) @?= Just schema
            _ -> assertFailure "missing tools"
        rejected emptyContext (emptyOptions & #toolChoice .~ Just ToolChoiceRequired)
        rejected ctx (emptyOptions & #toolChoice .~ Just (ToolChoiceSpecific "missing")),
      testCase "JSON schema and JSON object use Responses text.format" $ do
        strict <- mapped model emptyContext (emptyOptions & #responseFormat .~ Just (JsonSchema (jsonSchemaFormat "answer" schema & #strict .~ True)))
        field "text" strict.requestBody @?= Just (object ["format" .= object ["type" .= ("json_schema" :: Text), "name" .= ("answer" :: Text), "schema" .= schema, "strict" .= True]])
        plain <- mapped model emptyContext (emptyOptions & #responseFormat .~ Just JsonObject)
        field "text" plain.requestBody @?= Just (object ["format" .= object ["type" .= ("json_object" :: Text)]]),
      testCase "cache requests follow endpoint TTL contract" $ do
        req <- mapped model emptyContext (emptyOptions & #cacheRetention .~ Just CacheRetentionShort)
        field "prompt_cache_options" req.requestBody @?= Just (object ["ttl" .= ("30m" :: Text)])
        rejected emptyContext (emptyOptions & #cacheRetention .~ Just CacheRetentionLong),
      testCase "unsupported options fail instead of disappearing" $
        forM_ [emptyOptions & #seed .~ Just 1, emptyOptions & #stopSequences .~ ["stop"], emptyOptions & #frequencyPenalty .~ Just 1, emptyOptions & #presencePenalty .~ Just 1, emptyOptions & #metadata .~ Map.singleton "bad" (Number 1)] (rejected emptyContext),
      testCase "foreign, malformed and duplicate replay fails without exposing payload" $ do
        forM_ [replay & #replayApi .~ AnthropicMessages, replay & #replayModel .~ "other", replay & #replayItems .~ V.empty, replay & #replayItems .~ V.singleton (object []), replay & #replayItems .~ V.fromList [reasoningItem, reasoningItem]] $ \bad -> do
          let ctx = addResponse (emptyResponse & #message . #content .~ V.singleton (AssistantThinking (thought & #replayState .~ Just bad))) emptyContext
          case R.mapRequest model ctx emptyOptions of
            Left err -> assertBool "error contains no opaque data" (not ("SECRET" `Text.isInfixOf` err))
            Right _ -> assertFailure "invalid replay accepted",
      testCase "Anthropic state and incomplete calls cannot be replayed" $ do
        forM_ [AssistantThinking (emptyThinkingContent & #signature .~ Just "sig"), AssistantThinking emptyThinkingContent, AssistantToolCall (ToolCall "call_1" "lookup" (String "{"))] $ \block ->
          rejected (addResponse (emptyResponse & #message . #content .~ V.singleton block) emptyContext) emptyOptions
    ]
  where
    rejected ctx opts = case R.mapRequest model ctx opts of
      Left _ -> pure ()
      Right _ -> assertFailure "expected local rejection"

model :: Model
model =
  openai_gpt_6_astra
    & #api .~ OpenAIResponses
    & #modelId .~ "renamed-responses-model"
    & #compat
      .~ CompatOpenAIResponses
        ( defaultOpenAIResponsesCompat
            & #supportedReasoningEfforts .~ Just [ThinkingLow, ThinkingMedium, ThinkingHigh, ThinkingXHigh, ThinkingMax]
            & #supportsSamplingParameters .~ False
            & #supportsPromptCacheOptions .~ True
        )

schema :: Value
schema = object ["type" .= ("object" :: Text), "properties" .= object ["x" .= object ["type" .= ("integer" :: Text)]]]

tool :: Tool
tool = mkTool "lookup" "Look up x" schema

reasoningItem :: Value
reasoningItem = object ["type" .= ("reasoning" :: Text), "id" .= ("rs_7" :: Text), "summary" .= ([] :: [Value]), "encrypted_content" .= ("SECRET" :: Text), "status" .= ("completed" :: Text)]

replay :: ThinkingReplay
replay = ThinkingReplay OpenAIResponses "renamed-responses-model" (V.singleton reasoningItem)

thought :: ThinkingContent
thought = emptyThinkingContent & #replayState .~ Just replay

mapped :: Model -> Context -> Options -> IO R.PreparedRequest
mapped m ctx opts = either (assertFailure . Text.unpack) pure (R.mapRequest m ctx opts)

field :: Aeson.Key -> Value -> Maybe Value
field k (Object o) = KM.lookup k o
field _ _ = Nothing

inputItems :: R.PreparedRequest -> [Value]
inputItems req = case field "input" req.requestBody of
  Just (Array xs) -> V.toList xs
  _ -> []