packages feed

baikai-0.6.0.0: src/Baikai/Content.hs

{-# LANGUAGE LambdaCase #-}

-- | Typed content blocks for user, assistant, and tool-result messages.
--
-- A message no longer carries a single opaque blob of text. Instead it
-- holds a vector of typed blocks. For user input the blocks can be text
-- or an inline base64-encoded image. For assistant output a block can be
-- text, a thinking trace (for reasoning-capable models), or a tool
-- invocation. For tool-result messages (a caller-supplied reply to a
-- model-issued tool call) blocks can be text or image.
--
-- Image content is restricted to
-- inline base64 with an explicit @mimeType@: the caller is responsible
-- for the (small, reversible) work of base64-encoding bytes once, and
-- every provider can consume the same shape without a URL-fetch path
-- that varies in failure modes.
module Baikai.Content
  ( -- * Block primitives
    TextContent (..),
    ThinkingContent (..),
    ToolCall (..),
    ImageContent (..),

    -- * Per-role block sums
    UserContent (..),
    AssistantContent (..),
    ToolResultContent (..),

    -- * Tool-call arguments
    toolArgumentsFromText,
    isCutOffToolCall,

    -- * Smart defaults
    emptyTextContent,
    emptyThinkingContent,
    emptyToolCall,
    emptyImageContent,
  )
where

import Data.Aeson
  ( FromJSON (parseJSON),
    Options (..),
    SumEncoding (..),
    ToJSON (toJSON),
    Value (Null),
    camelTo2,
    defaultOptions,
    genericParseJSON,
    genericToJSON,
    object,
    withObject,
    (.:),
    (.=),
  )
import Data.Aeson qualified as Aeson
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base64 qualified as Base64
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import GHC.Generics (Generic)

-- | A plain-text block. The wire form is @{"text": "..."}@.
newtype TextContent = TextContent
  { text :: Text
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- | A reasoning trace block produced by thinking-capable models. The
-- @signature@ field is an opaque continuation token returned by the
-- provider; it must be threaded back into the next request unchanged for
-- the model to resume from the same state. @redacted@ is 'True' when the
-- provider hid the underlying content (Anthropic flags this when its
-- safety system removes a thinking block from the wire response). When
-- @redacted@ is 'True', @thinking@ holds the provider's opaque encrypted
-- payload verbatim; callers must not display or edit it.
data ThinkingContent = ThinkingContent
  { thinking :: !Text,
    signature :: !(Maybe Text),
    redacted :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | A model-issued tool invocation. @id_@ has a trailing underscore in
-- Haskell to dodge a clash with @Prelude.id@; the JSON encoding strips
-- it back to @id@.
--
-- @arguments@ is the decoded JSON value the model sent — normally an
-- object. A bare 'Data.Aeson.String' is the __cut-off marker__: the
-- model's argument stream was truncated (by the output cap, or by a
-- transport failure mid-call) and the raw text is kept verbatim rather
-- than replaced by something well-formed that the model never asked
-- for. 'isCutOffToolCall' is the predicate; 'toolArgumentsFromText' is
-- the one rule that produces it. A cut-off call must not be dispatched:
-- 'Baikai.Provider.Registry.runToolLoop' stops on one and
-- 'Baikai.Context.appendToolResult' reports it as a tool-result error.
data ToolCall = ToolCall
  { id_ :: !Text,
    name :: !Text,
    arguments :: !Value
  }
  deriving stock (Eq, Show, Generic)

-- | Turn a tool call's accumulated argument text into its @arguments@
-- value.
--
-- Empty text is an empty object: Anthropic opens a @tool_use@ block with
-- no input and streams no delta, and an empty object is exactly what the
-- model asked for. Non-empty text that does not decode is kept verbatim
-- as a 'Data.Aeson.String' — the call was cut off, and no byte of what
-- the model did send is dropped.
--
-- Both provider assemblers and core's stream-recovery path use this one
-- rule, so 'isCutOffToolCall' means the same thing at every layer.
-- Before it, the assemblers replaced malformed arguments with @{}@ and a
-- tool loop happily executed the call with no arguments at all.
toolArgumentsFromText :: Text -> Value
toolArgumentsFromText raw
  | Text.null (Text.strip raw) = Aeson.Object mempty
  | otherwise = case Aeson.eitherDecodeStrict (Text.encodeUtf8 raw) of
      Right v -> v
      Left _ -> Aeson.String raw

-- | 'True' when the call's argument stream was cut off: @arguments@ is
-- the raw text rather than a decoded value. See 'ToolCall'.
isCutOffToolCall :: ToolCall -> Bool
isCutOffToolCall ToolCall {arguments = Aeson.String _} = True
isCutOffToolCall _ = False

-- | An inline image block. Bytes are stored decoded; the JSON encoding
-- emits base64 under @data@ and the @mimeType@ camel-snakes to
-- @mime_type@.
data ImageContent = ImageContent
  { imageData :: !ByteString,
    mimeType :: !Text
  }
  deriving stock (Eq, Show, Generic)

-- | What a caller can put into a 'Baikai.Message.UserMessage'.
data UserContent
  = UserText !TextContent
  | UserImage !ImageContent
  deriving stock (Eq, Show, Generic)

-- | What a provider can put into a 'Baikai.Message.AssistantMessage'.
data AssistantContent
  = AssistantText !TextContent
  | AssistantThinking !ThinkingContent
  | AssistantToolCall !ToolCall
  deriving stock (Eq, Show, Generic)

-- | What a caller can put into a 'Baikai.Message.ToolResultMessage'.
data ToolResultContent
  = ToolResultText !TextContent
  | ToolResultImage !ImageContent
  deriving stock (Eq, Show, Generic)

emptyTextContent :: TextContent
emptyTextContent = TextContent {text = Text.empty}

emptyThinkingContent :: ThinkingContent
emptyThinkingContent =
  ThinkingContent
    { thinking = Text.empty,
      signature = Nothing,
      redacted = False
    }

emptyToolCall :: ToolCall
emptyToolCall =
  ToolCall
    { id_ = Text.empty,
      name = Text.empty,
      arguments = Null
    }

emptyImageContent :: ImageContent
emptyImageContent =
  ImageContent
    { imageData = BS.empty,
      mimeType = Text.empty
    }

-- Aeson plumbing ---------------------------------------------------------

snakeOptions :: Options
snakeOptions = defaultOptions {fieldLabelModifier = camelTo2 '_'}

instance FromJSON ThinkingContent where
  parseJSON = genericParseJSON snakeOptions

instance ToJSON ThinkingContent where
  toJSON = genericToJSON snakeOptions

-- Strip the trailing underscore on @id_@ so the wire form is @id@; the
-- other fields keep their natural names.
toolCallOptions :: Options
toolCallOptions =
  defaultOptions
    { fieldLabelModifier = \case
        "id_" -> "id"
        s -> camelTo2 '_' s
    }

instance FromJSON ToolCall where
  parseJSON = genericParseJSON toolCallOptions

instance ToJSON ToolCall where
  toJSON = genericToJSON toolCallOptions

-- ImageContent is encoded manually so the bytes round-trip through
-- base64 on the wire under @data@ while staying decoded in Haskell.
instance ToJSON ImageContent where
  toJSON c =
    object
      [ "data" .= Text.decodeUtf8 (Base64.encode (imageData c)),
        "mime_type" .= mimeType c
      ]

instance FromJSON ImageContent where
  parseJSON = withObject "ImageContent" $ \o -> do
    encoded <- o .: "data"
    mt <- o .: "mime_type"
    case Base64.decode (Text.encodeUtf8 encoded) of
      Left err -> fail ("ImageContent.data is not base64: " <> err)
      Right raw -> pure ImageContent {imageData = raw, mimeType = mt}

contentSumOptions :: Options
contentSumOptions =
  defaultOptions
    { sumEncoding = TaggedObject {tagFieldName = "type", contentsFieldName = "data"},
      constructorTagModifier = camelTo2 '_'
    }

instance FromJSON UserContent where
  parseJSON = genericParseJSON contentSumOptions

instance ToJSON UserContent where
  toJSON = genericToJSON contentSumOptions

instance FromJSON AssistantContent where
  parseJSON = genericParseJSON contentSumOptions

instance ToJSON AssistantContent where
  toJSON = genericToJSON contentSumOptions

instance FromJSON ToolResultContent where
  parseJSON = genericParseJSON contentSumOptions

instance ToJSON ToolResultContent where
  toJSON = genericToJSON contentSumOptions