packages feed

kioku-core-0.6.0.0: src/Kioku/AI/Runtime.hs

{-# LANGUAGE DataKinds #-}

-- | Immutable dispatch through capabilities supplied by the embedding host.
module Kioku.AI.Runtime
  ( AIRuntime,
    HostCapabilities (..),
    noHostCapabilities,
    newAIRuntime,
    disabledAIRuntime,
    featureConfiguration,
    executionAvailability,
    runAIProgram,
    runtimeEmbeddingModel,
    interactiveLauncher,
  )
where

import Baikai.Api (Api (..), normaliseApi)
import Baikai.Embedding qualified as Embedding
import Baikai.Interactive (InteractiveLaunchRequest, InteractiveLaunchResult)
import Baikai.Interactive qualified
import Baikai.Model qualified as Model
import Baikai.Options qualified as Options
import Baikai.Provider.Registry qualified as Registry
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes)
import Data.Text qualified as Text
import Effectful (runEff)
import Effectful.Concurrent (runConcurrent)
import Effectful.Dispatch.Dynamic (interpose)
import Effectful.Error.Static (runErrorNoCallStack)
import Kioku.AI.Config
import Kioku.Prelude
import Shikumi.Error (ShikumiError)
import Shikumi.LLM qualified as LLM
import Shikumi.Program (Program, runProgram)
import Shikumi.Routing (routeLLM, runRouting)

-- | Registries grant separate API and batch capabilities. The callback grants a
-- fresh interactive session; presence of a terminal or executable grants none.
data HostCapabilities = HostCapabilities
  { apiRegistry :: Maybe Registry.ProviderRegistry,
    batchRegistry :: Maybe Registry.ProviderRegistry,
    allowInteractive :: Bool,
    launchInteractive :: Maybe (AIFeature -> InteractiveLaunchRequest -> IO (Either AIExecutionError InteractiveLaunchResult)),
    allowEmbeddingAPI :: Bool
  }

noHostCapabilities :: HostCapabilities
noHostCapabilities = HostCapabilities Nothing Nothing False Nothing False

data AIRuntime = AIRuntime AIConfig HostCapabilities (Map.Map AIFeature Registry.ProviderRegistry)

disabledAIRuntime :: AIRuntime
disabledAIRuntime = AIRuntime disabledAIConfig noHostCapabilities Map.empty

featureConfiguration :: AIRuntime -> AIFeature -> DistillationConfig
featureConfiguration (AIRuntime cfg _ _) feature
  | feature `elem` distillationFeatures = Map.findWithDefault cfg.distillationDefault feature cfg.featureOverrides
  | otherwise = DistillationDisabled

runtimeEmbeddingModel :: AIRuntime -> AIFeature -> Either AIExecutionError Embedding.EmbeddingModel
runtimeEmbeddingModel (AIRuntime cfg _ _) feature = maybe (Left (AIDisabled feature)) Right (Map.lookup feature cfg.embeddingSettings)

interactiveLauncher :: AIRuntime -> Maybe (AIFeature -> InteractiveLaunchRequest -> IO (Either AIExecutionError InteractiveLaunchResult))
interactiveLauncher (AIRuntime _ caps _) = caps.launchInteractive

executionAvailability :: AIRuntime -> AIFeature -> Either AIExecutionError ()
executionAvailability rt feature
  | feature `elem` embeddingFeatures = () <$ runtimeEmbeddingModel rt feature
  | otherwise = case featureConfiguration rt feature of
      DistillationDisabled -> Left (AIDisabled feature)
      InteractiveConfig {} -> maybe (Left (InteractiveUnavailable feature)) (const (Right ())) (interactiveLauncher rt)
      CompletionConfig {} -> Right ()

