baikai-0.3.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.
--
-- EP-1 introduces the types; EP-3 streams them, and EP-4 wires tool
-- round-tripping through the providers. 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 (..),
-- * Smart defaults
emptyTextContent,
emptyThinkingContent,
emptyToolCall,
emptyImageContent,
_TextContent,
_ThinkingContent,
_ToolCall,
_ImageContent,
)
where
import Data.Aeson
( FromJSON (parseJSON),
Options (..),
SumEncoding (..),
ToJSON (toJSON),
Value (Null),
camelTo2,
defaultOptions,
genericParseJSON,
genericToJSON,
object,
withObject,
(.:),
(.=),
)
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@.
data ToolCall = ToolCall
{ id_ :: !Text,
name :: !Text,
arguments :: !Value
}
deriving stock (Eq, Show, Generic)
-- | 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
{-# DEPRECATED _TextContent "Use emptyTextContent instead." #-}
_TextContent :: TextContent
_TextContent = emptyTextContent
{-# DEPRECATED _ThinkingContent "Use emptyThinkingContent instead." #-}
_ThinkingContent :: ThinkingContent
_ThinkingContent = emptyThinkingContent
{-# DEPRECATED _ToolCall "Use emptyToolCall instead." #-}
_ToolCall :: ToolCall
_ToolCall = emptyToolCall
{-# DEPRECATED _ImageContent "Use emptyImageContent instead." #-}
_ImageContent :: ImageContent
_ImageContent = emptyImageContent