packages feed

shikumi-0.3.0.0: src/Shikumi/Routing.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}

-- | Ambient model routing (EP-14) — the keystone that lets
-- 'Shikumi.Program.runProgram' dispatch a /named/ model without changing its
-- pinned signature (MasterPlan integration point #4:
-- @runProgram :: (LLM :> es, Error ShikumiError :> es) => Program i o -> i -> Eff es o@).
--
-- The mechanism is approach (a) from the parent MasterPlan: a re-interpreting
-- router installed /below/ 'Shikumi.Program.runProgram' on the existing @LLM@
-- effect — exactly the seam @cachedLLM@ ("Shikumi.Cache") and @tracedLLM@
-- ("Shikumi.Trace") already use. 'runProgram' renders each 'Predict' node
-- model-agnostically against the inert placeholder model and stamps its intentions
-- (the derived JSON schema, any per-sample temperature, and the native-format render
-- alternative — system prompt + JSON demos) onto the private request-metadata
-- channel ('Shikumi.Adapter.attachSchema' / 'Shikumi.Adapter.stampTemperature' /
-- 'Shikumi.Adapter.attachNativeRender'). 'routeLLM' then reads the /ambient/ model
-- supplied by 'runRouting', overwrites the placeholder with it, and calls
-- 'translateForWire', which for a native-capable model sets
-- @Options.responseFormat@ from the stamped schema and swaps the marker @Context@
-- (system prompt + demo assistant turns) for the stamped native-format alternative;
-- sets @Options.temperature@ when stamped; and in all cases strips the private keys
-- before forwarding the call to the real @LLM@ interpreter beneath it.
--
-- Install order (mirroring @runTrace . runKeyedLLM . tracedLLM@): 'runRouting' is
-- /outer/ of the real @LLM@ interpreter, which is /outer/ of 'routeLLM' (the
-- innermost, closest to 'runProgram'). For example:
--
-- @
-- runEff
--   . runErrorNoCallStack \@ShikumiError
--   . runRouting openai_gpt_4o_mini   -- supplies the ambient model
--   . runLLMResilient cfg             -- the real LLM interpreter (or a stub)
--   . routeLLM                        -- rewrites outgoing LLM calls
--   $ runProgram program input
-- @
module Shikumi.Routing
  ( -- * The effect
    Routing (..),
    currentModel,
    runRouting,

    -- * The router
    routeLLM,
  )
where

import Baikai (Context, Message (..), Model, Options, ResponseFormat (..), assistant)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson (FromJSON, Result (..), Value, fromJSON)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Vector qualified as V
import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>))
import Effectful.Dispatch.Dynamic (interpose, interpret, send)
import Shikumi.Adapter
  ( ModelCapability (..),
    capabilityFor,
    metaNativeDemosKey,
    metaNativePromptKey,
    metaResponseSchemaKey,
    metaTemperatureKey,
  )
import Shikumi.LLM (LLM (..), complete, stream)

-- | The ambient model-routing effect. Its single operation reads the model every
-- 'Predict' node should dispatch against. It is supplied by an interpreter at the
-- bottom of the stack ('runRouting'), exactly as @LLM@ and @Error@ already are, so
-- it never appears in 'Shikumi.Program.runProgram'\'s constraint row.
data Routing :: Effect where
  CurrentModel :: Routing m Model

type instance DispatchOf Routing = 'Dynamic

-- | Read the ambient model.
currentModel :: (Routing :> es) => Eff es Model
currentModel = send CurrentModel

-- | Supply a constant ambient model to everything above. A pure reader: every
-- 'currentModel' resolves to @m@.
runRouting :: Model -> Eff (Routing : es) a -> Eff es a
runRouting m = interpret (\_ CurrentModel -> pure m)

-- | The router. Re-interpret the @LLM@ effect so each outgoing call — /both/
-- 'Complete' and 'Stream' — is dispatched against the ambient model (overwriting
-- the placeholder model the model-agnostic 'Shikumi.Program.runProgram' /
-- 'Shikumi.Stream.streamProgram' passes), with the private metadata channel
-- translated to real wire options (and the marker 'Context' swapped for the native
-- one for native-capable models) and stripped. The two operations are rewritten
-- identically through the single 'translateForWire', so a streamed call gets the
-- same real model id and wire options a blocking call does.
routeLLM :: (Routing :> es, LLM :> es) => Eff es a -> Eff es a
routeLLM = interpose $ \_ -> \case
  Complete _placeholder ctx opts -> do
    m <- currentModel
    let (ctx', opts') = translateForWire m ctx opts
    complete m ctx' opts'
  Stream _placeholder ctx opts -> do
    m <- currentModel
    let (ctx', opts') = translateForWire m ctx opts
    stream m ctx' opts'

