packages feed

baikai-openai-0.5.0.0: test/ShapeSpec.hs

{-# LANGUAGE LambdaCase #-}

module ShapeSpec (tests) where

import Baikai
import Baikai.Content qualified as Content
import Baikai.Models.Generated qualified as Models
import Baikai.Provider.OpenAI.Api
  ( RawChunk (..),
    RawToolDelta (..),
    closeOpenStream,
    emptyAssembler,
    translate,
  )
import Baikai.Provider.OpenAI.Internal.Request (mapRequest)
import Baikai.Provider.OpenAI.Shape (streamRequestBody)
import Control.Lens ((&), (.~))
import Data.Aeson (Value (..), (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as AesonKey
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Generics.Labels ()
import Data.Text qualified as Text
import Data.Time.Clock (UTCTime)
import Data.Vector qualified as Vector
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "ShapeSpec"
    [ deepseekShapeTest,
      nativeHigherEffortTests,
      compatibleHigherEffortClampTest,
      translationTableTests,
      nativeVersusCompatibleTests,
      openRouterCacheControlTest,
      strictModeGateTest,
      usageStreamingGateTest,
      zeroCapOmissionTest,
      indexlessToolDeltaTest
    ]

deepseekShapeTest :: TestTree
deepseekShapeTest =
  testCase "DeepSeek request body uses max_tokens and reasoning shape" $ do
    value <-
      shapedBody
        Models.deepseek_deepseek_chat
        (emptyOptions & #thinking .~ Just ThinkingHigh)
        emptyContext
    lookupTop "max_completion_tokens" value @?= Nothing
    lookupTop "max_tokens" value @?= Just (Number 8192)
    lookupTop "thinking" value
      @?= Just (Aeson.object ["type" .= ("enabled" :: Text.Text)])
    lookupTop "reasoning_effort" value @?= Just (String "high")

nativeHigherEffortTests :: TestTree
nativeHigherEffortTests =
  testGroup
    "native OpenAI higher reasoning effort"
    [ testCase name $ do
        value <-
          shapedBody
            Models.openai_gpt_5_6_terra
            (emptyOptions & #thinking .~ Just level)
            emptyContext
        lookupTop "reasoning_effort" value @?= Just (String expected)
    | (name, level, expected) <-
        [ ("xhigh survives SDK staging", ThinkingXHigh, "xhigh"),
          ("max survives SDK staging", ThinkingMax, "max")
        ]
    ]

compatibleHigherEffortClampTest :: TestTree
compatibleHigherEffortClampTest =
  testCase "OpenAI-compatible higher reasoning effort clamps to high" $ do
    value <-
      shapedBody
        Models.deepseek_deepseek_chat
        (emptyOptions & #thinking .~ Just ThinkingMax)
        emptyContext
    lookupTop "reasoning_effort" value @?= Just (String "high")

-- ============================================================
-- The forty-two-row translation table
-- ============================================================

-- | Every canonical level against every wire shape: what goes on the
-- wire, and what the evidence record says went on it.
--
-- Both halves are asserted on every row. Checking only the description
-- would let it drift away from the request it claims to describe, which
-- is the one failure this whole record exists to prevent.
translationTableTests :: TestTree
translationTableTests =
  testGroup
    "thinking translation across all seven wire shapes"
    [ testCase (shapeName fmt <> " at " <> Text.unpack (renderThinkingLevel lvl)) $ do
        (body, translation) <-
          shapedCall (hostWith fmt) (emptyOptions & #thinking .~ Just lvl) emptyContext
        translation @?= expected
        mapM_ (\(k, v) -> lookupTop k body @?= Just v) present
        mapM_ (\k -> lookupTop k body @?= Nothing) absent
    | (lvl, nativeWord, compatWord, clamps) <- effortRows,
      fmt <- everyThinkingFormat,
      let (expected, present, absent) = expectationFor fmt lvl nativeWord compatWord clamps
    ]

-- | The seven shapes, listed so a new constructor added to
-- 'ThinkingFormat' shows up here as a missing case in 'expectationFor'
-- and 'shapeName' rather than as a silently untested shape.
everyThinkingFormat :: [ThinkingFormat]
everyThinkingFormat =
  [ ThinkingFormatOpenAI,
    ThinkingFormatOpenRouter,
    ThinkingFormatDeepseek,
    ThinkingFormatTogether,
    ThinkingFormatZai,
    ThinkingFormatQwen,
    ThinkingFormatNone
  ]

-- | The exact effort word each of the two vocabularies sends for each
-- canonical level, and the adjustment a clamping vocabulary records.
--
-- Every value is written out rather than computed from the code under
-- test, so this is an independent statement of the intended behaviour
-- and not a second copy of the implementation. The native column never
-- clamps: it forwards the canonical name, which is exactly what an
-- empty adjustment list means.
effortRows :: [(ThinkingLevel, Text.Text, Text.Text, [ThinkingAdjustment])]
effortRows =
  [ (ThinkingMinimal, "minimal", "low", [EffortClamped ThinkingMinimal "low"]),
    (ThinkingLow, "low", "low", []),
    (ThinkingMedium, "medium", "medium", []),
    (ThinkingHigh, "high", "high", []),
    (ThinkingXHigh, "xhigh", "high", [EffortClamped ThinkingXHigh "high"]),
    (ThinkingMax, "max", "high", [EffortClamped ThinkingMax "high"])
  ]

-- | The translation, the body keys that must be present, and the body
-- keys that must be absent, for one shape at one level.
expectationFor ::
  ThinkingFormat ->
  ThinkingLevel ->
  -- | The word the native vocabulary sends.
  Text.Text ->
  -- | The word the compatible vocabulary sends.
  Text.Text ->
  -- | The adjustment the compatible vocabulary records, if any.
  [ThinkingAdjustment] ->
  (ThinkingTranslation, [(Text.Text, Value)], [Text.Text])
expectationFor fmt lvl nativeWord compatWord clamps = case fmt of
  ThinkingFormatOpenAI ->
    ( adaptiveTranslation lvl nativeWord "reasoning_effort" [],
      [("reasoning_effort", String nativeWord)],
      ["reasoning", "thinking", "enable_thinking"]
    )
  ThinkingFormatOpenRouter ->
    ( adaptiveTranslation lvl compatWord "reasoning" clamps,
      [("reasoning", Aeson.object ["effort" .= compatWord])],
      ["reasoning_effort", "thinking", "enable_thinking"]
    )
  ThinkingFormatDeepseek ->
    ( adaptiveTranslation lvl compatWord "reasoning_effort" clamps,
      [ ("reasoning_effort", String compatWord),
        ("thinking", Aeson.object ["type" .= ("enabled" :: Text.Text)])
      ],
      ["reasoning", "enable_thinking"]
    )
  ThinkingFormatTogether ->
    ( adaptiveTranslation lvl compatWord "reasoning_effort" clamps,
      [ ("reasoning_effort", String compatWord),
        ("reasoning", Aeson.object ["enabled" .= True])
      ],
      ["thinking", "enable_thinking"]
    )
  ThinkingFormatZai -> collapsed
  ThinkingFormatQwen -> collapsed
  ThinkingFormatNone ->
    ( ThinkingTranslation
        { requested = Just lvl,
          mode = ThinkingModeUnsupported,
          effortText = Nothing,
          budgetTokens = Nothing,
          wireField = Nothing,
          adjustments = [ThinkingDroppedUnsupportedHost lvl]
        },
      [],
      ["reasoning_effort", "reasoning", "thinking", "enable_thinking"]
    )
  where
    -- Z.ai and Qwen carry no depth at all, so every level collapses --
    -- including the ones a richer host would have accepted verbatim.
    collapsed =
      ( ThinkingTranslation
          { requested = Just lvl,
            mode = ThinkingModeToggle,
            effortText = Nothing,
            budgetTokens = Nothing,
            wireField = Just "enable_thinking",
            adjustments = [EffortCollapsedToToggle lvl]
          },
        [("enable_thinking", Bool True)],
        ["reasoning_effort", "reasoning", "thinking"]
      )

adaptiveTranslation ::
  ThinkingLevel -> Text.Text -> Text.Text -> [ThinkingAdjustment] -> ThinkingTranslation
adaptiveTranslation lvl wire field adjs =
  ThinkingTranslation
    { requested = Just lvl,
      mode = ThinkingModeAdaptive,
      effortText = Just wire,
      budgetTokens = Nothing,
      wireField = Just field,
      adjustments = adjs
    }

shapeName :: ThinkingFormat -> String
shapeName = \case
  ThinkingFormatOpenAI -> "openai-native"
  ThinkingFormatOpenRouter -> "openrouter"
  ThinkingFormatDeepseek -> "deepseek"
  ThinkingFormatTogether -> "together"
  ThinkingFormatZai -> "zai"
  ThinkingFormatQwen -> "qwen"
  ThinkingFormatNone -> "no-reasoning-controls"

-- | A reasoning-capable model pinned to one wire shape, so the table
-- exercises a shape rather than whichever host a catalog entry happens
-- to point at.
hostWith :: ThinkingFormat -> Model
hostWith fmt =
  Models.openai_gpt_5_6_terra
    & #compat
      .~ CompatOpenAICompletions
        defaultOpenAICompletionsCompat {thinkingFormat = fmt}

-- | The same request against a native host and against a clamping one,
-- written side by side because the contrast is the design.
--
-- The native rows are the ones that look wrong at a glance and are not:
-- `xhigh` and `max` reach the wire intact and the translation records no
-- adjustment, because nothing was adjusted. Clamping them here would
-- silently weaken every high-effort request against a current OpenAI
-- model.
nativeVersusCompatibleTests :: TestTree
nativeVersusCompatibleTests =
  testGroup
    "the native vocabulary forwards what the compatible one clamps"
    [ testCase "native xhigh reaches the wire and adjusts nothing" $
        assertEffort Models.openai_gpt_5_6_terra ThinkingXHigh "xhigh" [],
      testCase "deepseek xhigh clamps to high and records it" $
        assertEffort
          Models.deepseek_deepseek_chat
          ThinkingXHigh
          "high"
          [EffortClamped ThinkingXHigh "high"],
      testCase "native max reaches the wire and adjusts nothing" $
        assertEffort Models.openai_gpt_5_6_terra ThinkingMax "max" [],
      testCase "deepseek max clamps to high and records it" $
        assertEffort
          Models.deepseek_deepseek_chat
          ThinkingMax
          "high"
          [EffortClamped ThinkingMax "high"]
    ]
  where
    assertEffort model lvl wire adjs = do
      (body, translation) <-
        shapedCall model (emptyOptions & #thinking .~ Just lvl) emptyContext
      lookupTop "reasoning_effort" body @?= Just (String wire)
      effortText translation @?= Just wire
      adjustments translation @?= adjs

openRouterCacheControlTest :: TestTree
openRouterCacheControlTest =
  testCase "OpenRouter cache marker lands on the system content part with ttl" $ do
    let ctx =
          emptyContext
            & #systemPrompt .~ Just "cache this prefix"
            & #messages .~ Vector.singleton (user "answer")
        opts = emptyOptions & #cacheRetention .~ Just CacheRetentionLong
    value <- shapedBody Models.openrouter_openai_gpt_4o_mini opts ctx
    systemCacheControl value
      @?= Just
        ( Aeson.object
            [ "type" .= ("ephemeral" :: Text.Text),
              "ttl" .= ("1h" :: Text.Text)
            ]
        )

strictModeGateTest :: TestTree
strictModeGateTest =
  testCase "supportsStrictMode gates response_format json_schema strict" $ do
    let schema = Aeson.object ["type" .= ("object" :: Text.Text)]
        opts =
          emptyOptions
            & #responseFormat
              .~ Just (JsonSchema {name = "shape", schema = schema, strict = True})
    value <- shapedBody Models.deepseek_deepseek_chat opts emptyContext
    lookupPath ["response_format", "json_schema", "strict"] value
      @?= Nothing

usageStreamingGateTest :: TestTree
usageStreamingGateTest =
  testCase "supportsUsageInStreaming gates stream_options" $ do
    let compat = defaultOpenAICompletionsCompat {supportsUsageInStreaming = False}
        model =
          Models.openai_gpt_4o_mini
            & #compat .~ CompatOpenAICompletions compat
    value <- shapedBody model emptyOptions emptyContext
    lookupTop "stream" value @?= Just (Bool True)
    lookupTop "stream_options" value @?= Nothing

zeroCapOmissionTest :: TestTree
zeroCapOmissionTest =
  testCase "unknown zero maxOutputTokens omits max_completion_tokens" $ do
    let model =
          emptyModel
            & #modelId .~ "custom"
            & #api .~ OpenAIChatCompletions
            & #provider .~ "custom"
            & #maxOutputTokens .~ 0
    req <- either (assertFailure . Text.unpack) pure (mapRequest model emptyContext emptyOptions)
    lookupTop "max_completion_tokens" (Aeson.toJSON req) @?= Nothing

indexlessToolDeltaTest :: TestTree
indexlessToolDeltaTest =
  testCase "id-bearing index-less tool deltas remain separate" $ do
    let chunks =
          [ emptyChunk
              { toolDeltas =
                  [ RawToolDelta
                      { index = Nothing,
                        id_ = Just "call_a",
                        name = Just "first",
                        args = Just "{\"a\":"
                      },
                    RawToolDelta
                      { index = Nothing,
                        id_ = Just "call_b",
                        name = Just "second",
                        args = Just "{\"b\":"
                      }
                  ]
              },
            emptyChunk
              { toolDeltas =
                  [ RawToolDelta
                      { index = Nothing,
                        id_ = Just "call_a",
                        name = Nothing,
                        args = Just "1}"
                      },
                    RawToolDelta
                      { index = Nothing,
                        id_ = Just "call_b",
                        name = Nothing,
                        args = Just "2}"
                      }
                  ]
              },
            emptyChunk {finishReason = Just "tool_calls"}
          ]
        events = runChunks chunks
        toolCalls =
          [ toolCall
          | ToolCallEnd ToolCallEndPayload {toolCall = toolCall} <- events
          ]
    fmap Content.id_ toolCalls @?= ["call_a", "call_b"]
    fmap Content.name toolCalls @?= ["first", "second"]
    fmap Content.arguments toolCalls
      @?= [ Aeson.object ["a" .= (1 :: Int)],
            Aeson.object ["b" .= (2 :: Int)]
          ]

shapedBody :: Model -> Options -> Context -> IO Value
shapedBody model opts ctx = fst <$> shapedCall model opts ctx

-- | The shaped request body together with the description of what the
-- caller's reasoning-effort preference became inside it.
shapedCall :: Model -> Options -> Context -> IO (Value, ThinkingTranslation)
shapedCall model opts ctx = do
  req <- either (assertFailure . Text.unpack) pure (mapRequest model ctx opts)
  pure (streamRequestBody (openaiCompletionsCompatFor model) opts req)

lookupTop :: Text.Text -> Value -> Maybe Value
lookupTop field = lookupPath [field]

lookupPath :: [Text.Text] -> Value -> Maybe Value
lookupPath [] value = Just value
lookupPath (field : rest) (Object obj) =
  KeyMap.lookup (AesonKey.fromText field) obj >>= lookupPath rest
lookupPath _ _ = Nothing

systemCacheControl :: Value -> Maybe Value
systemCacheControl value = do
  Array messages <- lookupTop "messages" value
  systemMessage <-
    firstMay
      [ msg
      | msg@(Object _) <- Vector.toList messages,
        lookupPath ["role"] msg == Just (String "system")
      ]
  Array content <- lookupPath ["content"] systemMessage
  contentPart <- lastMay (Vector.toList content)
  lookupPath ["cache_control"] contentPart

firstMay :: [a] -> Maybe a
firstMay [] = Nothing
firstMay (x : _) = Just x

lastMay :: [a] -> Maybe a
lastMay [] = Nothing
lastMay xs = Just (last xs)

emptyChunk :: RawChunk
emptyChunk =
  RawChunk
    { contentDelta = Nothing,
      reasoningDelta = Nothing,
      finishReason = Nothing,
      toolDeltas = [],
      usage = Nothing,
      model = Nothing,
      responseId = Nothing
    }

runChunks :: [RawChunk] -> [AssistantMessageEvent]
runChunks chunks =
  let (events, ass) =
        foldl
          ( \(acc, st) chunk ->
              let (newEvents, st') = translate (Right chunk) st testTime
               in (acc <> newEvents, st')
          )
          ([], emptyAssembler Models.openai_gpt_4o_mini testTime)
          chunks
      (terminalEvents, _) = closeOpenStream testTime Nothing ass
   in events <> terminalEvents

testTime :: UTCTime
testTime = read "2026-07-03 12:00:00 UTC"