packages feed

baikai-0.3.0.0: src/Baikai/Provider/Registry.hs

-- | The provider registry — the dispatch surface that replaces the
-- prior 'Baikai.Provider' typeclass and 'SomeProvider' existential.
--
-- An 'ApiProvider' is the per-API handler. EP-3 promotes 'stream' to
-- the primary method: every handler exposes a streaming producer
-- that emits 'AssistantMessageEvent' values, and 'complete' is the
-- synchronous draining wrapper (typically @streamingComplete . stream@).
-- Callers can use an explicit 'ProviderRegistry' handle to isolate handler
-- sets, or use the global convenience registry for simple scripts.
--
-- 'completeRequest' looks the handler up by the 'Model'\'s 'Api'
-- tag and dispatches; when no handler is registered it returns an
-- error-shaped 'Response' in the 'Baikai.Error.ProviderUnavailable'
-- category.
module Baikai.Provider.Registry
  ( ApiProvider (..),
    ProviderRegistry,
    newProviderRegistry,
    newProviderRegistryFrom,
    globalProviderRegistry,
    registerApiProviderWith,
    registerApiProvider,
    assertRegistered,
    lookupApiProviderWith,
    lookupApiProvider,
    completeRequestWith,
    completeRequest,
    runToolLoopWith,
    runToolLoop,
    completeText,
  )
where

import Baikai.Api (Api, renderApi)
import Baikai.Content (AssistantContent (..), ToolCall)
import Baikai.Context (Context, appendToolResult, contextOf)
import Baikai.Error (providerUnavailable)
import Baikai.Message (AssistantPayload (..), ToolResult, toolResultErrorText, user)
import Baikai.Model (Model)
import Baikai.Model qualified as Model
import Baikai.Options (Options, emptyOptions)
import Baikai.Response (Response (..), errorResponse, flattenAssistantBlocks, flattenAssistantText, responseError)
import Baikai.StopReason (StopReason (..))
import Baikai.Stream.Event (AssistantMessageEvent)
import Control.Exception qualified as Exception
import Control.Monad (forM, unless)
import Data.Foldable (traverse_)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (getCurrentTime)
import Data.Vector qualified as Vector
import Streamly.Data.Stream (Stream)
import System.IO.Unsafe (unsafePerformIO)

-- | A per-API handler. 'stream' is the primary streaming
-- entry point; 'complete' is the synchronous draining wrapper,
-- typically @streamingComplete . stream@ from "Baikai.Stream".
data ApiProvider = ApiProvider
  { apiTag :: !Api,
    stream :: !(Model -> Context -> Options -> Stream IO AssistantMessageEvent),
    complete :: !(Model -> Context -> Options -> IO Response)
  }

-- | A mutable provider registry handle. Each handle owns its own handler map,
-- so tests and applications can maintain isolated provider sets in one process.
newtype ProviderRegistry = ProviderRegistry
  { registryRef :: IORef (Map Api ApiProvider)
  }

-- | Construct an empty provider registry.
newProviderRegistry :: IO ProviderRegistry
newProviderRegistry = ProviderRegistry <$> newIORef Map.empty

-- | Construct a registry pre-populated with the given providers. Later entries
-- win on tag collisions, matching 'registerApiProviderWith'.
newProviderRegistryFrom :: [ApiProvider] -> IO ProviderRegistry
newProviderRegistryFrom providers = do
  reg <- newProviderRegistry
  traverse_ (registerApiProviderWith reg) providers
  pure reg