-- | Validate without reading credentials or invoking handlers. Snapshot selected
-- handlers into private registries so later host registration cannot widen them.
newAIRuntime :: HostCapabilities -> AIConfig -> IO (Either AIConfigurationError AIRuntime)
newAIRuntime caps cfg = case validateShape of
  Left err -> pure (Left err)
  Right () -> do
    selected <- traverse validateFeature distillationFeatures
    pure $ AIRuntime cfg caps . Map.fromList . catMaybes <$> sequence selected
  where
    invalid f = Left . InvalidAIConfiguration (Just f)
    validateShape = do
      forM_ (Map.keys cfg.featureOverrides) $ \f ->
        unless (f `elem` distillationFeatures) (invalid f "embedding features require embedding settings")
      forM_ (Map.toList cfg.embeddingSettings) $ \(f, model) -> do
        unless (f `elem` embeddingFeatures) (invalid f "distillation features require distillation settings")
        unless caps.allowEmbeddingAPI (invalid f "host has not granted embedding API execution")
        when (Text.null (Text.strip model.modelId)) (invalid f "select an embedding model")
        unless (model.dimensions == Just 1536) (invalid f "stored vectors require 1536 dimensions; migrate and re-embed before changing models")
      case Map.elems cfg.embeddingSettings of
        [] -> Right ()
        model : rest ->
          unless
            (all (\other -> other.modelId == model.modelId && other.baseUrl == model.baseUrl) rest)
            (Left (InvalidAIConfiguration Nothing "memory, query and candidate embeddings must use the same model and endpoint"))
    validateFeature f = case featureConfiguration (AIRuntime cfg caps Map.empty) f of
      DistillationDisabled -> pure (Right Nothing)
      InteractiveConfig _ request -> pure $ do
        unless caps.allowInteractive (invalid f "host has not granted interactive execution")
        when (maybe True (Text.null . Text.strip) request.modelId) (invalid f "select an interactive model")
        Right Nothing
      CompletionConfig mode model _ -> do
        let registry = case mode of API -> caps.apiRegistry; Batch -> caps.batchRegistry; _ -> Nothing
            tag = normaliseApi model.api
            tagAllowed = case tag of
              AnthropicMessagesCli -> mode == Batch
              OpenAICompletionsCli -> mode == Batch
              Custom _ -> mode == API || mode == Batch
              _ -> mode == API
        if Text.null (Text.strip (Model.modelId model))
          then pure (invalid f "select a completion model")
          else
            if not tagAllowed
              then pure (invalid f "model transport does not match execution mode")
              else case registry of
                Nothing -> pure (invalid f "host has not granted this completion mode")
                Just reg ->
                  Registry.lookupApiProviderWith reg tag >>= \case
                    Nothing -> pure (invalid f "register the selected transport in the host registry")
                    Just handler -> do
                      snapshot <- Registry.newProviderRegistryFrom [handler]
                      pure (Right (Just (f, snapshot)))

-- | Force both model and options at the final LLM boundary. A Program's nested
-- routing choices cannot escape the selected feature capability.
runAIProgram :: AIRuntime -> AIFeature -> Program i o -> i -> IO (Either AIExecutionError o)
runAIProgram rt@(AIRuntime _ _ registries) feature prog input = case executionAvailability rt feature of
  Left err -> pure (Left err)
  Right () -> case (featureConfiguration rt feature, Map.lookup feature registries) of
    (CompletionConfig _ model selected, Just reg) -> do
      result <-
        runEff
          . runErrorNoCallStack @ShikumiError
          . runConcurrent
          . runRouting model
          . LLM.runLLMResilient (LLM.defaultLLMConfig reg)
          . interpose
            ( \_ -> \case
                LLM.Complete _ ctx opts -> LLM.complete model ctx (wireOptions selected opts)
                LLM.Stream _ ctx opts -> LLM.stream model ctx (wireOptions selected opts)
            )
          . routeLLM
          $ runProgram prog input
      pure (either (Left . AIProgramFailed) Right result)
    _ -> pure (Left (AIExecutionRefused feature "interactive execution requires a typed signature result handoff"))
  where
    wireOptions selected rendered =
      selected
        { Options.responseFormat = rendered.responseFormat,
          Options.metadata = Map.union rendered.metadata selected.metadata
        }