-- | Realize the private request-metadata channel against the real model. For a
-- native-capable model: attach the native @responseFormat@ from the stamped
-- schema, and swap the marker-format 'Context' (system prompt + demo assistant
-- turns) for the stamped native-format alternative. In all cases: set
-- @temperature@ when one was stamped, and strip every private @shikumi.*@ key so
-- nothing private reaches the transport. Fallback-capability models keep the
-- marker 'Context' unchanged; non-'Predict' @Complete@ calls (no stamps) are never
-- rewritten.
translateForWire :: Model -> Context -> Options -> (Context, Options)
translateForWire m ctx opts =
  let md = opts ^. #metadata
      mSchema = Map.lookup metaResponseSchemaKey md
      mTemp = Map.lookup metaTemperatureKey md >>= valueToDouble
      mNativeSys = Map.lookup metaNativePromptKey md >>= fromJSONMaybe
      mNativeDemos = Map.lookup metaNativeDemosKey md >>= fromJSONMaybe
      stripped =
        foldr
          Map.delete
          md
          [metaResponseSchemaKey, metaTemperatureKey, metaNativePromptKey, metaNativeDemosKey]
      isNative = case capabilityFor m of NativeSchema -> True; PromptFallback -> False
      withSchema o
        | isNative,
          Just s <- mSchema =
            o & #responseFormat .~ Just (JsonSchema {name = "output", schema = s, strict = True})
        | otherwise = o
      withTemp o = case mTemp of
        Just t -> o & #temperature .~ Just t
        Nothing -> o
      opts' = withTemp (withSchema (opts & #metadata .~ stripped))
      ctx' = case (isNative, mNativeSys, mNativeDemos) of
        (True, Just sys, Just demos) -> rewriteNativeContext sys demos ctx
        _ -> ctx
   in (ctx', opts')

-- | Install the native-format system prompt and demo assistant turns into a
-- 'Context'. The assistant turns of a 'Predict'-rendered context are exactly the
-- demo outputs, in order; each is replaced (via baikai's deterministic 'assistant'
-- constructor) with the corresponding stamped native text. Defensive: if the count
-- of assistant turns differs from the stamped list length the messages are left
-- untouched (only the system prompt is swapped), so a hand-built or unexpected
-- context is never corrupted.
rewriteNativeContext :: Text -> [Text] -> Context -> Context
rewriteNativeContext sys demos ctx =
  let msgs = V.toList (ctx ^. #messages)
      assistantCount = length [() | AssistantMessage _ <- msgs]
      msgs'
        | assistantCount == length demos = replaceAssistants demos msgs
        | otherwise = msgs
   in ctx & #systemPrompt .~ Just sys & #messages .~ V.fromList msgs'

-- | Replace each assistant message's text with the next demo text, in order,
-- preserving all user/tool messages. Total: if the demo list runs out, remaining
-- assistant messages are kept as-is.
replaceAssistants :: [Text] -> [Message] -> [Message]
replaceAssistants _ [] = []
replaceAssistants ds (m : rest) = case m of
  AssistantMessage _ -> case ds of
    (d : ds') -> assistant d : replaceAssistants ds' rest
    [] -> m : replaceAssistants ds rest
  _ -> m : replaceAssistants ds rest

-- | Read a JSON number back into a 'Double' (the per-sample temperature).
valueToDouble :: Value -> Maybe Double
valueToDouble v = case fromJSON v of
  Success d -> Just d
  Error _ -> Nothing

-- | Read a stamped JSON value back into a decodable Haskell value (the native
-- prompt 'Text' and the demo @[Text]@), returning 'Nothing' on any mismatch.
fromJSONMaybe :: (FromJSON a) => Value -> Maybe a
fromJSONMaybe v = case fromJSON v of
  Success a -> Just a
  Error _ -> Nothing