-- | Process-global handler registry used by the legacy convenience functions.
-- The 'unsafePerformIO' + 'NOINLINE' pattern mirrors 'Baikai.Trace'\'s
-- @eventCounter@; this is a single shared handle for the lifetime of the
-- process.
globalProviderRegistry :: ProviderRegistry
globalProviderRegistry = unsafePerformIO newProviderRegistry
{-# NOINLINE globalProviderRegistry #-}

-- | Install (or replace) a handler. Idempotent for the same 'Api'
-- tag — calling 'registerApiProviderWith' twice for the same tag keeps only
-- the second handler.
registerApiProviderWith :: ProviderRegistry -> ApiProvider -> IO ()
registerApiProviderWith reg p =
  atomicModifyIORef' (registryRef reg) $ \m -> (Map.insert (apiTag p) p m, ())

-- | Install (or replace) a handler in the process-global registry.
registerApiProvider :: ApiProvider -> IO ()
registerApiProvider = registerApiProviderWith globalProviderRegistry

-- | Startup preflight: throw 'Baikai.Error.ProviderUnavailable' listing every
-- requested API tag that has no registered handler.
assertRegistered :: ProviderRegistry -> [Api] -> IO ()
assertRegistered reg tags = do
  missing <-
    fmap concat $
      forM tags $ \tag -> do
        found <- lookupApiProviderWith reg tag
        pure $ case found of
          Nothing -> [tag]
          Just _ -> []
  unless (null missing) $
    Exception.throwIO
      ( providerUnavailable
          ( "no provider registered for: "
              <> Text.intercalate ", " (renderApi <$> missing)
          )
      )

-- | Look up the handler registered for an 'Api' tag.
lookupApiProviderWith :: ProviderRegistry -> Api -> IO (Maybe ApiProvider)
lookupApiProviderWith reg tag = Map.lookup tag <$> readIORef (registryRef reg)

-- | Look up the handler registered for an 'Api' tag in the process-global
-- registry.
lookupApiProvider :: Api -> IO (Maybe ApiProvider)
lookupApiProvider = lookupApiProviderWith globalProviderRegistry

-- | Dispatch a synchronous request through the registered handler
-- for the model's 'Api' tag. Returns an error-shaped 'Response' when
-- no handler is registered for that tag.
completeRequestWith :: ProviderRegistry -> Model -> Context -> Options -> IO Response
completeRequestWith reg m ctx opts = do
  mProvider <- lookupApiProviderWith reg (Model.api m)
  case mProvider of
    Just p -> complete p m ctx opts
    Nothing -> do
      now <- getCurrentTime
      pure $
        errorResponse
          m
          now
          0
          (providerUnavailable ("No provider registered for API: " <> renderApi (Model.api m)))

-- | Dispatch a synchronous request through the process-global registry.
completeRequest :: Model -> Context -> Options -> IO Response
completeRequest = completeRequestWith globalProviderRegistry

-- | Drive a tool-using conversation through the process-global registry.
--
-- The returned 'Context' contains the input context plus only fully resolved
-- assistant/tool-result exchanges, so it is always valid to replay. The final
-- 'Response' is not appended; callers that want the full transcript can use
-- 'Baikai.Context.addResponse'. 'Options' is passed unchanged each turn, so
-- forcing tools in options can exhaust the budget; loops should usually let
-- the provider choose tools automatically.
runToolLoop ::
  Int ->
  (ToolCall -> IO ToolResult) ->
  Model ->
  Context ->
  Options ->
  IO (Context, Response)
runToolLoop = runToolLoopWith globalProviderRegistry

-- | Drive a tool-using conversation through an explicit registry.
--
-- The turn budget is clamped to at least one model call. Synchronous dispatcher
-- exceptions become error tool results so the model can recover; asynchronous
-- exceptions are rethrown. Dispatchers should return 'toolResultErrorText' for
-- unknown tool names rather than throwing.
runToolLoopWith ::
  ProviderRegistry ->
  Int ->
  (ToolCall -> IO ToolResult) ->
  Model ->
  Context ->
  Options ->
  IO (Context, Response)
runToolLoopWith reg budget dispatcher model ctx0 opts =
  go (max 1 budget) ctx0
  where
    go remaining ctx = do
      resp <- completeRequestWith reg model ctx opts
      if shouldStop remaining resp
        then pure (ctx, resp)
        else do
          ctx' <- appendToolResult ctx resp (safeDispatcher dispatcher)
          go (remaining - 1) ctx'

    shouldStop remaining resp =
      responseError resp /= Nothing
        || responseStopReason resp /= ToolUse
        || Vector.null (responseToolCalls resp)
        || remaining <= 1

-- | One-shot text completion through the global registry. Throws the
-- 'Baikai.Error.BaikaiError' when the response is error-shaped.
completeText :: Model -> Text -> IO Text
completeText model prompt = do
  resp <- completeRequest model (contextOf [user prompt]) emptyOptions
  case responseError resp of
    Just err -> Exception.throwIO err
    Nothing -> pure (flattenAssistantText (flattenAssistantBlocks resp))

safeDispatcher :: (ToolCall -> IO ToolResult) -> ToolCall -> IO ToolResult
safeDispatcher dispatcher tc = do
  result <- trySync (dispatcher tc)
  case result of
    Right ok -> pure ok
    Left e -> pure (toolResultErrorText (Text.pack (Exception.displayException e)))

trySync :: IO a -> IO (Either Exception.SomeException a)
trySync action = do
  result <- Exception.try action
  case result of
    Left e
      | Just (Exception.SomeAsyncException _) <-
          (Exception.fromException e :: Maybe Exception.SomeAsyncException) ->
          Exception.throwIO e
      | otherwise -> pure (Left e)
    Right a -> pure (Right a)

responseStopReason :: Response -> StopReason
responseStopReason Response {message = AssistantPayload {stopReason = sr}} = sr

responseToolCalls :: Response -> Vector.Vector ToolCall
responseToolCalls Response {message = AssistantPayload {content = blocks}} =
  Vector.mapMaybe toolCallOf blocks
  where
    toolCallOf (AssistantToolCall tc) = Just tc
    toolCallOf _ = Nothing