packages feed

baikai-0.5.0.0: src/Baikai/Trace/Event.hs

{-# LANGUAGE LambdaCase #-}
{-# OPTIONS_GHC -Wno-partial-fields #-}

-- | The 'TraceEvent' sum and its JSON encoding.
--
-- A trace event is one of four discriminated cases: 'CallStarted' fires
-- when a provider call begins, 'CallFinished' when it returns a response,
-- 'CallFailed' when it throws, and 'CallEvidence' carries the full
-- 'ModelCallEvidence' record for callers who asked for one. The
-- 'sumEncoding' tag field is @kind@, so a JSON-Lines stream of these can
-- be filtered with @jq 'select(.kind == "call_finished")'@.
module Baikai.Trace.Event
  ( TraceEvent (..),
    traceEventOptions,
  )
where

import Baikai.Evidence (ModelCallEvidence)
import Data.Aeson
  ( FromJSON (parseJSON),
    Options (..),
    SumEncoding (..),
    ToJSON (..),
    defaultOptions,
    genericToEncoding,
    genericToJSON,
    withObject,
    (.:),
    (.:?),
  )
import Data.Char (toLower)
import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Time (UTCTime)
import GHC.Generics (Generic)
import Numeric.Natural (Natural)

-- | One observable event from a provider call.
--
-- Every event carries an 'eventId' that correlates the @started@ event
-- with its matching @finished@, @failed@, or @evidence@ event within a
-- single process run. Token counts are 'Maybe' because subscription-based
-- providers (the CLIs) do not report them; 'omitNothingFields' keeps the
-- absent fields out of the rendered JSON.
--
-- 'usd' is deliberately /not/ 'Maybe'-shaped as an "unknown" marker: it
-- was until this release, and a computed cost of zero was suppressed, so
-- a genuinely free call and a call whose cost baikai could not compute
-- looked identical in a trace. The field is still 'Maybe' because a
-- non-assistant terminal has no usage at all, but a zero cost now
-- renders as @0@.
data TraceEvent
  = CallStarted
      { eventId :: !Text,
        timestamp :: !UTCTime,
        provider :: !Text,
        model :: !Text,
        maxTokens :: !Natural,
        promptSummary :: !Text
      }
  | CallFinished
      { eventId :: !Text,
        timestamp :: !UTCTime,
        provider :: !Text,
        model :: !Text,
        latencyMs :: !Int,
        inputTokens :: !(Maybe Natural),
        outputTokens :: !(Maybe Natural),
        -- | Cache-read, cache-write, reasoning, and total token counts.
        -- 'Baikai.Cost.Log.CallLogEntry' has always kept the first and
        -- the third; a trace that dropped them was strictly less
        -- faithful than the cost log built from the same 'Usage' value.
        cachedInputTokens :: !(Maybe Natural),
        cacheWriteTokens :: !(Maybe Natural),
        reasoningTokens :: !(Maybe Natural),
        totalTokens :: !(Maybe Natural),
        usd :: !(Maybe Scientific)
      }
  | CallFailed
      { eventId :: !Text,
        timestamp :: !UTCTime,
        provider :: !Text,
        model :: !Text,
        latencyMs :: !Int,
        errorMessage :: !Text
      }
  | -- | The complete evidence record for one terminal provider call.
    --
    -- Emitted exactly once per call, immediately after the matching
    -- 'CallFinished' or 'CallFailed', and only when the caller set
    -- 'Baikai.Options.evidence' and the provider built a record. A
    -- consumer that wants only evidence can filter on this kind alone,
    -- and a consumer written before this constructor existed is
    -- unaffected as long as its pattern match is not exhaustive over
    -- the sum.
    CallEvidence
      { eventId :: !Text,
        timestamp :: !UTCTime,
        provider :: !Text,
        model :: !Text,
        evidence :: !ModelCallEvidence
      }
  deriving stock (Eq, Show, Generic)

-- | Aeson options used by the 'ToJSON' instance, and the shape the
-- hand-written 'FromJSON' instance parses.
--
-- * Sum encoding: a @kind@ discriminator alongside the constructor's
--   own fields. Every constructor here has named fields, and aeson's
--   'TaggedObject' merges those into the tagged object rather than
--   nesting them, so a line reads
--   @{"kind":"call_finished","eventId":…,"latencyMs":…}@ and not
--   @{"kind":…,"data":{…}}@. The @contentsFieldName@ below would only
--   take effect for a positional constructor, of which there are none.
--   Filter with @jq 'select(.kind == "call_finished") | .latencyMs'@.
-- * Constructor tags: snake-case (@call_started@, @call_finished@,
--   @call_failed@, @call_evidence@).
-- * Field labels: kept as-is (camelCase).
-- * Nothing fields are dropped from the encoded JSON.
traceEventOptions :: Options
traceEventOptions =
  defaultOptions
    { sumEncoding = TaggedObject {tagFieldName = "kind", contentsFieldName = "data"},
      constructorTagModifier = dropWhile (== '_') . camelToSnake,
      omitNothingFields = True
    }
  where
    camelToSnake :: String -> String
    camelToSnake [] = []
    camelToSnake (c : cs)
      | c `elem` ['A' .. 'Z'] = '_' : toLower c : camelToSnake cs
      | otherwise = c : camelToSnake cs

instance ToJSON TraceEvent where
  toJSON = genericToJSON traceEventOptions
  toEncoding = genericToEncoding traceEventOptions

-- | Written out rather than derived, and it decodes only the three
-- non-evidence cases.
--
-- 'ModelCallEvidence' deliberately has no 'FromJSON' instance: it embeds
-- a 'Baikai.Cost.Cost' whose exact 'Rational' amounts encode through an
-- approximating 'Data.Scientific.Scientific', so a decoder would return
-- a different value than was encoded. Rather than manufacture that
-- fidelity, a @call_evidence@ line fails to parse with a message saying
-- to read it as a plain 'Data.Aeson.Value'. That is the honest
-- behaviour, and it is what a consumer wants anyway — the JSON, not a
-- Haskell mirror of it, is the contract other systems pin against.
instance FromJSON TraceEvent where
  parseJSON = withObject "TraceEvent" $ \d -> do
    kind <- d .: "kind"
    case kind :: Text of
      "call_started" ->
        CallStarted
          <$> d .: "eventId"
          <*> d .: "timestamp"
          <*> d .: "provider"
          <*> d .: "model"
          <*> d .: "maxTokens"
          <*> d .: "promptSummary"
      "call_finished" ->
        CallFinished
          <$> d .: "eventId"
          <*> d .: "timestamp"
          <*> d .: "provider"
          <*> d .: "model"
          <*> d .: "latencyMs"
          <*> d .:? "inputTokens"
          <*> d .:? "outputTokens"
          <*> d .:? "cachedInputTokens"
          <*> d .:? "cacheWriteTokens"
          <*> d .:? "reasoningTokens"
          <*> d .:? "totalTokens"
          <*> d .:? "usd"
      "call_failed" ->
        CallFailed
          <$> d .: "eventId"
          <*> d .: "timestamp"
          <*> d .: "provider"
          <*> d .: "model"
          <*> d .: "latencyMs"
          <*> d .: "errorMessage"
      "call_evidence" ->
        fail
          "TraceEvent: a call_evidence line carries a ModelCallEvidence, \
          \which has no faithful decoder; read it as a Data.Aeson.Value"
      other -> fail ("TraceEvent: unknown kind " <> show other)