shikumi-0.3.0.0: test/StubProvider.hs
-- | Hand-rolled, network-free baikai providers for the hermetic shikumi tests.
--
-- Every helper builds an isolated 'ProviderRegistry' (never the global one) whose
-- single 'ApiProvider' serves a known 'Custom' 'Api' tag, so the default
-- @cabal test all@ needs no network and no API key. The variants differ only in
-- what @complete@ does: return fixed text, return text with a fixed dollar cost,
-- fail a fixed number of times then succeed, always fail with a non-transient
-- error, or instrument observed concurrency.
module StubProvider
( stubModel,
stubContext,
stubOptions,
flattenAssistantText,
stubRegistry,
costStubRegistry,
budgetBarrierStubRegistry,
failingStubRegistry,
failingStreamStubRegistry,
failingStreamCostStubRegistry,
invalidStubRegistry,
concurrencyStubRegistry,
)
where
import Baikai hiding (flattenAssistantText)
import Baikai.Prelude
import Control.Concurrent (threadDelay)
import Control.Concurrent.STM
( TVar,
atomically,
modifyTVar',
readTVar,
retry,
)
import Control.Exception (bracket_, throwIO)
import Data.Generics.Labels ()
import Data.IORef (IORef, atomicModifyIORef')
import Data.Text qualified as T
import Data.Vector qualified as V
import Streamly.Data.Stream qualified as Stream
-- | Concatenate the text of every 'AssistantText' block, ignoring thinking and
-- tool-call blocks. baikai 0.3 exports its own 'flattenAssistantText'; this stub
-- keeps a local copy (hidden from the @Baikai@ import) so the fixture's behavior
-- is pinned regardless of the library's.
flattenAssistantText :: V.Vector AssistantContent -> Text
flattenAssistantText = T.concat . V.toList . V.mapMaybe textOf
where
textOf (AssistantText (TextContent t)) = Just t
textOf _ = Nothing
-- | The 'Api' tag every stub serves. A 'Custom' tag needs no catalog entry.
stubApi :: Api
stubApi = Custom "stub"
-- | A hand-built 'Model' whose 'api' routes to the stub provider.
stubModel :: Model
stubModel =
_Model
& #api
.~ stubApi
& #modelId
.~ "stub-model"
& #name
.~ "stub"
& #provider
.~ "stub"
-- | A minimal request context (one user turn).
stubContext :: Context
stubContext = _Context & #messages .~ V.singleton (user "ping")
-- | Default request options.
stubOptions :: Options
stubOptions = _Options
-- | An assistant payload carrying the given text as its single text block.
stubPayloadWith :: Text -> AssistantPayload
stubPayloadWith t =
(_Response ^. #message)
& #content
.~ V.singleton (AssistantText (_TextContent & #text .~ t))
-- | A fixed assistant response carrying the given text as its single text block.
stubResponse :: Text -> Response
stubResponse t =
_Response
& #message
.~ stubPayloadWith t
& #model
.~ stubModel
& #api
.~ stubApi
& #provider
.~ "stub"
-- | A deterministic, valid event sequence for the given text.
stubEvents :: Text -> [AssistantMessageEvent]
stubEvents t =
[ EventStart StartPayload {partial = AssistantMessage (_Response ^. #message), responseId = Nothing},
TextStart IndexPayload {contentIndex = 0},
TextDelta DeltaPayload {contentIndex = 0, delta = t},
TextEnd BlockEndPayload {contentIndex = 0, content = t},
EventDone (doneTerminal Nothing Stop (AssistantMessage (stubPayloadWith t)))
]
-- | Build an isolated registry from a @complete@ implementation, reusing the
-- deterministic stub stream for the streaming path.
mkRegistry :: Text -> (Model -> Context -> Options -> IO Response) -> IO ProviderRegistry
mkRegistry streamText completeFn = do
reg <- newProviderRegistry
registerApiProviderWith
reg
ApiProvider
{ apiTag = stubApi,
complete = completeFn,
stream = \_ _ _ -> Stream.fromList (stubEvents streamText)
}
pure reg
-- | A registry whose @complete@ returns the given text.
stubRegistry :: Text -> IO ProviderRegistry
stubRegistry t = mkRegistry t (\_ _ _ -> pure (stubResponse t))
-- | A registry whose @complete@ returns the given text with a fixed dollar cost
-- recorded in @Usage.cost.usd@ (so the budget interpreter charges it).
costStubRegistry :: Rational -> Text -> IO ProviderRegistry
costStubRegistry c t =
mkRegistry t (\_ _ _ -> pure (stubResponse t & #message . #usage . #cost . #usd .~ c))
-- | A registry whose @complete@ blocks until @n@ calls have all arrived (a shared
-- 'TVar' barrier), then returns a cost-carrying response. Guarantees that @n@
-- concurrent calls are all in flight — each already past the budget's optimistic
-- @admitCall@ gate — before any of them records its cost, so a test can pin the
-- documented budget overshoot under concurrency.
budgetBarrierStubRegistry :: TVar Int -> Int -> Rational -> Text -> IO ProviderRegistry
budgetBarrierStubRegistry arrived n cost t =
mkRegistry t $ \_ _ _ -> do
atomically (modifyTVar' arrived (+ 1))
atomically $ do
a <- readTVar arrived
if a >= n then pure () else retry
pure (stubResponse t & #message . #usage . #cost . #usd .~ cost)
-- | A registry whose @complete@ throws 'providerError' (a transient failure) the
-- first @failTimes@ calls, then returns the given text. The 'IORef' records the
-- total number of attempts (used by the retry tests).
failingStubRegistry :: IORef Int -> Int -> Text -> IO ProviderRegistry
failingStubRegistry ref failTimes t =
mkRegistry t $ \_ _ _ -> do
n <- atomicModifyIORef' ref (\k -> (k + 1, k + 1))
if n <= failTimes
then throwIO (providerError ("stub transient failure #" <> T.pack (show n)))
else pure (stubResponse t)
-- | A terminal-failing event sequence: an 'EventError' whose assembled message
-- carries @errorMessage@ (and optionally a dollar cost). Shikumi's @LLM@
-- interpreters map this to a transient 'Shikumi.Error.ProviderFailure'.
streamErrorEvents :: Rational -> Text -> [AssistantMessageEvent]
streamErrorEvents cost msg =
[ EventStart StartPayload {partial = AssistantMessage (_Response ^. #message), responseId = Nothing},
EventError (doneTerminal Nothing ErrorReason (AssistantMessage errPayload))
]
where
errPayload =
(_Response ^. #message)
& #errorMessage
.~ Just msg
& #usage
. #cost
. #usd
.~ cost
-- | A registry whose /stream/ fails (a terminal 'EventError') the first
-- @failTimes@ calls, then streams success. @complete@ is irrelevant (stream-only
-- tests). The 'IORef' records the total number of stream attempts, so a retry test
-- can assert the loop fired.
failingStreamStubRegistry :: IORef Int -> Int -> Text -> IO ProviderRegistry
failingStreamStubRegistry ref failTimes t = do
reg <- newProviderRegistry
registerApiProviderWith
reg
ApiProvider
{ apiTag = stubApi,
complete = \_ _ _ -> pure (stubResponse t),
stream = \_ _ _ -> Stream.concatEffect $ do
n <- atomicModifyIORef' ref (\k -> (k + 1, k + 1))
pure $
Stream.fromList $
if n <= failTimes
then streamErrorEvents 0 ("stub stream failure #" <> T.pack (show n))
else stubEvents t
}
pure reg
-- | A registry whose /stream/ always fails with a terminal 'EventError' carrying
-- the given dollar cost, so a test can assert the budget is charged even though the
-- stream failed.
failingStreamCostStubRegistry :: Rational -> IO ProviderRegistry
failingStreamCostStubRegistry cost = do
reg <- newProviderRegistry
registerApiProviderWith
reg
ApiProvider
{ apiTag = stubApi,
complete = \_ _ _ -> pure (stubResponse ""),
stream = \_ _ _ -> Stream.fromList (streamErrorEvents cost "stub stream failure")
}
pure reg
-- | A registry whose @complete@ always throws 'invalidRequest' (a non-transient
-- failure that maps to 'Shikumi.Error.SchemaMismatch'). The 'IORef' records the
-- number of attempts (a retry must not increase it).
invalidStubRegistry :: IORef Int -> IO ProviderRegistry
invalidStubRegistry ref =
mkRegistry "" $ \_ _ _ -> do
_ <- atomicModifyIORef' ref (\k -> (k + 1, k + 1))
throwIO (invalidRequest "stub bad request")
-- | A registry whose @complete@ records observed concurrency: it bumps @cur@ on
-- entry (updating the running maximum in @mx@), sleeps briefly, and decrements on
-- exit. Used to assert that a rate limit caps in-flight calls.
concurrencyStubRegistry :: TVar Int -> TVar Int -> Text -> IO ProviderRegistry
concurrencyStubRegistry cur mx t =
mkRegistry t $ \_ _ _ ->
bracket_ enter leave (threadDelay 30000 >> pure (stubResponse t))
where
enter = atomically $ do
modifyTVar' cur (+ 1)
c <- readTVar cur
modifyTVar' mx (max c)
leave = atomically (modifyTVar' cur (subtract 1))