packages feed

baikai-0.2.0.0: test/CostSpec.hs

module CostSpec (tests) where

import Baikai.Api (Api (..))
import Baikai.Content (AssistantContent (..), TextContent (..))
import Baikai.Context (Context (..), _Context)
import Baikai.Cost qualified as Cost
import Baikai.Cost.Log
  ( CallLogConfig (..),
    CallLogEntry,
    runRequestWithLog,
    withCallLog,
  )
import Baikai.Cost.Pricing (attachCost, computeCost)
import Baikai.Message (AssistantPayload (..), user)
import Baikai.Model (Model (..), ModelCost (..), _Model)
import Baikai.Options (Options, _Options)
import Baikai.Prelude
import Baikai.Provider
  ( ApiProvider (..),
    registerApiProvider,
  )
import Baikai.Response (Response (..), flattenAssistantBlocks)
import Baikai.StopReason (StopReason (..))
import Baikai.Stream (liftCompleteToStream)
import Baikai.Usage (Usage, _Usage)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as BSL
import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty)
import Data.Maybe (fromJust, isJust)
import Data.Vector qualified as V
import System.Directory (getTemporaryDirectory, removeFile)
import System.FilePath ((</>))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Baikai.Cost"
    [ computeTests,
      attachCostTests,
      callLogTests
    ]

-- Sample: 1000 input + 500 output tokens against a claude-haiku
-- pricing record (input $1/M, output $5/M) is exactly
-- 1000*(1/1_000_000) + 500*(5/1_000_000) = 7/2000 USD.
sampleUsage :: Usage
sampleUsage =
  _Usage
    & #inputTokens
    .~ 1000
    & #outputTokens
    .~ 500

-- Build a known-pricing model that matches the prior
-- claude-haiku-4-5-20251001 rates: input $1/M, output $5/M,
-- cache-read $0.10/M, cache-write $1.25/M.
knownModel :: Model
knownModel =
  _Model
    & #modelId
    .~ "claude-haiku-4-5-20251001"
    & #api
    .~ Custom "test"
    & #provider
    .~ "anthropic"
    & #cost
    .~ ModelCost
      { inputCost = 1,
        outputCost = 5,
        cacheReadCost = 1 / 10,
        cacheWriteCost = 5 / 4
      }

unknownModel :: Model
unknownModel =
  _Model
    & #modelId
    .~ "totally-fake-model"
    & #api
    .~ Custom "test"

computeTests :: TestTree
computeTests =
  testGroup
    "computeCost"
    [ testCase "deterministic cost for the known model" $
        Cost.usd (computeCost knownModel sampleUsage)
          @?= 7 / 2000,
      testCase "zero cost for unknown models" $
        Cost.usd (computeCost unknownModel sampleUsage)
          @?= 0,
      testCase "cacheReadTokens contribute when present" $ do
        let u :: Usage
            u =
              _Usage
                & #cacheReadTokens
                .~ 1000
        Cost.usd (computeCost knownModel u) @?= 1 / 10000,
      testCase "cacheWriteTokens contribute against the known model" $ do
        let u :: Usage
            u =
              _Usage
                & #cacheWriteTokens
                .~ 1000
        Cost.usd (computeCost knownModel u) @?= 1 / 800
    ]

attachCostTests :: TestTree
attachCostTests =
  testGroup
    "attachCost"
    [ testCase "fills the cost field on known models" $
        attachedUsd knownModel @?= 7 / 2000,
      testCase "leaves cost zero on unknown models" $
        attachedUsd unknownModel @?= 0,
      testCase "leaves the response's content alone" $ do
        let resp = attachCost knownModel (mkResp knownModel)
        flattenAssistantBlocks resp
          @?= V.singleton (AssistantText (TextContent "hi"))
    ]
  where
    attachedUsd m =
      let AssistantPayload {usage = u} = attachCost m (mkResp m) ^. #message
       in Cost.usd (u ^. #cost)
    mkResp m =
      Response
        { message =
            AssistantPayload
              { content = V.singleton (AssistantText (TextContent "hi")),
                usage = sampleUsage,
                stopReason = Stop,
                errorMessage = Nothing,
                timestamp = read "2026-05-14 00:00:00 UTC"
              },
          model = m,
          api = Custom "test",
          provider = "claude-api",
          responseId = Nothing,
          latencyMs = 100,
          errorInfo = Nothing
        }

-- Register a handler under a private API tag that returns a canned
-- response. Used by the call-log tests below.
cannedApi :: Api
cannedApi = Custom "baikai-cost-canned"

cannedHaiku :: Response
cannedHaiku =
  let u = sampleUsage & #cost .~ computeCost knownModel sampleUsage
   in Response
        { message =
            AssistantPayload
              { content = V.singleton (AssistantText (TextContent "ok")),
                usage = u,
                stopReason = Stop,
                errorMessage = Nothing,
                timestamp = read "2026-05-14 00:00:00 UTC"
              },
          model = knownModel & #api .~ cannedApi,
          api = cannedApi,
          provider = "canned",
          responseId = Nothing,
          latencyMs = 7,
          errorInfo = Nothing
        }

registerCanned :: Response -> IO ()
registerCanned resp =
  let handler _m _ctx _opts = pure resp
   in registerApiProvider
        ApiProvider
          { apiTag = cannedApi,
            stream = liftCompleteToStream handler,
            complete = handler
          }

cannedModel :: Model
cannedModel = knownModel & #api .~ cannedApi

ctxHello :: Context
ctxHello = _Context & #messages .~ V.fromList [user "Hello world"]

optsZero :: Options
optsZero = _Options

callLogTests :: TestTree
callLogTests =
  testGroup
    "CallLog"
    [ testCase "disabled handle skips disk I/O" $ do
        registerCanned cannedHaiku
        let cfg = CallLogConfig {path = "/dev/null", enabled = False}
        withCallLog cfg $ \h -> do
          resp <- runRequestWithLog h cannedModel ctxHello optsZero
          flattenAssistantBlocks resp
            @?= V.singleton (AssistantText (TextContent "ok")),
      testCase "enabled handle writes one JSONL record per call" $ do
        registerCanned cannedHaiku
        tmp <- getTemporaryDirectory
        let path' = tmp </> "baikai-cost-test.jsonl"
        writeFile path' ""
        let cfg = CallLogConfig {path = path', enabled = True}
        withCallLog cfg $ \h -> do
          _ <- runRequestWithLog h cannedModel ctxHello optsZero
          pure ()
        raw <- BSL.readFile path'
        firstLine <- case nonEmpty (BSL.lines raw) of
          Nothing -> fail "expected one JSONL line, got an empty file"
          Just (l :| rest) -> do
            length rest @?= 0
            pure l
        let mEntry :: Maybe CallLogEntry
            mEntry = Aeson.decode firstLine
        isJust mEntry @?= True
        let entry = fromJust mEntry
        entry ^. #provider @?= "canned"
        entry ^. #model @?= "claude-haiku-4-5-20251001"
        entry ^. #inputTokens @?= Just 1000
        entry ^. #outputTokens @?= Just 500
        entry ^. #latencyMs @?= 7
        entry ^. #promptSummary @?= "Hello world"
        isJust (entry ^. #usd) @?= True
        removeFile path'
    ]