baikai-claude-0.6.0.0: src/Baikai/Provider/Claude/Internal/Stream.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
-- | __Internal module — no stability guarantees.__ This module is
-- exposed so baikai's own test suites and sibling packages can reach
-- it, but it is not part of the public API: its contents may change
-- in /any/ release without a PVP major bump. Do not import it from
-- application code.
--
-- The Anthropic Messages streaming machinery: the SSE driver seam, the
-- event assembler and the translator that turns the SDK's typed
-- 'Claude.V1.Messages.MessageStreamEvent' values into baikai
-- 'AssistantMessageEvent' values. The public entry points built on it
-- live in "Baikai.Provider.Claude.Api".
--
-- Requests start as the SDK's typed 'Claude.V1.Messages.CreateMessage'
-- value, then 'Baikai.Provider.Claude.Shape.streamRequestBody' patches
-- the raw JSON body for tool-schema, @tool_choice@, and tool-cache
-- compat before
-- 'Baikai.Provider.Claude.Sse.claudeSseStreamValueWithHeaders' sends it
-- with cached transport settings and caller headers.
module Baikai.Provider.Claude.Internal.Stream
( claudeMessagesStreamWith,
SseDriver,
liveSseDriver,
Assembler (..),
emptyAssembler,
translate,
)
where
import Baikai.Content qualified as Content
import Baikai.Context (Context (..))
import Baikai.Cost (zeroCost)
import Baikai.Cost.Pricing qualified as Pricing
import Baikai.Error (BaikaiError, contentFiltered, invalidRequest, providerError)
import Baikai.Evidence qualified as Ev
import Baikai.Evidence.Build qualified as Build
import Baikai.Message qualified as Msg
import Baikai.Model (Model, anthropicMessagesCompatFor)
import Baikai.Options (Options (..))
import Baikai.Provider.Claude.Internal.ErrorClass (classifyErrorValue, classifyException)
import Baikai.Provider.Claude.Internal.Request (describeThinkingFor, mapRequest)
import Baikai.Provider.Claude.Shape (streamRequestBody)
import Baikai.Provider.Claude.Sse (claudeSseStreamValueWithHeaders)
import Baikai.Provider.Claude.Sse qualified as Sse
import Baikai.Provider.Claude.Transport qualified as Transport
import Baikai.Provider.Internal.StreamWorker
( FrameQueue,
newFrameQueue,
pullFrame,
pushFrame,
withFrameWorker,
)
import Baikai.StopReason qualified as Stop
import Baikai.Stream.Event
( AssistantMessageEvent (..),
BlockEndPayload (..),
DeltaPayload (..),
IndexPayload (..),
StartPayload (..),
ThinkingEndPayload (..),
ToolCallEndPayload (..),
doneTerminal,
errorTerminal,
)
import Baikai.Url qualified as Url
import Baikai.Usage qualified as Usage
import Claude.V1.Messages qualified as Messages
import Control.Exception (SomeAsyncException (..), SomeException, fromException, throwIO, try)
import Control.Lens ((%~), (&), (.~), (^.))
import Data.Aeson (Value)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BSL
import Data.CaseInsensitive qualified as CI
import Data.Generics.Labels ()
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.IntSet qualified as IntSet
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Data.Version (showVersion)
import GHC.Generics (Generic)
import Network.HTTP.Types.Header (RequestHeaders)
import Paths_baikai_claude qualified as Paths
import Servant.Client qualified as Client
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
-- | How a call physically reaches Anthropic.
--
-- Production passes 'liveSseDriver'. A test passes one that replays a
-- recorded response through the same
-- 'Baikai.Provider.Claude.Sse.sseFromResponse' the live driver uses, so
-- header capture, status classification, and SSE frame decoding are all
-- the real implementations and only the socket is missing.
type SseDriver =
ClaudeCall ->
(Sse.ResponseMetadata -> IO ()) ->
(Either BaikaiError Messages.MessageStreamEvent -> IO ()) ->
IO ()
-- | The production driver: a real HTTPS request through the cached
-- transport settings.
liveSseDriver :: SseDriver
liveSseDriver call =
claudeSseStreamValueWithHeaders
(call ^. #clientEnv)
(call ^. #requestHeaders)
(call ^. #requestBody)
-- | 'Baikai.Provider.Claude.Api.claudeMessagesStream' over an explicit
-- transport driver.
claudeMessagesStreamWith ::
SseDriver -> Model -> Context -> Options -> Stream IO AssistantMessageEvent
claudeMessagesStreamWith driver m ctx opts =
Stream.concatEffect $ do
setupResult <- trySync (prepareCall m ctx opts)
let setup = either (Left . exceptionToError) id setupResult
case setup of
Left err -> Stream.fromList <$> immediateError m opts err
Right call -> do
q <- newFrameQueue :: IO (FrameQueue (Either BaikaiError Messages.MessageStreamEvent))
tref <- newIORef False
mref <- newIORef Nothing
startTime <- getCurrentTime
-- The request body is the envelope the two digests commit to:
-- it is exactly the JSON this call is about to put on the wire.
-- Credentials are not in it — they travel in the headers built
-- separately by 'Transport.requestHeaders'.
mkEvidence <-
Build.prepareEvidenceAt
(call ^. #baseUrl)
m
opts
Ev.TransportHttpApi
(call ^. #thinking)
(call ^. #requestBody)
startTime
let initialState =
ProducerState
{ chan = q,
-- Pre-seeded, exactly as the OpenAI producer does it,
-- so the first event reaches the consumer immediately
-- and carries the request-start timestamp, and so
-- every failure path is 'EventStart'-first without
-- per-path bookkeeping. Anthropic's message id is not
-- known yet; it rides the terminal's @responseId@,
-- which 'Baikai.Stream.reassembleResponse' prefers
-- anyway.
pending =
[ EventStart
StartPayload
{ partial = skeletonMessage (emptyAssembler m startTime) startTime,
responseId = Nothing
}
],
assembler = emptyAssembler m startTime,
finished = False,
terminalRef = tref,
metadataRef = mref,
evidence = mkEvidence
}
pure (withFrameWorker q (worker driver call mref q) (Stream.unfoldrM step initialState))
-- | Per-call prepared values, including the shaped JSON request body
-- passed to the local streaming transport.
data ClaudeCall = ClaudeCall
{ clientEnv :: !Client.ClientEnv,
requestHeaders :: !RequestHeaders,
timeoutMs :: !(Maybe Int),
requestBody :: !Aeson.Value,
-- | The base URL this call actually resolved to, which is the
-- vendor default when the model carries none. Carried so the
-- evidence endpoint names the host the call went to; the model's
-- own field can be @""@ for a call with a perfectly definite
-- destination.
baseUrl :: !Text,
-- | What the caller's reasoning-effort preference became on this
-- request, as 'mapRequest' described it. Carried from here rather
-- than recomputed at the terminal: only the request mapper knows
-- the host compat lookup and the max-tokens interaction that
-- produced it.
thinking :: !Ev.ThinkingTranslation
}
deriving stock (Generic)
-- | The host this call goes to: the model's base URL, or Anthropic's
-- when it carries none.
resolvedBaseUrl :: Model -> Text
resolvedBaseUrl m = case m ^. #baseUrl of
"" -> "https://api.anthropic.com"
u -> u
prepareCall ::
Model -> Context -> Options -> IO (Either BaikaiError ClaudeCall)
prepareCall m ctx opts = do
case mapRequest m ctx opts of
Left e -> pure (Left (invalidRequest e))
Right (req, translation) -> do
let url = resolvedBaseUrl m
compat = anthropicMessagesCompatFor m
version = Just "2023-06-01"
-- Checked before the key is resolved, so a base URL baikai will
-- not send to never causes a credential to be read out of the
-- environment. The message names the problem and what to write
-- instead; it renders the URL without its userinfo or query, so an
-- error reaching a log cannot carry a key someone put in either.
case Url.baseUrlProblem url of
Just problem ->
pure (Left (invalidRequest ("Model.baseUrl is not usable: " <> problem)))
Nothing -> do
key <- Transport.resolveKey url opts
env <- Transport.getClientEnvCached url
let body = streamRequestBody compat ctx opts req
headers = Transport.requestHeaders key version compat ctx m opts
pure
( Right
ClaudeCall
{ clientEnv = env,
requestHeaders = headers,
timeoutMs = opts ^. #timeoutMs,
requestBody = body,
baseUrl = url,
thinking = translation
}
)
-- | Worker body: drive the SDK's typed callback, forwarding events onto
-- the frame queue. Any synchronous exception is converted into a
-- classified error frame so the consumer side can translate it through
-- the normal path.
--
-- Nothing here signals end-of-frames: that is the queue's closed flag,
-- set by 'Baikai.Provider.Internal.StreamWorker.forkFrameWorker''s
-- @finally@ however this body ends. A sentinel push would block on a
-- full queue, which is exactly the state a stopped consumer leaves
-- behind.
worker ::
SseDriver ->
ClaudeCall ->
IORef (Maybe Sse.ResponseMetadata) ->
FrameQueue (Either BaikaiError Messages.MessageStreamEvent) ->
IO ()
worker driver call metaRef q = do
r <-
trySync $
Transport.runWithTimeout (call ^. #timeoutMs) $
driver
call
(writeIORef metaRef . Just)
(pushFrame q)
case r of
Right Nothing -> pure ()
Right (Just be) -> pushFrame q (Left be)
Left e -> pushFrame q (Left (exceptionToError e))
-- | The streaming 'Stream' state.
data ProducerState = ProducerState
{ chan :: !(FrameQueue (Either BaikaiError Messages.MessageStreamEvent)),
pending :: ![AssistantMessageEvent],
assembler :: !Assembler,
finished :: !Bool,
terminalRef :: !(IORef Bool),
-- | Where the worker leaves the response-level metadata it captured
-- before the first event. Read on this side rather than pushed
-- through 'chan' so the channel keeps carrying exactly one kind of
-- thing; 'absorbMetadata' folds it into the assembler.
metadataRef :: !(IORef (Maybe Sse.ResponseMetadata)),
-- | Everything about this call's evidence that was knowable before
-- the first byte came back, waiting on the terminal timestamp and
-- outcome. 'Nothing' when the caller did not ask for evidence.
-- 'sealTerminal' applies it.
evidence ::
!(Maybe (UTCTime -> Ev.CallStatus -> Maybe BaikaiError -> Ev.ModelCallEvidence))
}
deriving stock (Generic)
step :: ProducerState -> IO (Maybe (AssistantMessageEvent, ProducerState))
step s
| (e : rest) <- s ^. #pending = do
sealed <- sealTerminal s e
pure
( Just
( sealed,
s
& #pending .~ rest
& #finished .~ (s ^. #finished || terminal sealed)
)
)
| s ^. #finished = pure Nothing
| otherwise = do
mRaw <- pullFrame (s ^. #chan)
-- After the read, because the worker writes the metadata before it
-- writes anything onto the channel: taking it here means every
-- path out of this branch — including the one where the channel
-- closed without ever producing an event — sees it.
ass0 <- absorbMetadata (s ^. #metadataRef) (s ^. #assembler)
let s' = s & #assembler .~ ass0
case mRaw of
Nothing -> do
alreadyTerminal <- readIORef (s' ^. #terminalRef)
if alreadyTerminal
then pure Nothing
else do
now <- getCurrentTime
let (events, ass') = unexpectedEoS now ass0
case events of
[] -> pure Nothing
(e : rest) -> do
sealed <- sealTerminal (s' & #assembler .~ ass') e
pure
( Just
( sealed,
s'
& #pending .~ rest
& #assembler .~ ass'
& #finished .~ True
)
)
Just raw -> do
now <- getCurrentTime
let (events, ass') = translate raw ass0 now
case events of
[] -> step (s' & #assembler .~ ass')
(e : rest) -> do
sealed <- sealTerminal (s' & #assembler .~ ass') e
pure
( Just
( sealed,
s'
& #pending .~ rest
& #assembler .~ ass'
& #finished .~ (s' ^. #finished || terminal sealed)
)
)
-- | Mark the stream terminated and attach the call's evidence to the
-- terminal event.
--
-- Every event this producer yields goes through here, and the three
-- sites that can produce a terminal — a translated upstream event, a
-- queued event drained from 'pending', and the unexpected-end-of-stream
-- recovery — therefore all seal identically. Doing it here rather than
-- inside 'translate' keeps that function pure; evidence construction
-- needs 'IO' for the call identifier.
--
-- A non-terminal event passes through unchanged, and so does a terminal
-- on a call whose caller asked for no evidence.
sealTerminal :: ProducerState -> AssistantMessageEvent -> IO AssistantMessageEvent
sealTerminal s ev
| not (terminal ev) = pure ev
| otherwise = do
writeIORef (s ^. #terminalRef) True
case s ^. #evidence of
Nothing -> pure ev
Just finish -> do
now <- getCurrentTime
let st = statusOf ev
record = observeAnthropic st (s ^. #assembler) (finish now st (errorOf ev))
pure (withEvidence record ev)
where
statusOf = \case
EventDone {} -> Ev.CallSucceeded
_ -> Ev.CallFailed
-- The terminal payload already carries the normalized error, and
-- 'errorTerminal' guarantees it is 'Just' on every 'EventError'.
errorOf = \case
EventError p -> p ^. #errorInfo
_ -> Nothing
-- Set through the generic-lens label rather than a record update:
-- 'Baikai.Options.Options' also has an @evidence@ field, so under
-- @DuplicateRecordFields@ a bare @p {evidence = ...}@ has no unique
-- constructor to resolve to.
withEvidence record = \case
EventDone p -> EventDone (p & #evidence .~ Just record)
EventError p -> EventError (p & #evidence .~ Just record)
other -> other
-- | Replace the observed fields of a prepared evidence record with what
-- this call actually saw, and derive the strength from that.
--
-- Only ever reached on a call whose caller asked for evidence, which is
-- what makes it safe to compute the response commitment here: that
-- digest hashes the model's entire output and is the most expensive
-- thing this provider adds. The observations it reads were gathered
-- unconditionally, because each costs a lookup and each improves the
-- 'Baikai.Response.Response' for every caller.
--
-- Nothing here consults the request. An observation the provider did not
-- make stays 'Ev.Unobserved'.
observeAnthropic ::
Ev.CallStatus -> Assembler -> Ev.ModelCallEvidence -> Ev.ModelCallEvidence
observeAnthropic st ass ev =
ev
& #endpoint . #implementationVersion .~ Just claudePackageVersion
& #observedModel .~ (ass ^. #observedModel)
& #providerRequestId .~ (ass ^. #providerRequestId)
& #responseId .~ maybe Ev.Unobserved Ev.Observed (ass ^. #responseId)
& #usage .~ observedUsage ass
& #responseCommitment .~ responseCommitment st ass
& #strength
.~ Ev.deriveStrength
(ass ^. #observedModel)
(ass ^. #providerRequestId)
(maybe Ev.Unobserved Ev.Observed (ass ^. #responseId))
-- | The token accounting, but only if Anthropic actually reported it.
--
-- The assembler initialises 'usage' to zeroes, so reporting it
-- unconditionally would tell a reader the provider said this call
-- consumed nothing — which for a call that failed before any usage
-- arrived is a fabrication, and exactly what 'Ev.Observed' exists to
-- stop.
observedUsage :: Assembler -> Ev.Observed Usage.Usage
observedUsage ass
| ass ^. #usageReported = Ev.Observed (finalUsage ass)
| otherwise = Ev.Unobserved
-- | A commitment to what came back, on a call that produced a response.
--
-- Left 'Ev.Unobserved' otherwise: a digest of an empty envelope is a
-- real-looking value standing for a response that never arrived.
responseCommitment :: Ev.CallStatus -> Assembler -> Ev.Observed Text
responseCommitment Ev.CallSucceeded ass =
Ev.Observed (Ev.commitmentDigest (responseEnvelope ass))
responseCommitment _ _ = Ev.Unobserved
-- | What that digest commits to: the assembled content blocks in order,
-- the stop reason, and the reported usage.
--
-- Deliberately the assembled response rather than the raw SSE bytes. Two
-- identical responses split into different frames must produce the same
-- digest, and the frame boundaries are a transport detail no verifier
-- holding the response could reproduce.
responseEnvelope :: Assembler -> Value
responseEnvelope ass =
Aeson.object
[ "content" Aeson..= blocksInOrder ass,
"stop_reason" Aeson..= (ass ^. #stopReason),
-- Token counts only: 'Ev.usageEnvelope' omits the cost, which
-- baikai computes from the caller's catalog rather than reads off
-- the response, and which a verifier therefore cannot reproduce.
"usage" Aeson..= Ev.usageEnvelope (finalUsage ass)
]
-- | The version of this package, for the evidence record's endpoint
-- identity. Read from the cabal-generated module rather than written as
-- a literal, which becomes a lie the first time a release misses it.
claudePackageVersion :: Text
claudePackageVersion = Text.pack (showVersion Paths.version)
-- | Fold whatever response-level metadata the worker has captured into
-- the assembler.
--
-- Idempotent: applying it again overwrites the same fields with the same
-- values, which is what lets 'step' call it on every pass rather than
-- tracking whether it has run.
absorbMetadata :: IORef (Maybe Sse.ResponseMetadata) -> Assembler -> IO Assembler
absorbMetadata ref ass = do
meta <- readIORef ref
pure $ case meta of
Nothing -> ass
Just md ->
ass
& #httpStatus .~ Just (md ^. #httpStatus)
& #providerRequestId .~ correlationId md
-- | Anthropic's correlation identifier for this response, or a
-- gateway's if Anthropic's own is absent.
--
-- The preference order is 'Sse.capturedHeaderNames' itself, so the
-- allow-list and the preference cannot disagree. Nothing is invented:
-- a response carrying none of those headers leaves this
-- 'Ev.Unobserved'.
correlationId :: Sse.ResponseMetadata -> Ev.Observed Text
correlationId md =
case [v | n <- Sse.capturedHeaderNames, Just v <- [lookup (headerName n) (md ^. #headers)]] of
(v : _) -> Ev.Observed v
[] -> Ev.Unobserved
where
headerName = Text.decodeUtf8 . CI.foldedCase
terminal :: AssistantMessageEvent -> Bool
terminal = \case
EventDone {} -> True
EventError {} -> True
_ -> False
-- | The recovery path: the frame queue closed before any terminal event.
--
-- Returns the block-closing events first and then the terminal, so a
-- consumer reading raw events and a consumer reassembling them see the
-- same partial output.
unexpectedEoS ::
UTCTime -> Assembler -> ([AssistantMessageEvent], Assembler)
unexpectedEoS now ass =
let (closeEvents, ass') = closeOpenBlocks ass
errText = "claude stream ended without message_stop"
msg = finalMessageOnError ass' now errText
in ( closeEvents
<> [EventError (errorTerminal Nothing (ass' ^. #responseId) Stop.ErrorReason msg (providerError errText))],
ass'
)
-- | Close every still-open block in ascending index order, exactly as a
-- @content_block_stop@ for each would.
--
-- Used on every failure path, because the terminal message is built from
-- 'blocksInOrder' — the /closed/ blocks — and a failure that left text,
-- thinking or tool arguments open would otherwise drop them from both
-- the events and the message. Core's reassembler recovers open buffers
-- on its own; a consumer reading raw events had no such recourse.
closeOpenBlocks :: Assembler -> ([AssistantMessageEvent], Assembler)
closeOpenBlocks ass = foldl' close ([], ass) openIndices
where
openIndices =
IntSet.toAscList . IntSet.unions $
map
IntMap.keysSet
[ ass ^. #textBuf,
ass ^. #thinkBuf,
ass ^. #redactedBuf,
ass ^. #toolArgsBuf
]
close (acc, a) i = let (evs, a') = handleBlockStop i a in (acc <> evs, a')
-- | Translation state across one streaming call.
--
-- The four fields below @stopReason@ are what this call /observed/, as
-- distinct from what it requested. They are kept here rather than
-- derived at the terminal because this record is the only state that
-- survives from the first event to the last, and because an observation
-- that never arrived must stay 'Ev.Unobserved' rather than falling back
-- to the caller's configuration.
data Assembler = Assembler
{ model :: !Model,
start :: !UTCTime,
responseId :: !(Maybe Text),
closed :: !(IntMap Content.AssistantContent),
textBuf :: !(IntMap Text),
thinkBuf :: !(IntMap Text),
thinkSig :: !(IntMap Text),
redactedBuf :: !(IntMap Text),
toolArgsBuf :: !(IntMap Text),
toolMeta :: !(IntMap (Text, Text)),
usage :: !Usage.Usage,
stopReason :: !Stop.StopReason,
-- | Anthropic's own correlation identifier for this call, from the
-- response headers.
providerRequestId :: !(Ev.Observed Text),
-- | The model identifier Anthropic reported running, from
-- @message_start@. Never the configured model.
observedModel :: !(Ev.Observed Text),
-- | The response's HTTP status. Recorded because the transport has
-- it; 'Baikai.Evidence.ModelCallEvidence' has no field for it, and
-- adding one to the evidence schema is a core decision, not this
-- module's.
httpStatus :: !(Maybe Int),
-- | Whether Anthropic actually reported token counts, as opposed to
-- 'usage' still holding the zeroes it was initialised with. Without
-- this a failed call would claim the provider reported consuming
-- nothing.
usageReported :: !Bool
}
deriving stock (Generic)
emptyAssembler :: Model -> UTCTime -> Assembler
emptyAssembler m s =
Assembler
{ model = m,
start = s,
responseId = Nothing,
closed = IntMap.empty,
textBuf = IntMap.empty,
thinkBuf = IntMap.empty,
thinkSig = IntMap.empty,
redactedBuf = IntMap.empty,
toolArgsBuf = IntMap.empty,
toolMeta = IntMap.empty,
usage = Usage.zeroUsage,
stopReason = Stop.Stop,
providerRequestId = Ev.Unobserved,
observedModel = Ev.Unobserved,
httpStatus = Nothing,
usageReported = False
}
translate ::
Either BaikaiError Messages.MessageStreamEvent ->
Assembler ->
UTCTime ->
([AssistantMessageEvent], Assembler)
translate raw ass now = case raw of
Left be ->
let (closeEvents, ass') = closeOpenBlocks ass
msg = finalMessageOnError ass' now (be ^. #message)
in ( closeEvents
<> [EventError (errorTerminal Nothing (ass' ^. #responseId) Stop.ErrorReason msg be)],
ass'
)
Right ev -> translateEvent ev ass now
translateEvent ::
Messages.MessageStreamEvent ->
Assembler ->
UTCTime ->
([AssistantMessageEvent], Assembler)
translateEvent raw ass now = case raw of
Messages.Ping -> ([], ass)
-- Updates the assembler and emits nothing: the stream's one
-- 'EventStart' was pre-seeded before the first wire read, so that a
-- failure arriving before this frame — a 401, a rate limit, an
-- in-band error event, an EOF — still begins the stream the way the
-- protocol says every stream begins.
Messages.Message_Start {Messages.message = mr} ->
let usage0 = anthroUsageToBaikai (mr ^. #usage)
ass' =
ass
& #responseId .~ Just (mr ^. #id)
-- The provider's value, never the caller's. The SDK's
-- @model@ field is not optional, so a @message_start@ that
-- arrives at all is a genuine observation; a stream that
-- fails before one arrives leaves this 'Ev.Unobserved'.
& #observedModel .~ Ev.Observed (mr ^. #model)
& #usage .~ usage0
& #usageReported .~ True
in ([], ass')
Messages.Content_Block_Start {Messages.index = idx, Messages.content_block = block} ->
handleBlockStart (fromIntegral idx) block ass
Messages.Content_Block_Delta {Messages.index = idx, Messages.delta = d} ->
handleBlockDelta (fromIntegral idx) d ass
Messages.Content_Block_Stop {Messages.index = idx} ->
handleBlockStop (fromIntegral idx) ass
Messages.Message_Delta {Messages.message_delta = md, Messages.usage = su} ->
let stopR = mapStopReason (md ^. #stop_reason)
u = ass ^. #usage
-- @message_delta@ carries the call's final counts. Older models
-- send only @output_tokens@; Claude 5 repeats the prompt-side
-- classes too, which matters because a server-side tool run
-- grows them after @message_start@. An absent field keeps what
-- @message_start@ reported rather than zeroing it.
--
-- 'Messages.StreamUsage' has no 'GHC.Generics.Generic' instance,
-- so these are record dots rather than the generic-lens labels
-- used for 'Messages.Usage'.
inputFinal = fromMaybe (u ^. #inputTokens) su.stream_input_tokens
outputFinal = su.output_tokens
cacheReadFinal = fromMaybe (u ^. #cacheReadTokens) su.stream_cache_read_input_tokens
cacheWriteFinal = fromMaybe (u ^. #cacheWriteTokens) su.stream_cache_creation_input_tokens
reasoningFinal = case su.stream_output_tokens_details of
Just d -> Just d.thinking_tokens
Nothing -> u ^. #reasoningTokens
u' =
u
& #inputTokens .~ inputFinal
& #outputTokens .~ outputFinal
& #cacheReadTokens .~ cacheReadFinal
& #cacheWriteTokens .~ cacheWriteFinal
& #reasoningTokens .~ reasoningFinal
& #totalTokens .~ (inputFinal + outputFinal + cacheReadFinal + cacheWriteFinal)
in ([], ass & #stopReason .~ stopR & #usage .~ u' & #usageReported .~ True)
Messages.Message_Stop ->
let reason = ass ^. #stopReason
-- A refusal is a filter: the content, not the transport, is
-- the problem, and a caller can branch on the category.
refusal = contentFiltered "Anthropic refused to generate a response (stop_reason=refusal)"
msg =
if reason == Stop.ErrorReason
then finalMessageOnError ass now (refusal ^. #message)
else finalMessage ass now
terminalEvent =
if reason == Stop.ErrorReason
then EventError (errorTerminal Nothing (ass ^. #responseId) reason msg refusal)
else EventDone (doneTerminal Nothing (ass ^. #responseId) reason msg)
in ([terminalEvent], ass)
Messages.Error {Messages.error = errVal} ->
let (closeEvents, ass') = closeOpenBlocks ass
errText = renderAnthropicError errVal
mErr = classifyErrorValue errVal
msg = finalMessageOnError ass' now errText
errInfo = fromMaybe (providerError errText) mErr
in ( closeEvents
<> [EventError (errorTerminal Nothing (ass' ^. #responseId) Stop.ErrorReason msg errInfo)],
ass'
)
handleBlockStart ::
Int ->
Messages.ContentBlock ->
Assembler ->
([AssistantMessageEvent], Assembler)
handleBlockStart i block ass = case block of
Messages.ContentBlock_Text {} ->
( [TextStart IndexPayload {contentIndex = i}],
ass & #textBuf %~ IntMap.insert i Text.empty
)
Messages.ContentBlock_Thinking {} ->
( [ThinkingStart IndexPayload {contentIndex = i}],
ass & #thinkBuf %~ IntMap.insert i Text.empty
)
Messages.ContentBlock_Redacted_Thinking {Messages.data_ = payload} ->
( [ThinkingStart IndexPayload {contentIndex = i}],
ass & #redactedBuf %~ IntMap.insert i payload
)
Messages.ContentBlock_Tool_Use {Messages.id = tid, Messages.name = tn} ->
( [ToolCallStart IndexPayload {contentIndex = i}],
ass
& #toolArgsBuf %~ IntMap.insert i Text.empty
& #toolMeta %~ IntMap.insert i (tid, tn)
)
_ ->
-- Server-tool, code-execution-tool, unknown — pass-through with no events.
([], ass)
handleBlockDelta ::
Int ->
Messages.ContentBlockDelta ->
Assembler ->
([AssistantMessageEvent], Assembler)
handleBlockDelta i d ass = case d of
Messages.Delta_Text_Delta {Messages.text = t} ->
if IntMap.member i (ass ^. #textBuf)
then
( [TextDelta DeltaPayload {contentIndex = i, delta = t}],
ass & #textBuf %~ IntMap.adjust (<> t) i
)
else ([], ass)
Messages.Delta_Thinking_Delta {Messages.thinking = t} ->
if IntMap.member i (ass ^. #thinkBuf)
then
( [ThinkingDelta DeltaPayload {contentIndex = i, delta = t}],
ass & #thinkBuf %~ IntMap.adjust (<> t) i
)
else ([], ass)
Messages.Delta_Signature_Delta {Messages.signature = sig} ->
-- Signatures are tail-end metadata on thinking blocks; they
-- attach to the ThinkingEnd event's content build, not a public
-- delta event.
if IntMap.member i (ass ^. #thinkBuf)
then
( [],
ass & #thinkSig %~ IntMap.insertWith (\new old -> old <> new) i sig
)
else ([], ass)
Messages.Delta_Input_Json_Delta {Messages.partial_json = j} ->
if IntMap.member i (ass ^. #toolArgsBuf)
then
( [ToolCallDelta DeltaPayload {contentIndex = i, delta = j}],
ass & #toolArgsBuf %~ IntMap.adjust (<> j) i
)
else ([], ass)
handleBlockStop ::
Int -> Assembler -> ([AssistantMessageEvent], Assembler)
handleBlockStop i ass
| Just body <- IntMap.lookup i (ass ^. #textBuf) =
let block = Content.AssistantText (Content.TextContent body)
in ( [TextEnd BlockEndPayload {contentIndex = i, content = body}],
ass
& #closed %~ IntMap.insert i block
& #textBuf %~ IntMap.delete i
)
| Just payload <- IntMap.lookup i (ass ^. #redactedBuf) =
let thinkingContent =
Content.ThinkingContent
{ Content.thinking = payload,
Content.signature = Nothing,
Content.redacted = True
}
block = Content.AssistantThinking thinkingContent
in ( [ThinkingEnd ThinkingEndPayload {contentIndex = i, content = thinkingContent}],
ass
& #closed %~ IntMap.insert i block
& #redactedBuf %~ IntMap.delete i
)
| Just body <- IntMap.lookup i (ass ^. #thinkBuf) =
let sig = IntMap.lookup i (ass ^. #thinkSig)
thinkingContent =
Content.ThinkingContent
{ Content.thinking = body,
Content.signature = if maybe True Text.null sig then Nothing else sig,
Content.redacted = False
}
block = Content.AssistantThinking thinkingContent
in ( [ThinkingEnd ThinkingEndPayload {contentIndex = i, content = thinkingContent}],
ass
& #closed %~ IntMap.insert i block
& #thinkBuf %~ IntMap.delete i
& #thinkSig %~ IntMap.delete i
)
| Just argsText <- IntMap.lookup i (ass ^. #toolArgsBuf) =
let (tid, tn) =
-- A tool args buffer is opened together with metadata in
-- handleBlockStart; the fallback is defensive only.
fromMaybe ("", "") (IntMap.lookup i (ass ^. #toolMeta))
-- One rule, shared with the OpenAI assembler and with core's
-- stream recovery: empty text is an empty object (Anthropic
-- opens a tool_use block with no input and streams no delta),
-- and text that does not decode is kept verbatim as a String,
-- marking the call cut off. This module used to answer @{}@
-- to both, which handed a tool loop a well-formed call the
-- model never finished asking for.
decoded :: Value
decoded = Content.toolArgumentsFromText argsText
tc =
Content.ToolCall
{ Content.id_ = tid,
Content.name = tn,
Content.arguments = decoded
}
block = Content.AssistantToolCall tc
in ( [ToolCallEnd ToolCallEndPayload {contentIndex = i, toolCall = tc}],
ass
& #closed %~ IntMap.insert i block
& #toolArgsBuf %~ IntMap.delete i
& #toolMeta %~ IntMap.delete i
)
| otherwise = ([], ass)
-- | The 'EventStart' message skeleton (empty content; usage/etc.
-- carried for downstream consumers that want metadata up front).
skeletonMessage :: Assembler -> UTCTime -> Msg.Message
skeletonMessage ass _now =
Msg.AssistantMessage
Msg.AssistantPayload
{ Msg.content = Vector.empty,
Msg.usage = ass ^. #usage,
Msg.stopReason = Stop.Stop,
Msg.errorMessage = Nothing,
Msg.timestamp = Just (ass ^. #start)
}
-- | The assembler's token accounting with this model's pricing applied.
-- Shared so the terminal message and the evidence record cannot report
-- two different figures for one call.
--
-- __Known limitation: cache writes are priced at one rate.__ Anthropic
-- bills a one-hour ('Baikai.CacheRetention.CacheRetentionLong') cache
-- write at roughly twice the five-minute rate, but the catalog carries a
-- single @cacheWriteCost@ — the five-minute one — and the SDK's
-- 'Messages.Usage' reports a single @cache_creation_input_tokens@ with
-- no per-TTL split (see 'anthroUsageToBaikai'). A long-retention write
-- is therefore /under-stated/ here. Token counts are unaffected; only
-- the dollar figure is low. Fixing it needs a second value carried off
-- the worker channel, a second field inside the evidence record, and a
-- second rate models.dev does not publish.
finalUsage :: Assembler -> Usage.Usage
finalUsage ass =
let usageBare = ass ^. #usage
in usageBare & #cost .~ Pricing.computeCost (ass ^. #model) usageBare
finalMessage :: Assembler -> UTCTime -> Msg.Message
finalMessage ass now =
Msg.AssistantMessage
Msg.AssistantPayload
{ Msg.content = blocksInOrder ass,
Msg.usage = finalUsage ass,
Msg.stopReason = ass ^. #stopReason,
Msg.errorMessage = Nothing,
Msg.timestamp = Just now
}
finalMessageOnError :: Assembler -> UTCTime -> Text -> Msg.Message
finalMessageOnError ass now reason =
Msg.AssistantMessage
Msg.AssistantPayload
{ Msg.content = blocksInOrder ass,
Msg.usage = finalUsage ass,
Msg.stopReason = Stop.ErrorReason,
Msg.errorMessage = Just reason,
Msg.timestamp = Just now
}
blocksInOrder :: Assembler -> Vector Content.AssistantContent
blocksInOrder ass = Vector.fromList (IntMap.elems (ass ^. #closed))
-- | The immediate "request invalid" stream, emitted when 'mapRequest'
-- fails or 'prepareCall' is otherwise unable to build a valid SDK
-- request.
--
-- Nothing was sent, so there is no wire body to digest and the evidence
-- commits to 'Build.dispatchEnvelope' instead — see its documentation.
immediateError :: Model -> Options -> BaikaiError -> IO [AssistantMessageEvent]
immediateError m opts err = do
now <- getCurrentTime
let errText = err ^. #message
let msg =
Msg.AssistantMessage
Msg.AssistantPayload
{ Msg.content = Vector.empty,
Msg.usage = Usage.zeroUsage,
Msg.stopReason = Stop.ErrorReason,
Msg.errorMessage = Just errText,
Msg.timestamp = Just now
}
ev <-
Build.minimalEvidenceAt
(resolvedBaseUrl m)
m
opts
Ev.TransportHttpApi
-- The adapter's own describer, not 'Ev.noThinkingRequested': the
-- caller's level is a fact about the call even when the request
-- was never built, and this function is the only description
-- ADR 0003 permits anyone to use for this provider.
(describeThinkingFor m opts)
(Build.dispatchEnvelope m opts)
now
now
Ev.CallFailed
(Just err)
pure
[ EventStart StartPayload {partial = msg, responseId = Nothing},
EventError (errorTerminal ev Nothing Stop.ErrorReason msg err)
]
trySync :: IO a -> IO (Either SomeException a)
trySync action = do
r <- try action
case r of
Left e
| Just (SomeAsyncException _) <- (fromException e :: Maybe SomeAsyncException) ->
throwIO e
| otherwise -> pure (Left e)
Right a -> pure (Right a)
exceptionToError :: SomeException -> BaikaiError
exceptionToError e = fromMaybe (classifyException e) (fromException e)
renderAnthropicError :: Value -> Text
renderAnthropicError v = case v of
Aeson.String t -> t
_ -> Text.decodeUtf8 (BSL.toStrict (Aeson.encode v))
-- | Map the Anthropic streaming 'Message_Start.message.usage' value
-- into baikai's 'Usage' shape. Cache-related counters are populated
-- where present; cost is left at zero (the terminal event
-- recomputes it). A thinking-token breakdown, when Anthropic reports
-- one, becomes 'Usage.reasoningTokens' — an informational subset of the
-- output tokens rather than a billed class of its own, so it moves no
-- total.
--
-- @cache_creation_input_tokens@ is one number covering both cache-write
-- TTLs. The SDK's 'Messages.Usage' has no per-TTL breakdown, so baikai
-- cannot tell a five-minute write from a one-hour one and prices both at
-- the catalog's single @cacheWriteCost@; see 'finalUsage' and
-- @docs\/user\/prompt-caching.md@.
anthroUsageToBaikai :: Messages.Usage -> Usage.Usage
anthroUsageToBaikai u =
let i = u ^. #input_tokens
o = u ^. #output_tokens
cr = fromMaybe 0 (u ^. #cache_read_input_tokens)
cw = fromMaybe 0 (u ^. #cache_creation_input_tokens)
in Usage.Usage
{ Usage.inputTokens = i,
Usage.outputTokens = o,
Usage.cacheReadTokens = cr,
Usage.cacheWriteTokens = cw,
Usage.reasoningTokens = fmap (^. #thinking_tokens) (u ^. #output_tokens_details),
Usage.totalTokens = i + o + cr + cw,
Usage.cost = zeroCost
}
mapStopReason :: Maybe Messages.StopReason -> Stop.StopReason
mapStopReason = \case
Just Messages.End_Turn -> Stop.Stop
Just Messages.Max_Tokens -> Stop.Length
Just Messages.Stop_Sequence -> Stop.Stop
Just Messages.Tool_Use -> Stop.ToolUse
-- The turn was suspended mid-flight (a long-running server-side tool)
-- and Anthropic expects the caller to send the message back to
-- continue it. Nothing failed, so this is a stop, not an error;
-- baikai's 'Stop.StopReason' has no constructor that says "resume me".
Just Messages.Pause_Turn -> Stop.Stop
Just Messages.Refusal -> Stop.ErrorReason
Just Messages.Model_Context_Window_Exceeded -> Stop.Length
Nothing -> Stop.Stop