packages feed

shikumi 0.3.0.3 → 0.4.0.0

raw patch · 27 files changed

+1504/−160 lines, 27 filesdep ~baikaidep ~baikai-claudedep ~baikai-effectfulPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: baikai, baikai-claude, baikai-effectful, baikai-openai, shikumi

API changes (from Hackage documentation)

+ Shikumi.Adapter: nestedXmlAdapter :: forall i o. (ToSchema o, FromModel o, Validatable o, ToPrompt i, ToJSON o) => Adapter i o
+ Shikumi.Error: ProviderError :: !BaikaiError -> ShikumiError
+ Shikumi.Error: renderShikumiError :: ShikumiError -> Text
+ Shikumi.LLM: [observer] :: LLMConfig -> !Maybe LLMObserver
+ Shikumi.LLM: runLLMWithObserver :: forall (es :: [Effect]) a. (IOE :> es, Error ShikumiError :> es) => ProviderRegistry -> LLMObserver -> Eff (LLM ': es) a -> Eff es a
+ Shikumi.LLM.Continuation: contextIdentity :: Context -> Value
+ Shikumi.LLM.Continuation: data RequestOrigin
+ Shikumi.LLM.Continuation: hasOpaqueContinuation :: [Message] -> Bool
+ Shikumi.LLM.Continuation: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.LLM.Continuation.RequestOrigin
+ Shikumi.LLM.Continuation: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.LLM.Continuation.RequestOrigin
+ Shikumi.LLM.Continuation: instance GHC.Classes.Eq Shikumi.LLM.Continuation.RequestOrigin
+ Shikumi.LLM.Continuation: instance GHC.Internal.Generics.Generic Shikumi.LLM.Continuation.RequestOrigin
+ Shikumi.LLM.Continuation: instance GHC.Internal.Show.Show Shikumi.LLM.Continuation.RequestOrigin
+ Shikumi.LLM.Continuation: opaqueThinking :: ThinkingContent -> Bool
+ Shikumi.LLM.Continuation: originModel :: RequestOrigin -> Model
+ Shikumi.LLM.Continuation: requestOrigin :: Model -> Maybe RequestOrigin
+ Shikumi.LLM.Continuation: stampContinuation :: Maybe RequestOrigin -> Maybe Value -> Options -> Options
+ Shikumi.LLM.Continuation: stripContinuationMetadata :: Options -> Options
+ Shikumi.LLM.Continuation: validateReplayOrigin :: Model -> [Message] -> Either ShikumiError ()
+ Shikumi.LLM.Continuation: validateRequestContinuation :: Model -> Context -> Options -> Either ShikumiError ()
+ Shikumi.LLM.Defaults: RequestDefaults :: !Maybe ThinkingLevel -> !Maybe Speed -> !Maybe Natural -> !Maybe EvidenceRequest -> RequestDefaults
+ Shikumi.LLM.Defaults: [defaultEvidence] :: RequestDefaults -> !Maybe EvidenceRequest
+ Shikumi.LLM.Defaults: [defaultMaxTokens] :: RequestDefaults -> !Maybe Natural
+ Shikumi.LLM.Defaults: [defaultSpeed] :: RequestDefaults -> !Maybe Speed
+ Shikumi.LLM.Defaults: [defaultThinking] :: RequestDefaults -> !Maybe ThinkingLevel
+ Shikumi.LLM.Defaults: applyRequestDefaults :: RequestDefaults -> Options -> Options
+ Shikumi.LLM.Defaults: data RequestDefaults
+ Shikumi.LLM.Defaults: emptyRequestDefaults :: RequestDefaults
+ Shikumi.LLM.Defaults: instance GHC.Classes.Eq Shikumi.LLM.Defaults.RequestDefaults
+ Shikumi.LLM.Defaults: instance GHC.Internal.Show.Show Shikumi.LLM.Defaults.RequestDefaults
+ Shikumi.LLM.Defaults: withRequestDefaults :: forall (es :: [Effect]) a. (LLM :> es, Error ShikumiError :> es) => RequestDefaults -> Eff es a -> Eff es a
+ Shikumi.LLM.Observation: BillingSummary :: !Int -> !Int -> !Int -> !UsageRecord -> ![LLMObservation] -> !Bool -> BillingSummary
+ Shikumi.LLM.Observation: CompletionCall :: CallKind
+ Shikumi.LLM.Observation: LLMObservation :: !Text -> !Int -> !CallKind -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Maybe UsageRecord -> !UTCTime -> !UTCTime -> LLMObservation
+ Shikumi.LLM.Observation: StreamCall :: CallKind
+ Shikumi.LLM.Observation: UsageRecord :: Usage -> UsageRecord
+ Shikumi.LLM.Observation: [attempt] :: LLMObservation -> !Int
+ Shikumi.LLM.Observation: [callId] :: LLMObservation -> !Text
+ Shikumi.LLM.Observation: [callKind] :: LLMObservation -> !CallKind
+ Shikumi.LLM.Observation: [completedAttempts] :: BillingSummary -> !Int
+ Shikumi.LLM.Observation: [detailTruncated] :: BillingSummary -> !Bool
+ Shikumi.LLM.Observation: [endedAt] :: LLMObservation -> !UTCTime
+ Shikumi.LLM.Observation: [failedAttempts] :: BillingSummary -> !Int
+ Shikumi.LLM.Observation: [getUsage] :: UsageRecord -> Usage
+ Shikumi.LLM.Observation: [observedModel] :: LLMObservation -> !Maybe Text
+ Shikumi.LLM.Observation: [observedUsage] :: BillingSummary -> !UsageRecord
+ Shikumi.LLM.Observation: [requestedModel] :: LLMObservation -> !Text
+ Shikumi.LLM.Observation: [requestedProvider] :: LLMObservation -> !Text
+ Shikumi.LLM.Observation: [retainedAttempts] :: BillingSummary -> ![LLMObservation]
+ Shikumi.LLM.Observation: [startedAt] :: LLMObservation -> !UTCTime
+ Shikumi.LLM.Observation: [terminalError] :: LLMObservation -> !Maybe Text
+ Shikumi.LLM.Observation: [unknownUsageAttempts] :: BillingSummary -> !Int
+ Shikumi.LLM.Observation: [usage] :: LLMObservation -> !Maybe UsageRecord
+ Shikumi.LLM.Observation: data BillingSummary
+ Shikumi.LLM.Observation: data CallKind
+ Shikumi.LLM.Observation: data LLMObservation
+ Shikumi.LLM.Observation: emptyBillingSummary :: BillingSummary
+ Shikumi.LLM.Observation: errorClass :: ShikumiError -> Text
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.LLM.Observation.BillingSummary
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.LLM.Observation.CallKind
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.LLM.Observation.LLMObservation
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.LLM.Observation.UsageRecord
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.LLM.Observation.BillingSummary
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.LLM.Observation.CallKind
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.LLM.Observation.LLMObservation
+ Shikumi.LLM.Observation: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.LLM.Observation.UsageRecord
+ Shikumi.LLM.Observation: instance GHC.Classes.Eq Shikumi.LLM.Observation.BillingSummary
+ Shikumi.LLM.Observation: instance GHC.Classes.Eq Shikumi.LLM.Observation.CallKind
+ Shikumi.LLM.Observation: instance GHC.Classes.Eq Shikumi.LLM.Observation.LLMObservation
+ Shikumi.LLM.Observation: instance GHC.Classes.Eq Shikumi.LLM.Observation.UsageRecord
+ Shikumi.LLM.Observation: instance GHC.Internal.Generics.Generic Shikumi.LLM.Observation.BillingSummary
+ Shikumi.LLM.Observation: instance GHC.Internal.Generics.Generic Shikumi.LLM.Observation.CallKind
+ Shikumi.LLM.Observation: instance GHC.Internal.Generics.Generic Shikumi.LLM.Observation.LLMObservation
+ Shikumi.LLM.Observation: instance GHC.Internal.Show.Show Shikumi.LLM.Observation.BillingSummary
+ Shikumi.LLM.Observation: instance GHC.Internal.Show.Show Shikumi.LLM.Observation.CallKind
+ Shikumi.LLM.Observation: instance GHC.Internal.Show.Show Shikumi.LLM.Observation.LLMObservation
+ Shikumi.LLM.Observation: instance GHC.Internal.Show.Show Shikumi.LLM.Observation.UsageRecord
+ Shikumi.LLM.Observation: newBillingCollector :: IO (LLMObserver, IO BillingSummary)
+ Shikumi.LLM.Observation: newBillingCollectorWithLimit :: Int -> IO (LLMObserver, IO BillingSummary)
+ Shikumi.LLM.Observation: newtype UsageRecord
+ Shikumi.LLM.Observation: noObservation :: LLMObserver
+ Shikumi.LLM.Observation: observedModelOf :: Maybe ModelCallEvidence -> Maybe Text
+ Shikumi.LLM.Observation: renderBillingSummary :: BillingSummary -> Text
+ Shikumi.LLM.Observation: type LLMObserver = LLMObservation -> IO ()
+ Shikumi.LLM.Observation: usageUnknown :: Maybe UsageRecord -> Bool
+ Shikumi.Module: predictCaptured :: (FromModel i, FromModel o, ToSchema i, ToSchema o, ToJSON i, ToJSON o, Validatable o, ToPrompt i, ToPrompt o) => Signature i o -> Program i o
+ Shikumi.Program: CaptureCodec :: (i -> Value) -> (o -> Value) -> Value -> Value -> CaptureCodec i o
+ Shikumi.Program: [PredictCaptured] :: forall i o. (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o) => CaptureCodec i o -> Signature i o -> Params -> Program i o
+ Shikumi.Program: [captureInputSchema] :: CaptureCodec i o -> Value
+ Shikumi.Program: [captureOutputSchema] :: CaptureCodec i o -> Value
+ Shikumi.Program: [encodeCaptureInput] :: CaptureCodec i o -> i -> Value
+ Shikumi.Program: [encodeCaptureOutput] :: CaptureCodec i o -> o -> Value
+ Shikumi.Program: data CaptureCodec i o
- Shikumi.LLM: LLMConfig :: !RetryPolicy -> !Maybe Budget -> !Maybe RateLimiter -> !ProviderRegistry -> LLMConfig
+ Shikumi.LLM: LLMConfig :: !RetryPolicy -> !Maybe Budget -> !Maybe RateLimiter -> !ProviderRegistry -> !Maybe LLMObserver -> LLMConfig
- Shikumi.Routing: routeLLM :: forall (es :: [Effect]) a. (Routing :> es, LLM :> es) => Eff es a -> Eff es a
+ Shikumi.Routing: routeLLM :: forall (es :: [Effect]) a. (Routing :> es, LLM :> es, Error ShikumiError :> es) => Eff es a -> Eff es a

Files

CHANGELOG.md view
@@ -2,6 +2,22 @@  ## Unreleased +## 0.4.0.0 — 2026-09-08++- Add per-attempt observers to bare/resilient runtimes and a bounded, thread-safe billing collector with exact cost persistence and explicit unknown usage. Observer exceptions propagate without retry; no additional budget charge occurs. `LLMConfig.observer` is a public record addition requiring a PVP major review.++- Add `Shikumi.LLM.Defaults`: invocation-scoped thinking, speed, token and evidence defaults with fill-only precedence for blocking and streaming calls. Zero default ceilings raise `ValidationFailure`. Additive public API requires a PVP minor release; existing unreleased major changes still govern the next release.++- Add continuation origin/prefix guards shared by routing and transport. `routeLLM` now requires `Error ShikumiError`; this public constraint change requires a PVP major review at release.++- Preserve structured transport failures in `ProviderError BaikaiError` and expose `renderShikumiError`. Blocking and streaming calls retry only typed rate limits/transient failures; refusals, auth, unavailable-provider, process and unknown failures are terminal. Legacy `ProviderFailure` and malformed stream terminals retain retry behavior. The added public sum constructor requires a PVP major bump at release; exhaustive downstream matches must handle it.++- Upgrade the dependency on `mori://shinzui/baikai/packages/baikai` to `>=0.7.0.0 && <0.8`. Require the Claude and OpenAI providers at `0.7.0.0` and Effectful at `0.4.0.1`; recognize OpenAI Responses as native structured-output capable.++- Decode nested XML records and arrays with bounded, balanced parsing. Add `nestedXmlAdapter` with `ToJSON` output demonstrations and schema guides; preserve legacy XML rendering and JSON-in-tag container decoding. XML required strings now retain literal `null`.++- Add `CaptureCodec`, `PredictCaptured`, and `predictCaptured` for opt-in typed node capture. Ordinary prediction constraints and parameter serialization are unchanged. Public GADT matches must handle the new constructor; all core traversals preserve codecs.+ ## 0.3.0.3 — 2026-08-29  ### Changed
shikumi.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            shikumi-version:         0.3.0.3+version:         0.4.0.0 synopsis:        Typed, structured, evaluable LM programs over baikai category:        AI description:@@ -39,6 +39,9 @@     Shikumi.Error     Shikumi.LLM     Shikumi.LLM.Budget+    Shikumi.LLM.Continuation+    Shikumi.LLM.Defaults+    Shikumi.LLM.Observation     Shikumi.Module     Shikumi.Multimodal     Shikumi.Prelude@@ -51,25 +54,26 @@     Shikumi.Signature     Shikumi.Stream +  other-modules:   Shikumi.Adapter.Xml   build-depends:-    , aeson              >=2.2  && <2.3-    , baikai             >=0.6  && <0.7-    , baikai-claude      >=0.6  && <0.7-    , baikai-effectful   >=0.4  && <0.5-    , baikai-openai      >=0.6  && <0.7-    , base               >=4.20 && <5-    , base64-bytestring  >=1.2  && <1.3-    , bytestring         >=0.11 && <0.13-    , containers         >=0.6  && <0.9-    , effectful          >=2.5  && <2.7-    , filepath           >=1.4  && <1.6-    , generic-lens       >=2.2  && <2.4+    , aeson              >=2.2     && <2.3+    , baikai             >=0.7.0.0 && <0.8+    , baikai-claude      >=0.7.0.0 && <0.8+    , baikai-effectful   >=0.4.0.1 && <0.5+    , baikai-openai      >=0.7.0.0 && <0.8+    , base               >=4.20    && <5+    , base64-bytestring  >=1.2     && <1.3+    , bytestring         >=0.11    && <0.13+    , containers         >=0.6     && <0.9+    , effectful          >=2.5     && <2.7+    , filepath           >=1.4     && <1.6+    , generic-lens       >=2.2     && <2.4     , lens               ^>=5.3-    , scientific         >=0.3  && <0.4-    , stm                >=2.5  && <2.6+    , scientific         >=0.3     && <0.4+    , stm                >=2.5     && <2.6     , text               ^>=2.1-    , time               >=1.12 && <1.17-    , vector             >=0.13 && <0.14+    , time               >=1.12    && <1.17+    , vector             >=0.13    && <0.14  test-suite shikumi-test   import:         common-options@@ -81,6 +85,7 @@     AdapterSpec     CombinatorSpec     ConstraintSpec+    ContinuationSpec     EndToEndSpec     ErrorSpec     Fixtures@@ -95,7 +100,9 @@     ProgramSpec     RefineSpec     RefineStub+    RequestDefaultsSpec     ResilienceSpec+    ResponsesSpec     RoutingSpec     SchemaSpec     SerializeSpec@@ -109,10 +116,10 @@    build-depends:     , aeson-    , baikai             >=0.6      && <0.7-    , baikai-claude      >=0.6      && <0.7-    , baikai-effectful   >=0.4      && <0.5-    , baikai-openai      >=0.6      && <0.7+    , baikai             >=0.7.0.0  && <0.8+    , baikai-claude      >=0.7.0.0  && <0.8+    , baikai-effectful   >=0.4.0.1  && <0.5+    , baikai-openai      >=0.7.0.0  && <0.8     , base     , base64-bytestring     , bytestring@@ -122,7 +129,7 @@     , generic-lens     , lens     , QuickCheck-    , shikumi            ^>=0.3.0.0+    , shikumi            ^>=0.4.0.0     , stm     , streamly-core     , tasty
src/Shikumi/Adapter.hs view
@@ -8,10 +8,10 @@ -- @render@ builds a baikai @Context@+@Options@; @parse@ decodes a baikai -- @Response@ into the typed output via "Shikumi.Schema". ----- Two adapters ship. The native-schema adapter is the reliable path (the provider--- enforces the JSON schema); the prompt-based fallback renders @[[ ## field ## ]]@--- sections and re-parses them, for models without native structured output.--- 'capabilityFor' selects per model.+-- The native-schema adapter uses provider-enforced JSON; the prompt-based fallback+-- renders @[[ ## field ## ]]@ sections for models without native structured output.+-- 'capabilityFor' selects between them per model. Two opt-in XML adapters share+-- bounded nested decoding and offer legacy or structured demonstration rendering. -- -- Native structured output is wired through a private /metadata channel/ (EP-14). -- Because a 'Program' renders before the ambient model is known (the model is@@ -44,6 +44,7 @@     nativeAdapter,     fallbackAdapter,     xmlAdapter,+    nestedXmlAdapter,     adapterFor,     attachSchema,     responseText,@@ -92,6 +93,7 @@ import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Vector qualified as V import GHC.Generics+import Shikumi.Adapter.Xml (decodeXmlFields, renderXmlFields, xmlSchemaGuide) import Shikumi.Error (ShikumiError (..)) import Shikumi.Multimodal (GImageFieldNames (..), GImageFields (..), Image, imageToContent) import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema, fromModelChecked)@@ -188,6 +190,7 @@ capabilityFor :: Model -> ModelCapability capabilityFor m = case (m ^. #provider, m ^. #api) of   ("openai", OpenAIChatCompletions) -> NativeSchema+  ("openai", OpenAIResponses) -> NativeSchema   ("anthropic", AnthropicMessages) -> NativeSchema   _ -> PromptFallback @@ -307,9 +310,9 @@ -- | The XML adapter (EP-26). A third wire format on the same typed seam: @render@ -- asks the model to wrap each output field in @\<field\>…\</field\>@ tags, and -- @parse@ reads those tags back. Some models follow an XML shape more reliably than--- JSON or the @[[ ## … ## ]]@ markers. Reuses the same 'sectionsToObject' +--- 'fromModelChecked' decode path as 'fallbackAdapter', so nested records and lists--- in tags coerce the same way.+-- JSON or the @[[ ## … ## ]]@ markers. Both XML adapters decode nested elements+-- and legacy JSON-in-tag containers through 'fromModelChecked'. This adapter+-- retains its historical flattened demonstration rendering. -- -- Reachability: 'xmlAdapter' is /not/ selectable by the runtime router. The router -- ("Shikumi.Routing".@routeLLM@) picks between the native and fallback wire shapes@@ -330,11 +333,26 @@         let sys = systemHeader sig <> xmlOutputGuide sig             ctx = buildContext sys (xmlDemoMessages sig ++ [userTurn i])          in (ctx, emptyOptions),-      parse = \sig resp ->+      parse = \_sig resp -> decodeXmlFields (deriveSchema @o) (responseText resp) >>= fromModelChecked+    }++-- | Nested XML guides and faithful structured demonstrations (EP-55). Requires+-- 'Aeson.ToJSON' for outputs; inputs still use 'ToPrompt'. Supports generated+-- records, arrays, scalars and nullable schemas; other schema forms render an+-- escaped JSON fallback. Parsing is shared with 'xmlAdapter'. Opt-in only.+nestedXmlAdapter ::+  forall i o.+  (ToSchema o, FromModel o, Validatable o, ToPrompt i, Aeson.ToJSON o) =>+  Adapter i o+nestedXmlAdapter =+  Adapter+    { render = \sig i ->         let names = map fieldName (outputFields sig)-            sections = parseXmlTags names (responseText resp)-            obj = sectionsToObject (deriveSchema @o) sections-         in fromModelChecked obj+            schema = deriveSchema @o+            sys = systemHeader sig <> xmlSchemaGuide names schema+            demos = concat [[userTurn di, assistant (renderXmlFields names schema (toJSON o))] | Demo di o <- getDemos sig]+         in (buildContext sys (demos ++ [userTurn i]), emptyOptions),+      parse = \_sig resp -> decodeXmlFields (deriveSchema @o) (responseText resp) >>= fromModelChecked     }  -- ---------------------------------------------------------------------------@@ -484,29 +502,6 @@     flush (Just (name, buf)) acc       | name == "completed" = acc       | otherwise = Map.insert name (T.strip (T.unlines buf)) acc---- | Extract @\<name\>…\</name\>@ sections into a name->text map. Only names that--- appear as output fields are kept (so stray tags are ignored, DSPy parity), and--- the first match per name wins. The content is whatever lies between the first--- @\<name\>@ and its next @\</name\>@ — a non-greedy match, the same as DSPy's--- @\<(?P\<name\>\\w+)\>(?P\<content\>.*?)\</\\1\>@ with DOTALL.-parseXmlTags :: [Text] -> Text -> Map Text Text-parseXmlTags names body =-  Map.fromList [(nm, inner) | nm <- names, Just inner <- [extractTag nm body]]---- | The text between the first @\<name\>@ and its next @\</name\>@, trimmed;--- 'Nothing' if either tag is absent.-extractTag :: Text -> Text -> Maybe Text-extractTag nm body =-  let open = openTag nm-      close = closeTag nm-      (_, afterOpen) = T.breakOn open body-   in if T.null afterOpen-        then Nothing-        else-          let rest = T.drop (T.length open) afterOpen-              (inner, afterClose) = T.breakOn close rest-           in if T.null afterClose then Nothing else Just (T.strip inner)  -- | Recognize a @[[ ## name ## ]]@ marker line. markerName :: Text -> Maybe Text
+ src/Shikumi/Adapter/Xml.hs view
@@ -0,0 +1,233 @@+-- | Bounded model-output XML fragments, not a general XML processor.+module Shikumi.Adapter.Xml (decodeXmlFields, renderXmlFields, xmlSchemaGuide) where++import Data.Aeson (Object, Value (..), eitherDecodeStrict, encode)+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy qualified as LBS+import Data.Char (chr, isAlpha, isAlphaNum, ord)+import Data.List (sortOn)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8, encodeUtf8)+import Data.Vector qualified as V+import Shikumi.Error (ShikumiError (..))++-- Offsets count Unicode code points, starting at zero. CDATA provenance matters+-- for the nullable literal string "null".+data Node = Element !Int !Text [Node] | Content !Bool !Text++data Cursor = Cursor !Int !Text++failure :: Int -> Text -> Either ShikumiError a+failure pos msg = Left (SchemaMismatch ("XML: offset " <> T.pack (show pos) <> ": " <> msg))++advance :: Text -> Cursor -> Cursor+advance consumed (Cursor pos rest) = Cursor (pos + T.length consumed) (T.drop (T.length consumed) rest)++xmlSpace :: Char -> Bool+xmlSpace c = c `elem` [' ', '\t', '\r', '\n']++validChar :: Char -> Bool+validChar c = let n = ord c in n == 9 || n == 10 || n == 13 || (n >= 32 && n <= 0xD7FF) || (n >= 0xE000 && n <= 0xFFFD) || (n >= 0x10000 && n <= 0x10FFFF)++-- Siblings accumulate tail-recursively; only element nesting consumes depth.+fragment :: Int -> Maybe Text -> Cursor -> Either ShikumiError ([Node], Cursor)+fragment depth closing = go []+  where+    go acc cur@(Cursor pos rest)+      | T.null rest = case closing of+          Nothing -> Right (reverse acc, cur)+          Just name -> failure pos ("unterminated element " <> name)+      | "<!--" `T.isPrefixOf` rest = do+          (body, next) <- delimited "<!--" "-->" cur+          if "--" `T.isInfixOf` body || "-" `T.isSuffixOf` body then failure pos "invalid comment" else go acc next+      | "<![CDATA[" `T.isPrefixOf` rest = do+          (body, next) <- delimited "<![CDATA[" "]]>" cur+          go (Content True body : acc) next+      | "</" `T.isPrefixOf` rest = do+          (name, selfClosing, next) <- tag True cur+          if closing == Just name && not selfClosing+            then Right (reverse acc, next)+            else failure pos ("unexpected closing tag " <> name)+      | "<!" `T.isPrefixOf` rest || "<?" `T.isPrefixOf` rest = failure pos "unsupported declaration or processing instruction"+      | "<" `T.isPrefixOf` rest = do+          if depth >= 64 then failure pos "depth limit 64 exceeded" else Right ()+          (name, selfClosing, next) <- tag False cur+          (children, after) <- if selfClosing then Right ([], next) else fragment (depth + 1) (Just name) next+          go (Element pos name children : acc) after+      | otherwise = do+          let (raw, _) = T.breakOn "<" rest+          if "]]>" `T.isInfixOf` raw then failure pos "CDATA terminator outside CDATA" else Right ()+          decoded <- if closing == Nothing then Right raw else entities pos raw+          go (Content False decoded : acc) (advance raw cur)++    delimited start end cur@(Cursor pos rest) =+      let (body, suffix) = T.breakOn end (T.drop (T.length start) rest)+       in if T.null suffix+            then failure pos ("unterminated " <> start)+            else Right (body, advance (start <> body <> end) cur)++tag :: Bool -> Cursor -> Either ShikumiError (Text, Bool, Cursor)+tag closing cur@(Cursor pos rest) =+  let prefix = if closing then "</" else "<"+      (name, suffix) = T.span (\c -> isAlphaNum c || c `elem` ['_', '-', '.']) (T.drop (T.length prefix) rest)+      spaces = T.takeWhile xmlSpace suffix+      end = T.dropWhile xmlSpace suffix+      finish token self = Right (name, self, advance (prefix <> name <> spaces <> token) cur)+   in case T.uncons name of+        Just (c, _)+          | isAlpha c || c == '_' ->+              if ">" `T.isPrefixOf` end+                then finish ">" False+                else+                  if not closing && "/>" `T.isPrefixOf` end+                    then finish "/>" True+                    else failure pos "attributes, namespaces, or malformed tag are unsupported"+        _ -> failure pos "invalid element name"++entities :: Int -> Text -> Either ShikumiError Text+entities = go []+  where+    go acc pos raw =+      let (plain, suffix) = T.breakOn "&" raw+          at = pos + T.length plain+       in if T.null suffix+            then Right (T.concat (reverse (plain : acc)))+            else+              let (ref, end) = T.breakOn ";" (T.drop 1 suffix)+               in if T.null end+                    then failure at "unterminated entity"+                    else do+                      value <- case ref of+                        "amp" -> Right "&"+                        "lt" -> Right "<"+                        "gt" -> Right ">"+                        "quot" -> Right "\""+                        "apos" -> Right "'"+                        _+                          | Just digits <- T.stripPrefix "#x" ref -> numeric at 16 digits+                          | Just digits <- T.stripPrefix "#" ref -> numeric at 10 digits+                          | otherwise -> failure at "unknown entity"+                      go (value : plain : acc) (at + T.length ref + 2) (T.drop 1 end)+    numeric pos base digits =+      let digit c+            | c >= '0' && c <= '9' = ord c - ord '0'+            | c >= 'a' && c <= 'f' = 10 + ord c - ord 'a'+            | c >= 'A' && c <= 'F' = 10 + ord c - ord 'A'+            | otherwise = base+          step n c = if n > 0x10FFFF || digit c >= base then 0x110000 else n * base + digit c+          value = T.foldl' step 0 digits+       in if T.null digits || value > 0x10FFFF || not (validChar (chr value))+            then failure pos "invalid Unicode character reference"+            else Right (T.singleton (chr value))++property :: Key.Key -> Value -> Value+property key (Object obj) = maybe Null id (KM.lookup key obj)+property _ _ = Null++properties :: Value -> Object+properties schema = case property "properties" schema of Object p -> p; _ -> KM.empty++nonNull :: Value -> Value+nonNull schema = case property "anyOf" schema of+  Array alts -> case V.toList alts of+    [a, b]+      | property "type" a == String "null" -> nonNull b+      | property "type" b == String "null" -> nonNull a+    _ -> schema+  _ -> schema++nullable :: Value -> Bool+nullable schema =+  property "type" schema == String "null" || case property "anyOf" schema of+    Array alts -> any nullable alts+    _ -> False++jsonText :: Text -> Value+jsonText raw = either (const (String raw)) id (eitherDecodeStrict (encodeUtf8 raw))++-- | Parse the entire fragment before selecting known top-level properties.+decodeXmlFields :: Value -> Text -> Either ShikumiError Value+decodeXmlFields schema body+  | T.length body > 1048576 = failure 0 "input length limit 1048576 exceeded"+  | Just pos <- T.findIndex (not . validChar) body = failure pos "invalid XML character"+  | otherwise = do+      (nodes, _) <- fragment 0 Nothing (Cursor 0 body)+      objectFields schema nodes++objectFields :: Value -> [Node] -> Either ShikumiError Value+objectFields schema nodes = Object . KM.fromList <$> traverse convert selected+  where+    present = KM.fromListWith (\_ first -> first) [(Key.fromText name, node) | node@(Element _ name _) <- nodes]+    selected = [(key, s, node) | (key, s) <- KM.toList (properties schema), Just node <- [KM.lookup key present]]+    convert (key, s, node) = (key,) <$> elementValue s node++elementValue :: Value -> Node -> Either ShikumiError Value+elementValue schema (Element pos _ nodes)+  | null children && nullable schema && raw == "null" && not cdata = Right Null+  | null children = Right $ case kind of+      String "string" -> String raw+      String "object" | T.null raw -> Object KM.empty+      String "array" | T.null raw -> Array V.empty+      _ -> jsonText raw+  | not (T.null raw) = failure pos "non-whitespace text mixed with child elements"+  | kind == String "object" = objectFields inner children+  | kind == String "array" = do+      values <- traverse item children+      Right (Array (V.fromList values))+  | otherwise = failure pos "scalar field contains child elements"+  where+    inner = nonNull schema+    kind = property "type" inner+    children = [node | node@Element {} <- nodes]+    raw = T.strip (T.concat [t | Content _ t <- nodes])+    cdata = or [flag | Content flag _ <- nodes]+    item node@(Element p name _)+      | name == "item" = elementValue (property "items" inner) node+      | otherwise = failure p "array children must be item elements"+    item _ = failure pos "invalid array content"+elementValue _ _ = failure 0 "expected element"++escape :: Text -> Text+escape = T.replace ">" "&gt;" . T.replace "<" "&lt;" . T.replace "&" "&amp;"++json :: Value -> Text+json = decodeUtf8 . LBS.toStrict . encode++wrap :: Text -> Text -> Text+wrap name body = "<" <> name <> ">" <> body <> "</" <> name <> ">"++-- | Signature order at the root, lexical property order within records.+renderXmlFields :: [Text] -> Value -> Value -> Text+renderXmlFields names schema value = T.unlines [renderElement name (property (Key.fromText name) (Object (properties schema))) v | name <- names, Just v <- [lookupValue name value]]+  where+    lookupValue name (Object obj) = KM.lookup (Key.fromText name) obj+    lookupValue _ _ = Nothing++renderElement :: Text -> Value -> Value -> Text+renderElement name schema value = wrap name body+  where+    inner = nonNull schema+    body = case (property "type" inner, value) of+      (_, Null) -> "null"+      (String "object", Object obj) -> T.concat [renderElement (Key.toText k) (property k (Object (properties inner))) v | (k, v) <- sortOn fst (KM.toList obj)]+      (String "array", Array values) -> T.concat [renderElement "item" (property "items" inner) v | v <- V.toList values]+      (String "string", String t)+        | nullable schema && T.strip t == "null" -> "<![CDATA[" <> T.replace "]]>" "]]]]><![CDATA[>" t <> "]]>"+        | otherwise -> escape t+      _ -> escape (json value)++-- | Guide for generated record/array/scalar/nullable schemas. Other hand-written+-- schema forms use escaped JSON values and are checked by the typed decoder.+xmlSchemaGuide :: [Text] -> Value -> Text+xmlSchemaGuide names schema =+  "Reply with these XML fields (no attributes or namespaces). Escape &, < and > in text.\n"+    <> "Arrays use repeated <item> elements; empty containers may self-close. Nullable fields may be omitted or contain null; use <![CDATA[null]]> for a nullable literal string.\n"+    <> T.unlines [guide name (property (Key.fromText name) (Object (properties schema))) | name <- names]+  where+    guide name s = wrap name $ case property "type" (nonNull s) of+      String "object" -> T.concat [guide (Key.toText k) v | (k, v) <- sortOn fst (KM.toList (properties (nonNull s)))]+      String "array" -> guide "item" (property "items" (nonNull s)) <> guide "item" (property "items" (nonNull s))+      String t -> "[" <> t <> "]"+      _ -> "[escaped JSON value]"
src/Shikumi/Error.hs view
@@ -8,10 +8,11 @@   ( ShikumiError (..),     fromBaikaiError,     isTransient,+    renderShikumiError,   ) where -import Baikai.Error (BaikaiError (..), ErrorCategory (..))+import Baikai.Error (BaikaiError (..), ErrorCategory (..), isRetryable) import Data.Text (Text) import Data.Text qualified as T @@ -28,13 +29,15 @@     SchemaMismatch !Text   | -- | a typed value failed a user/program validation rule     ValidationFailure !Text-  | -- | the provider/transport failed (mapped from baikai)+  | -- | Legacy unclassified failure supplied by callers or scripted interpreters.     ProviderFailure !Text+  | -- | Structured transport failure, including verbatim refusal metadata.+    ProviderError !BaikaiError   | -- | the prompt exceeded the model's context window     ContextWindowExceeded !Text   | -- | the call exceeded its time budget     Timeout !Text-  | -- | the running cost ceiling was reached; the call was refused+  | -- | a configured resource allowance was exhausted (cost, calls or session size)     BudgetExceeded !Text   | -- | generated code failed after exhausting correction attempts     CodeExecFailed !Text@@ -50,20 +53,34 @@   DecodeFailure -> InvalidJSON (message e)   InvalidRequest -> SchemaMismatch ("invalid request: " <> message e)   ContextOverflow -> ContextWindowExceeded (message e)-  ProcessFailure ->-    ProviderFailure $-      case exitCode e of-        Just n -> "process exited " <> T.pack (show n) <> ": " <> message e-        Nothing -> message e-  _ -> ProviderFailure (message e)+  _ -> ProviderError e --- | Which errors are worth retrying. Provider/transport failures and timeouts are--- transient; decode, schema, validation, budget, and code-execution failures are--- deterministic and retrying cannot fix them. Centralizing the policy here keeps--- it auditable (the resilience interpreter in "Shikumi.LLM" consults exactly this--- predicate).+-- | Human-readable detail without dumping the transport record. Baikai's+-- message is its safe-to-log description; opaque request/response data is absent.+renderShikumiError :: ShikumiError -> Text+renderShikumiError = \case+  ProviderError e ->+    "provider "+      <> T.pack (show (category e))+      <> maybe "" (\n -> " (exit " <> T.pack (show n) <> ")") (exitCode e)+      <> ": "+      <> message e+  InvalidJSON t -> t+  MissingField t -> "missing field " <> t+  SchemaMismatch t -> t+  ValidationFailure t -> t+  ProviderFailure t -> t+  ContextWindowExceeded t -> t+  Timeout t -> t+  BudgetExceeded t -> t+  CodeExecFailed t -> t++-- | Retry only typed rate limits and transient failures, using Baikai's+-- classification. Legacy text failures and timeouts retain their retry policy.+-- Unknown/process errors and refusals are terminal; never infer from prose. isTransient :: ShikumiError -> Bool isTransient = \case+  ProviderError e -> isRetryable e   ProviderFailure {} -> True   Timeout {} -> True   _ -> False
src/Shikumi/LLM.hs view
@@ -7,7 +7,7 @@ -- -- The effect exposes two operations ('Complete', 'Stream'). The bare interpreters -- 'runLLM' / 'runLLMWith' map baikai's 'BaikaiError' into 'ShikumiError' and do--- nothing else. The resilient interpreter 'runLLMResilient' adds the production+-- validate continuation compatibility before transport. The resilient interpreter 'runLLMResilient' adds the production -- features baikai deliberately omits: retries with exponential backoff, an -- in-flight rate limit, and a US-dollar budget ceiling. --@@ -26,6 +26,7 @@     -- * Bare interpreters     runLLM,     runLLMWith,+    runLLMWithObserver,      -- * Resilience     RetryPolicy (..),@@ -59,6 +60,7 @@ import Baikai.Effectful qualified as BE import Baikai.Error (BaikaiError) import Baikai.Provider.Registry (ProviderRegistry)+import Baikai.Usage qualified as U import Control.Concurrent.STM   ( TVar,     modifyTVar',@@ -71,6 +73,8 @@ import Data.Generics.Labels () import Data.Maybe (fromMaybe, mapMaybe) import Data.Text qualified as T+import Data.Time.Clock (getCurrentTime)+import Data.Unique (hashUnique, newUnique) import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, IOE, liftIO, (:>)) import Effectful.Concurrent (Concurrent, threadDelay) import Effectful.Concurrent.STM (atomically)@@ -79,6 +83,8 @@ import Effectful.Exception (bracket_, try) import Shikumi.Error (ShikumiError (..), fromBaikaiError, isTransient) import Shikumi.LLM.Budget (Budget, admitCall, recordCost)+import Shikumi.LLM.Continuation+import Shikumi.LLM.Observation qualified as O  -- | The provider-neutral LM effect. 'Complete' is a blocking completion; -- 'Stream' returns the assembled list of typed events so callers that need@@ -116,7 +122,7 @@   (IOE :> es, Error ShikumiError :> es) =>   Eff (LLM : es) a ->   Eff es a-runLLM = reinterpret_ runBaikai bareHandler+runLLM = reinterpret_ runBaikai (bareHandler O.noObservation)  -- | Bare interpreter over an explicit registry. runLLMWith ::@@ -124,8 +130,12 @@   ProviderRegistry ->   Eff (LLM : es) a ->   Eff es a-runLLMWith reg = reinterpret_ (runBaikaiWith reg) bareHandler+runLLMWith reg = runLLMWithObserver reg O.noObservation +-- | Observe bare transport calls. Callback exceptions propagate without retries.+runLLMWithObserver :: (IOE :> es, Error ShikumiError :> es) => ProviderRegistry -> O.LLMObserver -> Eff (LLM : es) a -> Eff es a+runLLMWithObserver reg observer = reinterpret_ (runBaikaiWith reg) (bareHandler observer)+ -- | The bare handler, shared by both interpreters. It runs in the handler stack -- (@Baikai : es@), so it can call the @Baikai@ transport effect and throw -- through the @Error ShikumiError@ effect. The blocking path routes baikai's@@ -136,15 +146,77 @@ -- 'stream' never receive an in-band error terminal and resilience/decoding treat -- both operations identically. bareHandler ::-  (Baikai :> es, Error ShikumiError :> es) =>-  LLM (Eff localEs) a ->-  Eff es a-bareHandler = \case+  (IOE :> es, Baikai :> es, Error ShikumiError :> es) =>+  O.LLMObserver -> LLM (Eff localEs) a -> Eff es a+bareHandler observer op = do+  cid <- liftIO freshCallId+  transportAttempt observer Nothing cid 1 op++freshCallId :: IO T.Text+freshCallId = (T.pack . ("call-" <>) . show . hashUnique) <$> newUnique++-- The callback sits outside the typed transport try; its exceptions cannot+-- become retryable provider errors, even if the callback throws BaikaiError.+transportAttempt ::+  (IOE :> es, Baikai :> es, Error ShikumiError :> es) =>+  O.LLMObserver -> Maybe Budget -> T.Text -> Int -> LLM (Eff localEs) a -> Eff es a+transportAttempt observer mb cid ordinal op = case op of   Complete m c o -> do-    res <- try @BaikaiError (BE.complete m c o)-    either (throwError . fromBaikaiError) raiseResponseError res-  Stream m c o -> BE.streamCollect m c o >>= raiseStreamError+    either throwError pure (validateRequestContinuation m c o)+    start <- liftIO getCurrentTime+    res <- try @BaikaiError (BE.complete m c (stripContinuationMetadata o))+    case res of+      Left be -> do+        emit m O.CompletionCall start (Just (fromBaikaiError be)) Nothing Nothing+        throwError (fromBaikaiError be)+      Right resp -> do+        liftIO (chargeBudget mb resp)+        emit+          m+          O.CompletionCall+          start+          (fromBaikaiError <$> responseError resp)+          (availableUsage (resp ^. #message . #usage))+          (O.observedModelOf (resp ^. #evidence))+        raiseResponseError resp+  Stream m c o -> do+    either throwError pure (validateRequestContinuation m c o)+    start <- liftIO getCurrentTime+    res <- try @BaikaiError (BE.streamCollect m c (stripContinuationMetadata o))+    case res of+      Left be -> do+        emit m O.StreamCall start (Just (fromBaikaiError be)) Nothing Nothing+        throwError (fromBaikaiError be)+      Right evs -> do+        liftIO (chargeBudgetFromEvents mb evs)+        let terminals = [tp | ev <- evs, tp <- case ev of EventDone t -> [t]; EventError t -> [t]; _ -> []]+            terminal = case terminals of t : _ -> Just t; [] -> Nothing+            usage = terminal >>= \t -> case t ^. #message of AssistantMessage p -> availableUsage (p ^. #usage); _ -> Nothing+            err = case [streamTerminalError t | EventError t <- evs] of e : _ -> Just e; [] -> Nothing+        emit m O.StreamCall start err usage (terminal >>= O.observedModelOf . (^. #evidence))+        raiseStreamError evs+  where+    emit m kind start err usage observed = liftIO $ do+      end <- getCurrentTime+      observer+        ( O.LLMObservation+            cid+            ordinal+            kind+            (m ^. #modelId)+            (m ^. #provider)+            observed+            (O.errorClass <$> err)+            (O.UsageRecord <$> usage)+            start+            end+        ) +-- Baikai represents thrown transport errors with the additive zero. It carries+-- no observation; a reported zero has availability/basis and is retained.+availableUsage :: U.Usage -> Maybe U.Usage+availableUsage u = if u == U.zeroUsage then Nothing else Just u+ -- --------------------------------------------------------------------------- -- Resilience: retries, rate limiting, budget -- ---------------------------------------------------------------------------@@ -182,7 +254,9 @@     -- | 'Nothing' = unbounded concurrency     rateLimit :: !(Maybe RateLimiter),     -- | which baikai registry to dispatch against-    registry :: !ProviderRegistry+    registry :: !ProviderRegistry,+    -- | Optional per-attempt accounting; never charges budgets itself.+    observer :: !(Maybe O.LLMObserver)   }  -- | A config with default retries, no budget, and no rate limit, dispatching@@ -193,40 +267,23 @@     { retryPolicy = defaultRetryPolicy,       budget = Nothing,       rateLimit = Nothing,-      registry = reg+      registry = reg,+      observer = Nothing     }  -- | The resilient interpreter. Each operation is wrapped, outermost to -- innermost, by: budget check → rate-limit acquire → retry loop → the @Baikai@--- transport call. The budget is reserved once before the attempts and charged--- once after success; retries re-run only the transport call.+-- transport call. Budget admission happens once before the attempts; each+-- response or stream terminal is charged before success or failure is raised. runLLMResilient ::   (IOE :> es, Concurrent :> es, Error ShikumiError :> es) =>   LLMConfig ->   Eff (LLM : es) a ->   Eff es a-runLLMResilient cfg = reinterpret_ (runBaikaiWith (registry cfg)) $ \case-  Complete m c o ->-    withBudget mb . withRateLimit mr . retrying rp $ do-      res <- try @BaikaiError (BE.complete m c o)-      case res of-        Left be -> throwError (fromBaikaiError be)-        Right resp -> do-          -- Charge before raising: an error-shaped 'Response' may still carry-          -- billable usage (mirrors 'chargeBudgetFromEvents' on the stream path).-          liftIO (chargeBudget mb resp)-          raiseResponseError resp-  Stream m c o ->-    withBudget mb . withRateLimit mr . retrying rp $ do-      evs <- BE.streamCollect m c o-      -- Charge from the terminal payload (success /or/ error) before raising: a-      -- failed stream may still have consumed billable tokens.-      liftIO (chargeBudgetFromEvents mb evs)-      raiseStreamError evs-  where-    mb = budget cfg-    mr = rateLimit cfg-    rp = retryPolicy cfg+runLLMResilient cfg = reinterpret_ (runBaikaiWith (registry cfg)) $ \op -> do+  cid <- liftIO freshCallId+  withBudget (budget cfg) . withRateLimit (rateLimit cfg) $+    retrying (retryPolicy cfg) (\ordinal -> transportAttempt (fromMaybe O.noObservation (observer cfg)) (budget cfg) cid ordinal op)  -- | Optimistic pre-call budget gate (admission, not reservation). Refuses the -- call with 'BudgetExceeded' when the recorded running total has already reached@@ -264,12 +321,12 @@ retrying ::   (Concurrent :> es, Error ShikumiError :> es) =>   RetryPolicy ->-  Eff es a ->+  (Int -> Eff es a) ->   Eff es a retrying pol act = go 1   where     go attempt =-      act `catchError` \_cs e ->+      act attempt `catchError` \_cs e ->         if isTransient e && attempt < maxAttempts pol           then do             threadDelay (backoffMicros pol attempt)@@ -292,18 +349,9 @@ responseCostUSD :: Response -> Rational responseCostUSD resp = resp ^. #message . #usage . #cost . #usd --- | Enforce the stream-error posture: a terminal 'EventError' becomes an--- out-of-band 'ShikumiError' — a 'ProviderFailure' carrying the terminal message's--- @errorMessage@ (or the stop reason if none). A successful event list passes--- through unchanged, so callers of 'stream' never see an in-band error terminal.--- Because both interpreters call this /inside/ their retry loop, a transient stream--- failure is retried exactly like a blocking one ('ProviderFailure' is transient--- per 'isTransient').------ The baikai version shikumi pins does not attach a structured 'BaikaiError' to a--- terminal payload (its @TerminalPayload@ is @{reason, message}@; the failure detail--- lives in the assembled message's @errorMessage@ and @stopReason@), so all stream--- failures map to 'ProviderFailure' rather than through 'fromBaikaiError'.+-- | Raise structured terminal failures through the same mapping as blocking+-- calls. Only malformed third-party terminals without errorInfo use the legacy+-- text fallback. Successful event lists pass through unchanged. raiseStreamError ::   (Error ShikumiError :> es) => [AssistantMessageEvent] -> Eff es [AssistantMessageEvent] raiseStreamError evs = case [tp | EventError tp <- evs] of@@ -316,7 +364,7 @@ -- 'responseError' is populated. Convert that in-band failure into the same -- out-of-band 'ShikumiError' the rest of shikumi consumes, so an error response -- never masquerades as success and 'runLLMResilient' still retries transient--- failures (a 'ProviderFailure' thrown /inside/ the retry loop). A success+-- failures (a classified error thrown /inside/ the retry loop). A success -- passes through unchanged. raiseResponseError ::   (Error ShikumiError :> es) => Response -> Eff es Response@@ -326,7 +374,9 @@  -- | Map a terminal 'EventError' payload to a 'ShikumiError'. streamTerminalError :: TerminalPayload -> ShikumiError-streamTerminalError tp = ProviderFailure ("stream failed: " <> detail)+streamTerminalError tp = case tp ^. #errorInfo of+  Just err -> fromBaikaiError err+  Nothing -> ProviderFailure ("stream failed: " <> detail)   where     detail = case tp ^. #message of       AssistantMessage p -> fromMaybe (T.pack (show (tp ^. #reason))) (p ^. #errorMessage)
+ src/Shikumi/LLM/Continuation.hs view
@@ -0,0 +1,115 @@+-- | Pure continuation checks shared by routing, memoization and transport.+-- Request identity is not provider attestation. No credentials are persisted.+module Shikumi.LLM.Continuation+  ( RequestOrigin,+    requestOrigin,+    originModel,+    opaqueThinking,+    hasOpaqueContinuation,+    contextIdentity,+    stampContinuation,+    validateRequestContinuation,+    stripContinuationMetadata,+    validateReplayOrigin,+  )+where++import Baikai qualified as B+import Control.Lens ((&), (.~), (^.))+import Control.Monad (unless)+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (parseEither)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Shikumi.Error (ShikumiError (ValidationFailure))++data RequestOrigin = RequestOrigin+  { provider :: !Text,+    api :: !B.Api,+    model :: !Text,+    endpoint :: !Text+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToJSON, FromJSON)++-- | Endpoints containing query strings or userinfo may contain credentials.+-- Refuse to persist them; callers can restart with a credential-free endpoint.+requestOrigin :: B.Model -> Maybe RequestOrigin+requestOrigin m+  | any T.null [m ^. #provider, m ^. #modelId, m ^. #baseUrl] = Nothing+  | m ^. #api == B.Custom "" = Nothing+  | T.any (`elem` ("?@#" :: String)) (m ^. #baseUrl) = Nothing+  | otherwise = Just (RequestOrigin (m ^. #provider) (m ^. #api) (m ^. #modelId) (m ^. #baseUrl))++-- | Minimal explicit request identity. Credentials and compatibility settings+-- must be supplied by the caller's runtime/router, never by a checkpoint.+originModel :: RequestOrigin -> B.Model+originModel (RequestOrigin p a m e) = B.mkModel a m e & #provider .~ p++opaqueThinking :: B.ThinkingContent -> Bool+opaqueThinking t = isJust (t ^. #signature) || t ^. #redacted || isJust (t ^. #replayState)++hasOpaqueContinuation :: [B.Message] -> Bool+hasOpaqueContinuation = any (\case B.AssistantMessage p -> any block (p ^. #content); _ -> False)+  where+    block (B.AssistantThinking t) = opaqueThinking t+    block _ = False++-- | Exact ordered request projection; only message construction timestamps go.+contextIdentity :: B.Context -> Value+contextIdentity c = object ["system" .= (c ^. #systemPrompt), "tools" .= (c ^. #tools), "messages" .= map messageIdentity (V.toList (c ^. #messages))]+  where+    messageIdentity m = case toJSON m of+      Object fields -> Object (case KM.lookup "contents" fields of Just v -> KM.insert "contents" (dropTimestamp v) fields; Nothing -> fields)+      v -> v+    dropTimestamp (Object fields) = Object (KM.delete "timestamp" fields)+    dropTimestamp v = v++continuationKey :: Text+continuationKey = "shikumi.continuation.v1"++stampContinuation :: Maybe RequestOrigin -> Maybe Value -> B.Options -> B.Options+stampContinuation origin prefix o = o & #metadata .~ Map.insert continuationKey (object ["origin" .= origin, "prefix" .= prefix]) (o ^. #metadata)++stripContinuationMetadata :: B.Options -> B.Options+stripContinuationMetadata o = o & #metadata .~ Map.delete continuationKey (o ^. #metadata)++failure :: Text -> Either ShikumiError a+failure t = Left (ValidationFailure ("ReAct continuation: " <> t <> "; explicitly restart from a caller-approved summary"))++-- | Also check returned Responses replay before tools can execute. Aliases are+-- compared to replay scope, never to optional observed-provider model evidence.+validateReplayOrigin :: B.Model -> [B.Message] -> Either ShikumiError ()+validateReplayOrigin m msgs = mapM_ check [r | B.AssistantMessage p <- msgs, B.AssistantThinking t <- V.toList (p ^. #content), Just r <- [t ^. #replayState]]+  where+    check r = unless (r ^. #replayApi == m ^. #api && r ^. #replayModel == m ^. #modelId) (failure "replay API/model differs from resolved request")++validateRequestContinuation :: B.Model -> B.Context -> B.Options -> Either ShikumiError ()+validateRequestContinuation m c o = do+  let opaque = hasOpaqueContinuation (V.toList (c ^. #messages))+  case Map.lookup continuationKey (o ^. #metadata) of+    Nothing -> unless (not opaque) (failure "opaque history has no origin expectation")+    Just value -> case parseEither (withObject "continuation" (\v -> (,) <$> v .: "origin" <*> v .: "prefix")) value of+      Left _ -> failure "malformed expectation"+      Right (origin, prefix) -> do+        case origin of+          Just expected -> unless (requestOrigin m == Just expected) (failure "provider/API/model/endpoint changed or unresolved")+          Nothing -> unless (not opaque) (failure "opaque history has unknown request origin")+        case prefix of+          Nothing -> unless (not opaque) (failure "opaque history has no protected prefix")+          Just protected -> unless (prefixMatches protected (contextIdentity c)) (failure "protected system/tools/message prefix changed")+  validateReplayOrigin m (V.toList (c ^. #messages))+  where+    prefixMatches (Object old) (Object new) =+      KM.lookup "system" old == KM.lookup "system" new+        && KM.lookup "tools" old == KM.lookup "tools" new+        && case (KM.lookup "messages" old, KM.lookup "messages" new) of+          (Just (Array before), Just (Array after)) -> V.length before <= V.length after && before == V.take (V.length before) after+          _ -> False+    prefixMatches _ _ = False
+ src/Shikumi/LLM/Defaults.hs view
@@ -0,0 +1,65 @@+-- | Invocation-scoped, fill-only defaults for every LLM operation.+-- Compose the base interpreter with cache/trace, then 'withRequestDefaults',+-- then routing. The rightmost wrapper sees the originating call first.+module Shikumi.LLM.Defaults+  ( RequestDefaults (..),+    emptyRequestDefaults,+    applyRequestDefaults,+    withRequestDefaults,+  )+where++import Baikai (Options)+import Baikai.Evidence (EvidenceRequest)+import Baikai.Speed (Speed)+import Baikai.ThinkingLevel (ThinkingLevel)+import Control.Applicative ((<|>))+import Control.Lens ((&), (.~), (^.))+import Control.Monad (when)+import Data.Generics.Labels ()+import Effectful (Eff, (:>))+import Effectful.Dispatch.Dynamic (interpose)+import Effectful.Error.Static (Error, throwError)+import Numeric.Natural (Natural)+import Shikumi.Error (ShikumiError (ValidationFailure))+import Shikumi.LLM (LLM (..), complete, stream)++-- | Only request preferences belong here. Models, schemas, tools, credentials,+-- temperatures and private continuation metadata remain owned by their callers.+-- Evidence is replaced as a whole; requested settings are not provider evidence.+data RequestDefaults = RequestDefaults+  { defaultThinking :: !(Maybe ThinkingLevel),+    defaultSpeed :: !(Maybe Speed),+    defaultMaxTokens :: !(Maybe Natural),+    defaultEvidence :: !(Maybe EvidenceRequest)+  }+  deriving stock (Eq, Show)++-- | No implicit reasoning, fast mode, token ceiling or evidence collection.+emptyRequestDefaults :: RequestDefaults+emptyRequestDefaults = RequestDefaults Nothing Nothing Nothing Nothing++-- | Fill absent options only. This pure merge does not validate token ceilings;+-- use 'withRequestDefaults' to reject a zero configured default before execution.+applyRequestDefaults :: RequestDefaults -> Options -> Options+applyRequestDefaults defaults opts =+  opts+    & #thinking .~ (opts ^. #thinking <|> defaultThinking defaults)+    & #speed .~ (opts ^. #speed <|> defaultSpeed defaults)+    & #maxTokens .~ (opts ^. #maxTokens <|> defaultMaxTokens defaults)+    & #evidence .~ (opts ^. #evidence <|> defaultEvidence defaults)++-- | Apply the same merge to blocking and streaming calls. Per-call values win;+-- the innermost default scope fills first and therefore wins over outer scopes.+-- A zero default ceiling is invalid even when a call supplies its own ceiling.+-- No mutable state is shared between invocations.+withRequestDefaults :: (LLM :> es, Error ShikumiError :> es) => RequestDefaults -> Eff es a -> Eff es a+withRequestDefaults defaults action = do+  when (defaultMaxTokens defaults == Just 0) $+    throwError (ValidationFailure "request defaults: defaultMaxTokens must be positive")+  interpose+    ( \_ -> \case+        Complete model ctx opts -> complete model ctx (applyRequestDefaults defaults opts)+        Stream model ctx opts -> stream model ctx (applyRequestDefaults defaults opts)+    )+    action
+ src/Shikumi/LLM/Observation.hs view
@@ -0,0 +1,180 @@+-- | Run-local transport billing. Observations contain identity and accounting,+-- never prompts, outputs, credentials, or provider error messages. Observer IO+-- exceptions propagate outside provider retry classification. Cancellation emits+-- no synthetic terminal. Create a separate collector for each run.+module Shikumi.LLM.Observation+  ( LLMObservation (..),+    CallKind (..),+    LLMObserver,+    noObservation,+    BillingSummary (..),+    emptyBillingSummary,+    newBillingCollector,+    newBillingCollectorWithLimit,+    renderBillingSummary,+    usageUnknown,+    observedModelOf,+    errorClass,+    UsageRecord (..),+  )+where++import Baikai.Cost qualified as C+import Baikai.Error qualified as BE+import Baikai.Evidence qualified as E+import Baikai.Usage qualified as U+import Data.Aeson+import Data.Aeson.Types (Parser)+import Data.ByteString.Lazy qualified as BL+import Data.IORef+import Data.Maybe (isNothing)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Shikumi.Error (ShikumiError (..))++-- | A local wrapper avoids adding competing orphan decoders. The exact rational+-- cost is encoded separately from the canonical provider usage JSON.+newtype UsageRecord = UsageRecord {getUsage :: U.Usage}+  deriving stock (Eq, Show)++instance ToJSON UsageRecord where+  toJSON (UsageRecord u) = object ["usage" .= u, "exactCost" .= exactCost (U.cost u)]+    where+      exactCost c = let b = C.breakdown c in [C.usd c, C.inputUsd b, C.outputUsd b, C.cachedInputUsd b, C.cachedWriteUsd b]++instance FromJSON UsageRecord where+  parseJSON = withObject "UsageRecord" $ \o -> do+    v <- o .: "usage"+    ns <- o .: "exactCost" :: Parser [Rational]+    c <- case ns of+      [a, b, c, d, e] -> pure (C.Cost a (C.CostBreakdown b c d e) mempty)+      _ -> fail "exactCost must contain five rational amounts"+    withObject+      "Usage"+      ( \u -> do+          cv <- u .: "cost"+          basis <- withObject "Cost" (\x -> x .:? "basis" .!= mempty) cv+          UsageRecord+            <$> ( U.Usage+                    <$> u .: "input_tokens"+                    <*> u .: "output_tokens"+                    <*> u .: "cache_read_tokens"+                    <*> u .: "cache_write_tokens"+                    <*> u .:? "reasoning_tokens"+                    <*> u .: "total_tokens"+                    <*> u .:? "availability"+                    <*> pure c {C.basis = basis}+                )+      )+      v++data CallKind = CompletionCall | StreamCall+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToJSON, FromJSON)++-- | callId identifies a logical invocation within this process; attempt is+-- one-based within it. No structural node attribution is implied.+data LLMObservation = LLMObservation+  { callId :: !Text,+    attempt :: !Int,+    callKind :: !CallKind,+    requestedModel :: !Text,+    requestedProvider :: !Text,+    observedModel :: !(Maybe Text),+    terminalError :: !(Maybe Text),+    usage :: !(Maybe UsageRecord),+    startedAt :: !UTCTime,+    endedAt :: !UTCTime+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToJSON, FromJSON)++type LLMObserver = LLMObservation -> IO ()++noObservation :: LLMObserver+noObservation _ = pure ()++observedModelOf :: Maybe E.ModelCallEvidence -> Maybe Text+observedModelOf e = e >>= E.observedValue . E.observedModel++-- | Stable classification only: raw messages can include provider output.+errorClass :: ShikumiError -> Text+errorClass = \case+  ProviderError e -> T.pack (show (BE.category e))+  ProviderFailure _ -> "ProviderFailure"+  InvalidJSON _ -> "InvalidJSON"+  MissingField _ -> "MissingField"+  SchemaMismatch _ -> "SchemaMismatch"+  ValidationFailure _ -> "ValidationFailure"+  ContextWindowExceeded _ -> "ContextWindowExceeded"+  Timeout _ -> "Timeout"+  BudgetExceeded _ -> "BudgetExceeded"+  CodeExecFailed _ -> "CodeExecFailed"++-- | Unknown includes absent/legacy quality metadata and partially missing or+-- inconsistent counters. Available numeric components still contribute to sums.+usageUnknown :: Maybe UsageRecord -> Bool+usageUnknown Nothing = True+usageUnknown (Just (UsageRecord u)) = case U.availability u of+  Nothing -> True+  Just a -> not (Set.null (U.missingCategories a)) || U.inconsistent a++data BillingSummary = BillingSummary+  { completedAttempts :: !Int,+    failedAttempts :: !Int,+    unknownUsageAttempts :: !Int,+    observedUsage :: !UsageRecord,+    retainedAttempts :: ![LLMObservation],+    detailTruncated :: !Bool+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToJSON, FromJSON)++emptyBillingSummary :: BillingSummary+emptyBillingSummary = BillingSummary 0 0 0 (UsageRecord mempty) [] False++-- | Aggregate only by default. Opt in to bounded detail with the other constructor.+newBillingCollector :: IO (LLMObserver, IO BillingSummary)+newBillingCollector = newBillingCollectorWithLimit 0++-- | Negative limits are treated as zero. Counts and sums continue after the+-- detail limit, using an atomic strict update safe for concurrent evaluations.+newBillingCollectorWithLimit :: Int -> IO (LLMObserver, IO BillingSummary)+newBillingCollectorWithLimit limit = do+  ref <- newIORef emptyBillingSummary+  let observe o = atomicModifyIORef' ref $ \s ->+        let n = completedAttempts s + failedAttempts s+            ok = isNothing (terminalError o)+            u = maybe mempty getUsage (usage o)+            totalUsage = getUsage (observedUsage s) <> u+            next =+              BillingSummary+                (completedAttempts s + if ok then 1 else 0)+                (failedAttempts s + if ok then 0 else 1)+                (unknownUsageAttempts s + if usageUnknown (usage o) then 1 else 0)+                (UsageRecord totalUsage)+                (if n < max 0 limit then o : retainedAttempts s else retainedAttempts s)+                (detailTruncated s || n >= max 0 limit)+         in next `seq` (next, ())+  pure (observe, (\s -> s {retainedAttempts = reverse (retainedAttempts s)}) <$> readIORef ref)++renderBillingSummary :: BillingSummary -> Text+renderBillingSummary s =+  "transport billing: completed="+    <> t (completedAttempts s)+    <> " failed="+    <> t (failedAttempts s)+    <> " unknown-usage="+    <> t (unknownUsageAttempts s)+    <> " observed-usd="+    <> t (C.usd (U.cost (getUsage (observedUsage s))))+    <> " detail-truncated="+    <> t (detailTruncated s)+    <> "\ntransport quality: "+    <> TE.decodeUtf8 (BL.toStrict (encode (observedUsage s)))+  where+    t :: (Show a) => a -> Text; t = T.pack . show
src/Shikumi/Module.hs view
@@ -21,6 +21,7 @@ -- lives here. See the plan's Decision Log. module Shikumi.Module   ( predict,+    predictCaptured,     chainOfThought,     chainOfThoughtRaw,     twoStep,@@ -30,7 +31,7 @@  import Baikai (emptyContext, emptyModel, emptyOptions, user) import Control.Lens ((&), (.~))-import Data.Aeson (Object, Value (Object))+import Data.Aeson (Object, ToJSON (..), Value (Object)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KM import Data.Generics.Labels ()@@ -43,8 +44,8 @@ import Shikumi.Adapter (Adapter (..), ToPrompt (..), fallbackAdapter, responseText) import Shikumi.Error (ShikumiError (..)) import Shikumi.LLM (complete)-import Shikumi.Program (Program (FMap, Predict), embed, emptyParams)-import Shikumi.Schema (FromModel (..), ToSchema (..), Validatable (..))+import Shikumi.Program (CaptureCodec (..), Program (FMap, Predict, PredictCaptured), embed, emptyParams)+import Shikumi.Schema (FromModel (..), ToSchema (..), Validatable (..), deriveSchema) import Shikumi.Schema.Types   ( FieldMeta (..),     FieldPath,@@ -64,6 +65,22 @@   Signature i o ->   Program i o predict sig = Predict sig emptyParams++-- | Opt in to typed observation and node-local demonstration recovery.+predictCaptured ::+  forall i o.+  ( FromModel i,+    FromModel o,+    ToSchema i,+    ToSchema o,+    ToJSON i,+    ToJSON o,+    Validatable o,+    ToPrompt i,+    ToPrompt o+  ) =>+  Signature i o -> Program i o+predictCaptured sig = PredictCaptured (CaptureCodec toJSON toJSON (deriveSchema @i) (deriveSchema @o)) sig emptyParams  -- --------------------------------------------------------------------------- -- Chain of thought
src/Shikumi/Program.hs view
@@ -31,6 +31,7 @@   ( -- * The representation     Program       ( Predict,+        PredictCaptured,         Compose,         FMap,         Map,@@ -42,6 +43,7 @@         Ensemble,         Embed       ),+    CaptureCodec (..),     Params (..),     Demo (..),     emptyParams,@@ -186,7 +188,19 @@ -- 'Predict' captures the adapter/decode dictionaries existentially so that -- 'runProgram' can recover them by pattern-matching — this is what lets a program -- be rewritten as data while staying type-checked.+-- | Explicit wire encoders and schema evidence. Functions stay in the template;+-- parameter artifacts never serialize them.+data CaptureCodec i o = CaptureCodec+  { encodeCaptureInput :: i -> Value,+    encodeCaptureOutput :: o -> Value,+    captureInputSchema :: Value,+    captureOutputSchema :: Value+  }+ data Program i o where+  PredictCaptured ::+    (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o) =>+    CaptureCodec i o -> Signature i o -> Params -> Program i o   Predict ::     (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o) =>     Signature i o ->@@ -271,6 +285,7 @@   i ->   Eff es o runProgram (Predict sig ps) i = runPredict sig ps i+runProgram (PredictCaptured _ sig ps) i = runPredict sig ps i runProgram (Compose f g) i = runProgram f i >>= runProgram g runProgram (FMap k p) i = k <$> runProgram p i runProgram (Map _ p) xs = traverse (runProgram p) xs@@ -295,6 +310,7 @@   i ->   Eff es o runProgramConc (Predict sig ps) i = runPredict sig ps i+runProgramConc (PredictCaptured _ sig ps) i = runPredict sig ps i runProgramConc (Compose f g) i = runProgramConc f i >>= runProgramConc g runProgramConc (FMap k p) i = k <$> runProgramConc p i runProgramConc (Map w p) xs = pooledMapConcurrentlyN (max 1 w) (runProgramConc p) xs@@ -477,6 +493,7 @@ -- @lens@ @Traversal'@ laws; use it directly with @toListOf@/@over@/@set@. paramsTraversal :: (Applicative f) => (Params -> f Params) -> Program i o -> f (Program i o) paramsTraversal h (Predict sig ps) = Predict sig <$> h ps+paramsTraversal h (PredictCaptured codec sig ps) = PredictCaptured codec sig <$> h ps paramsTraversal h (Compose f g) = Compose <$> paramsTraversal h f <*> paramsTraversal h g paramsTraversal h (FMap k p) = FMap k <$> paramsTraversal h p paramsTraversal h (Map w p) = Map w <$> paramsTraversal h p@@ -513,6 +530,7 @@ nodeFieldsIndexed = go   where     go :: forall x y. Program x y -> [NodeFields]+    go (PredictCaptured _ sig ps) = go (Predict sig ps)     go (Predict sig _) =       [NodeFields (map fieldName (inputFields sig)) (map fieldName (outputFields sig))]     go (Compose a b) = go a ++ go b@@ -538,6 +556,7 @@   where     go :: forall x y. Program x y -> [Text]     go (Predict sig _) = [getInstruction sig]+    go (PredictCaptured _ sig _) = [getInstruction sig]     go (Compose a b) = go a ++ go b     go (FMap _ p) = go p     go (Map _ p) = go p@@ -562,6 +581,7 @@   where     go :: forall x y. Int -> Program x y -> (Program x y, Int)     go idx (Predict sig ps) = (Predict sig (if idx == n then f ps else ps), idx + 1)+    go idx (PredictCaptured codec sig ps) = (PredictCaptured codec sig (if idx == n then f ps else ps), idx + 1)     go idx (Compose a b) =       let (a', idx') = go idx a           (b', idx'') = go idx' b@@ -647,6 +667,7 @@ -- changes (parameters do not affect shape). programShape :: Program i o -> ProgramShape programShape (Predict sig _) = ShapePredict (sigLabel sig)+programShape (PredictCaptured _ sig _) = ShapePredict (sigLabel sig) programShape (Compose a b) = ShapeCompose (programShape a) (programShape b) programShape (FMap _ p) = ShapeFMap (programShape p) programShape (Map w p) = ShapeMap w (programShape p)@@ -682,7 +703,9 @@     n = length (foldParams prog)     go :: forall x y. [Params] -> Program x y -> (Program x y, [Params])     go (q : qs) (Predict sig _) = (Predict sig q, qs)+    go (q : qs) (PredictCaptured codec sig _) = (PredictCaptured codec sig q, qs)     go qs (Predict sig old) = (Predict sig old, qs) -- unreachable after the length guard+    go qs (PredictCaptured codec sig old) = (PredictCaptured codec sig old, qs) -- unreachable after the length guard     go qs (Compose a b) =       let (a', qs') = go qs a           (b', qs'') = go qs' b
src/Shikumi/Routing.hs view
@@ -20,7 +20,9 @@ -- @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.+-- before forwarding the call to the real @LLM@ interpreter beneath it. Continuation+-- expectations survive routing and are validated here, then again before cache+-- lookup and final transport; only the transport boundary removes them. -- -- Install order (mirroring @runTrace . runKeyedLLM . tracedLLM@): 'runRouting' is -- /outer/ of the real @LLM@ interpreter, which is /outer/ of 'routeLLM' (the@@ -54,6 +56,7 @@ import Data.Vector qualified as V import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>)) import Effectful.Dispatch.Dynamic (interpose, interpret, send)+import Effectful.Error.Static (Error, throwError) import Shikumi.Adapter   ( ModelCapability (..),     capabilityFor,@@ -62,7 +65,9 @@     metaResponseSchemaKey,     metaTemperatureKey,   )+import Shikumi.Error (ShikumiError) import Shikumi.LLM (LLM (..), complete, stream)+import Shikumi.LLM.Continuation (validateRequestContinuation)  -- | 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@@ -90,23 +95,25 @@ -- 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 :: (Routing :> es, LLM :> es, Error ShikumiError :> es) => Eff es a -> Eff es a routeLLM = interpose $ \_ -> \case   Complete _placeholder ctx opts -> do     m <- currentModel     let (ctx', opts') = translateForWire m ctx opts+    either throwError pure (validateRequestContinuation m ctx' opts')     complete m ctx' opts'   Stream _placeholder ctx opts -> do     m <- currentModel     let (ctx', opts') = translateForWire m ctx opts+    either throwError pure (validateRequestContinuation 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+-- @temperature@ when one was stamped, and strip the four rendering keys.+-- Continuation expectations remain available for later boundary checks. Fallback-capability models keep the -- marker 'Context' unchanged; non-'Predict' @Complete@ calls (no stamps) are never -- rewritten. translateForWire :: Model -> Context -> Options -> (Context, Options)
src/Shikumi/Stream.hs view
@@ -67,7 +67,7 @@ import Shikumi.LLM (LLM, stream) import Shikumi.Program   ( Params,-    Program (Compose, Embed, FMap, MajorityVote, Map, Parallel, Predict, Retry, RetryWhen, Validate),+    Program (Compose, Embed, FMap, MajorityVote, Map, Parallel, Predict, PredictCaptured, Retry, RetryWhen, Validate),     acceptOrReject,     effectiveSignature,     parseResponse,@@ -219,6 +219,7 @@   (StreamEvent -> Eff es ()) ->   Eff es o streamProgram prog i cb = case prog of+  PredictCaptured _ sig ps -> streamPredict sig ps i cb   Predict sig ps -> streamPredict sig ps i cb   Compose f g -> bracketNode cb $ do     b <- streamProgram f i cb
test/AdapterSpec.hs view
@@ -98,6 +98,8 @@     "AdapterSpec"     [ testCase "capabilityFor: Anthropic messages -> NativeSchema" $         capabilityFor anthropicModel @?= NativeSchema,+      testCase "capabilityFor: OpenAI Responses -> NativeSchema" $+        capabilityFor (emptyModel & #provider .~ "openai" & #api .~ OpenAIResponses) @?= NativeSchema,       testCase "capabilityFor: Custom (ollama) host -> PromptFallback" $         capabilityFor ollamaModel @?= PromptFallback,       testCase "fallback render: system prompt has the instruction and field markers" $ do
+ test/ContinuationSpec.hs view
@@ -0,0 +1,93 @@+module ContinuationSpec (tests) where++import Baikai qualified as B+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..))+import Data.Generics.Labels ()+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Vector qualified as V+import Effectful (runEff)+import Effectful.Concurrent (runConcurrent)+import Effectful.Error.Static (runErrorNoCallStack)+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM+import Shikumi.LLM.Continuation+import Shikumi.Routing (routeLLM, runRouting)+import Streamly.Data.Stream qualified as Stream+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++model :: B.Model+model = B.mkModel (B.Custom "continuation-test") "model" "https://provider.example"++context :: B.Context+context = B.emptyContext & #systemPrompt .~ Just "system" & #messages .~ V.fromList [B.user "input", B.AssistantMessage (B.emptyResponse ^. #message & #content .~ V.singleton (B.AssistantThinking (B.ThinkingContent "" (Just "signature") False Nothing)))]++options :: B.Options+options = stampContinuation (requestOrigin model) (Just (contextIdentity context)) B.emptyOptions++tests :: TestTree+tests =+  testGroup+    "Continuation"+    [ testCase "ordered prefix permits only append and construction timestamp changes" $ do+        validateRequestContinuation model context options @?= Right ()+        let appended = context & #messages .~ (context ^. #messages <> V.singleton (B.user "next"))+        validateRequestContinuation model appended options @?= Right ()+        let timestamped = context & #messages .~ V.map (\case B.AssistantMessage p -> B.AssistantMessage (p & #timestamp .~ Just (read "2026-09-08 00:00:00 UTC")); x -> x) (context ^. #messages)+        validateRequestContinuation model timestamped options @?= Right ()+        mapM_+          (\c -> assertRejected (validateRequestContinuation model c options))+          [context & #systemPrompt .~ Just "different", context & #messages .~ V.reverse (context ^. #messages), context & #tools .~ V.singleton B.emptyTool],+      testCase "unknown and malformed expectations reject opaque history" $ do+        assertRejected (validateRequestContinuation model context B.emptyOptions)+        assertRejected (validateRequestContinuation model context (stampContinuation Nothing Nothing B.emptyOptions))+        assertRejected (validateRequestContinuation model context (B.emptyOptions & #metadata .~ Map.singleton "shikumi.continuation.v1" (String "bad"))),+      testCase "bare, resilient and routed calls reject changed targets before transport" $ do+        (reg, seen) <- recordingRegistry+        let changed = [model & #modelId .~ "other", model & #api .~ B.OpenAIResponses, model & #provider .~ "other", model & #baseUrl .~ "https://other.example"]+        mapM_+          ( \m -> do+              runEff (runErrorNoCallStack @ShikumiError (runLLMWith reg (complete m context options))) >>= assertRejected+              runEff (runErrorNoCallStack @ShikumiError (runLLMWith reg (stream m context options))) >>= assertRejected+              runEff (runErrorNoCallStack @ShikumiError (runConcurrent (runLLMResilient (defaultLLMConfig reg) (complete m context options)))) >>= assertRejected+              runEff (runErrorNoCallStack @ShikumiError (runConcurrent (runLLMResilient (defaultLLMConfig reg) (stream m context options)))) >>= assertRejected+              runEff (runErrorNoCallStack @ShikumiError (runRouting m (runLLMWith reg (routeLLM (complete B.emptyModel context options))))) >>= assertRejected+          )+          changed+        readIORef seen >>= (@?= []),+      testCase "compatible route preserves payload and strips only private continuation metadata" $ do+        (reg, seen) <- recordingRegistry+        let opts = options & #metadata .~ Map.insert "public" (String "value") (options ^. #metadata)+        result <- runEff . runErrorNoCallStack @ShikumiError . runRouting model . runLLMWith reg . routeLLM $ complete B.emptyModel context opts+        case result of Left e -> assertFailure (show e); Right _ -> pure ()+        readIORef seen >>= (@?= [(context, stripContinuationMetadata opts)]),+      testCase "Responses replay scope is cross-checked independently of expectation" $ do+        let replay = B.ThinkingReplay B.OpenAIResponses "other" V.empty+            msg = B.AssistantMessage (B.emptyResponse ^. #message & #content .~ V.singleton (B.AssistantThinking (B.ThinkingContent "" Nothing False (Just replay))))+        assertRejected (validateReplayOrigin model [msg]),+      testCase "credentials never enter request-origin records" $ do+        requestOrigin (model & #baseUrl .~ "https://user:secret@provider.example") @?= Nothing+        requestOrigin (model & #baseUrl .~ "https://provider.example?key=secret") @?= Nothing+    ]++assertRejected :: Either ShikumiError a -> Assertion+assertRejected (Left (ValidationFailure _)) = pure ()+assertRejected _ = assertFailure "expected local continuation validation failure"++recordingRegistry :: IO (B.ProviderRegistry, IORef [(B.Context, B.Options)])+recordingRegistry = do+  seen <- newIORef []+  reg <- B.newProviderRegistry+  B.registerApiProviderWith+    reg+    ( ( B.apiProviderWith+          (model ^. #api)+          (\_ c o -> Stream.concatEffect $ modifyIORef' seen (<> [(c, o)]) >> pure (Stream.fromList []))+          (\m c o -> modifyIORef' seen (<> [(c, o)]) >> pure (B.emptyResponse & #model .~ m))+      )+        { B.describeThinking = \_ _ -> B.noThinkingRequested+        }+    )+  pure (reg, seen)
test/ErrorSpec.hs view
@@ -8,6 +8,7 @@   ( ShikumiError (..),     fromBaikaiError,     isTransient,+    renderShikumiError,   ) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=))@@ -16,14 +17,14 @@ tests =   testGroup     "ErrorSpec"-    [ testCase "maps providerError -> ProviderFailure" $-        fromBaikaiError (providerError "x") @?= ProviderFailure "x",+    [ testCase "maps providerError -> ProviderError" $+        fromBaikaiError (providerError "x") @?= ProviderError (providerError "x"),       testCase "maps decodeError -> InvalidJSON" $         fromBaikaiError (decodeError "y") @?= InvalidJSON "y",       testCase "maps invalidRequest -> SchemaMismatch" $         fromBaikaiError (invalidRequest "z") @?= SchemaMismatch "invalid request: z",-      testCase "maps processError -> ProviderFailure" $-        fromBaikaiError (processError 2 "boom") @?= ProviderFailure "process exited 2: boom",+      testCase "maps processError -> ProviderError" $+        fromBaikaiError (processError 2 "boom") @?= ProviderError (processError 2 "boom"),       testCase "maps ContextOverflow -> ContextWindowExceeded" $         fromBaikaiError           BaikaiError@@ -31,6 +32,7 @@               message = "context length exceeded",               httpStatus = Just 400,               retryAfterSeconds = Nothing,+              refusalCategory = Nothing,               exitCode = Nothing             }           @?= ContextWindowExceeded "context length exceeded",@@ -43,5 +45,28 @@         isTransient (InvalidJSON "") @?= False         isTransient (MissingField "") @?= False         isTransient (ValidationFailure "") @?= False-        isTransient (CodeExecFailed "") @?= False+        isTransient (CodeExecFailed "") @?= False,+      testGroup+        "released categories"+        [ testCase (show cat) $ do+            let e = (providerError "detail") {category = cat, httpStatus = Just 403, retryAfterSeconds = Just 7, exitCode = Just 2, refusalCategory = Just "future_policy"}+                expected = case cat of+                  DecodeFailure -> InvalidJSON "detail"+                  InvalidRequest -> SchemaMismatch "invalid request: detail"+                  ContextOverflow -> ContextWindowExceeded "detail"+                  _ -> ProviderError e+            fromBaikaiError e @?= expected+            isTransient expected @?= (cat `elem` [RateLimited, TransientError])+        | cat <- [AuthError, RateLimited, ContextOverflow, InvalidRequest, ContentFiltered, TransientError, DecodeFailure, ProcessFailure, ProviderUnavailable, OtherError]+        ],+      testGroup+        "refusal categories retained verbatim"+        [ testCase (show rc) $ do+            let e = (providerError "refused") {category = ContentFiltered, refusalCategory = rc}+            fromBaikaiError e @?= ProviderError e+        | rc <- [Nothing, Just "policy_example", Just "future_category"]+        ],+      testCase "readable structured error" $ do+        renderShikumiError (ProviderError (processError 2 "boom")) @?= "provider ProcessFailure (exit 2): boom"+        renderShikumiError (ProviderFailure "legacy") @?= "legacy"     ]
test/LLMSpec.hs view
@@ -30,13 +30,13 @@             r <- complete stubModel stubContext stubOptions             pure (flattenAssistantText (flattenAssistantBlocks r))         res @?= Right "hello from stub",-      testCase "unregistered tag -> ProviderFailure" $ do+      testCase "unregistered tag -> ProviderError" $ do         reg <- newProviderRegistry -- empty registry: no handler for the stub tag         res <-           runEff . runErrorNoCallStack @ShikumiError . runLLMWith reg $ do             r <- complete stubModel stubContext stubOptions             pure (flattenAssistantText (flattenAssistantBlocks r))         case res of-          Left (ProviderFailure _) -> pure ()-          other -> assertFailure ("expected Left (ProviderFailure ...), got " <> show other)+          Left (ProviderError _) -> pure ()+          other -> assertFailure ("expected Left (ProviderError ...), got " <> show other)     ]
test/Main.hs view
@@ -5,6 +5,7 @@ import AdapterSpec qualified import CombinatorSpec qualified import ConstraintSpec qualified+import ContinuationSpec qualified import EndToEndSpec qualified import ErrorSpec qualified import LLMSpec qualified@@ -16,7 +17,9 @@ import ProgramAcceptanceSpec qualified import ProgramSpec qualified import RefineSpec qualified+import RequestDefaultsSpec qualified import ResilienceSpec qualified+import ResponsesSpec qualified import RoutingSpec qualified import SchemaSpec qualified import SerializeSpec qualified@@ -32,7 +35,10 @@   defaultMain $     testGroup       "shikumi"-      [ ErrorSpec.tests,+      [ ResponsesSpec.tests,+        RequestDefaultsSpec.tests,+        ContinuationSpec.tests,+        ErrorSpec.tests,         SchemaSpec.tests,         SignatureSpec.tests,         AdapterSpec.tests,
test/ModuleSpec.hs view
@@ -6,7 +6,7 @@  import Control.Lens ((&), (.~)) import Data.Generics.Labels ()-import Data.IORef (newIORef)+import Data.IORef (newIORef, readIORef) import Effectful (runEff) import Effectful.Error.Static (runErrorNoCallStack) import ProgramFixtures@@ -14,12 +14,14 @@     Topic (..),     markerBody,     mkResponse,+    outlineResponse,+    runFullRecordingLLM,     runScriptedLLM,     topicToOutline,     topicToVerdict,   ) import Shikumi.Error (ShikumiError (..))-import Shikumi.Module (WithReasoning (..), chainOfThought, chainOfThoughtRaw, predict)+import Shikumi.Module (WithReasoning (..), chainOfThought, chainOfThoughtRaw, predict, predictCaptured) import Shikumi.Program (emptyParams, foldParams, mapParamsAt, runProgram) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=))@@ -28,7 +30,17 @@ tests =   testGroup     "ModuleSpec"-    [ testCase "predict builds a single default-parameter node" $+    [ testCase "captured prediction preserves requests and decoded output" $ do+        let run p = do+              replies <- newIORef [outlineResponse]+              requests <- newIORef []+              result <- runEff . runErrorNoCallStack @ShikumiError . runFullRecordingLLM requests replies $ runProgram p (Topic "haskell")+              sent <- readIORef requests+              pure (result, sent)+        ordinary <- run (predict topicToOutline)+        captured <- run (predictCaptured topicToOutline)+        captured @?= ordinary,+      testCase "predict builds a single default-parameter node" $         foldParams (predict topicToOutline) @?= [emptyParams],       testCase "chainOfThoughtRaw yields a WithReasoning value through the stub" $ do         ref <-
test/ProgramFixtures.hs view
@@ -29,6 +29,7 @@     -- * Fake LLM interpreters     runScriptedLLM,     runRecordingLLM,+    runFullRecordingLLM,   ) where @@ -40,6 +41,7 @@     emptyTextContent,   ) import Control.Lens ((&), (.~), (^.))+import Data.Aeson (ToJSON) import Data.Generics.Labels () import Data.IORef (IORef, atomicModifyIORef', modifyIORef') import Data.Maybe (fromMaybe)@@ -61,6 +63,8 @@ newtype Topic = Topic {subject :: Text}   deriving stock (Generic, Show, Eq) +instance ToJSON Topic+ instance ToSchema Topic  instance FromModel Topic@@ -72,6 +76,8 @@ newtype Outline = Outline {points :: [Text]}   deriving stock (Generic, Show, Eq) +instance ToJSON Outline+ instance ToSchema Outline  instance FromModel Outline@@ -188,6 +194,14 @@ runRecordingLLM capture ref = interpret $ \_ -> \case   Complete _ ctx _ -> do     liftIO (modifyIORef' capture (++ [renderedPrompt ctx]))+    liftIO (pop ref)+  Stream _ _ _ -> pure []++-- | Capture every request component, including demos and routing metadata.+runFullRecordingLLM :: (IOE :> es) => IORef [Text] -> IORef [Response] -> Eff (LLM : es) a -> Eff es a+runFullRecordingLLM capture ref = interpret $ \_ -> \case+  Complete model ctx opts -> do+    liftIO (modifyIORef' capture (++ [T.pack (show (model, ctx, opts))]))     liftIO (pop ref)   Stream _ _ _ -> pure [] 
+ test/RequestDefaultsSpec.hs view
@@ -0,0 +1,108 @@+module RequestDefaultsSpec (tests) where++import Baikai (ToolChoice (..), emptyContext, emptyModel, emptyOptions, emptyResponse)+import Baikai.Evidence qualified as E+import Baikai.Speed (Speed (..))+import Baikai.ThinkingLevel (ThinkingLevel (..))+import Control.Concurrent (forkFinally, newEmptyMVar, putMVar, takeMVar)+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..))+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.Map.Strict qualified as Map+import Effectful (liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (runErrorNoCallStack)+import Shikumi.Error (ShikumiError (..), isTransient)+import Shikumi.LLM (LLM (..), complete, stream)+import Shikumi.LLM.Defaults+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++defaults :: RequestDefaults+defaults = RequestDefaults (Just ThinkingHigh) (Just SpeedFast) (Just 4096) (Just (E.evidenceRequest "default"))++tests :: TestTree+tests =+  testGroup+    "Request defaults"+    [ testCase "empty is identity and unrelated policy and metadata survive" $ do+        let opts =+              emptyOptions+                & #temperature .~ Just 0.7+                & #toolChoice .~ Just ToolChoiceAuto+                & #metadata .~ Map.fromList [("shikumi.continuation.v1", String "protected"), ("schema", Bool True)]+        applyRequestDefaults emptyRequestDefaults opts @?= opts+        let merged = applyRequestDefaults defaults opts+        merged ^. #temperature @?= opts ^. #temperature+        merged ^. #toolChoice @?= opts ^. #toolChoice+        merged ^. #metadata @?= opts ^. #metadata+        merged @?= (opts & #thinking .~ Just ThinkingHigh & #speed .~ Just SpeedFast & #maxTokens .~ Just 4096 & #evidence .~ defaultEvidence defaults),+      testCase "explicit fields and whole evidence object override every default" $ do+        let evidence = (E.evidenceRequest "explicit") {E.strictness = E.EvidenceRequired E.EvidenceFullyObserved, E.attempt = 3, E.supersedes = Just "previous"}+            opts = emptyOptions & #thinking .~ Just ThinkingLow & #speed .~ Just SpeedStandard & #maxTokens .~ Just 12 & #evidence .~ Just evidence+        applyRequestDefaults defaults opts @?= opts+        applyRequestDefaults defaults (applyRequestDefaults defaults emptyOptions) @?= applyRequestDefaults defaults emptyOptions,+      testCase "blocking and streaming use nearest scope and preserve explicit values" $ do+        ref <- newIORef []+        result <- runEff+          . runErrorNoCallStack @ShikumiError+          . interpret+            ( \_ -> \case+                Complete _ _ o -> liftIO (modifyIORef' ref (++ [o])) >> pure emptyResponse+                Stream _ _ o -> liftIO (modifyIORef' ref (++ [o])) >> pure []+            )+          . withRequestDefaults defaults+          $ do+            _ <- complete emptyModel emptyContext emptyOptions+            withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedStandard}) $ do+              _ <- stream emptyModel emptyContext emptyOptions+              _ <- complete emptyModel emptyContext (emptyOptions & #speed .~ Just SpeedFast)+              pure ()+        result @?= Right ()+        captured <- readIORef ref+        map (^. #speed) captured @?= [Just SpeedFast, Just SpeedStandard, Just SpeedFast]+        map (^. #thinking) captured @?= replicate 3 (Just ThinkingHigh)+        map (^. #evidence) captured @?= replicate 3 (defaultEvidence defaults),+      testCase "zero default ceiling rejects before either dispatch or action" $ do+        ref <- newIORef (0 :: Int)+        result <- runEff+          . runErrorNoCallStack @ShikumiError+          . interpret+            ( \_ -> \case+                Complete {} -> liftIO (modifyIORef' ref (+ 1)) >> pure emptyResponse+                Stream {} -> liftIO (modifyIORef' ref (+ 1)) >> pure []+            )+          . withRequestDefaults (defaults {defaultMaxTokens = Just 0})+          $ do+            liftIO (modifyIORef' ref (+ 1))+            _ <- complete emptyModel emptyContext (emptyOptions & #maxTokens .~ Just 12)+            pure ()+        result @?= Left (ValidationFailure "request defaults: defaultMaxTokens must be positive")+        readIORef ref >>= (@?= 0)+        isTransient (ValidationFailure "invalid configuration") @?= False,+      testCase "concurrent invocations retain independent settings" $ do+        let run :: Speed -> IO [Maybe Speed]+            run speed = do+              ref <- newIORef []+              r <- runEff+                . runErrorNoCallStack @ShikumiError+                . interpret+                  ( \_ -> \case+                      Complete _ _ o -> liftIO (modifyIORef' ref (++ [o ^. #speed])) >> pure emptyResponse+                      Stream _ _ o -> liftIO (modifyIORef' ref (++ [o ^. #speed])) >> pure []+                  )+                . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just speed})+                $ do+                  _ <- complete emptyModel emptyContext emptyOptions+                  _ <- stream emptyModel emptyContext emptyOptions+                  pure ()+              r @?= Right ()+              readIORef ref+        a <- newEmptyMVar+        b <- newEmptyMVar+        _ <- forkFinally (run SpeedFast) (putMVar a)+        _ <- forkFinally (run SpeedStandard) (putMVar b)+        takeMVar a >>= either (assertFailure . show) (@?= replicate 2 (Just SpeedFast))+        takeMVar b >>= either (assertFailure . show) (@?= replicate 2 (Just SpeedStandard))+    ]
test/ResilienceSpec.hs view
@@ -5,16 +5,20 @@ module ResilienceSpec (tests) where  import Baikai (AssistantMessageEvent (..), flattenAssistantBlocks)+import Baikai.Error (BaikaiError (..), ErrorCategory (..), providerError) import Control.Concurrent (forkIO) import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (newTVarIO, readTVarIO)+import Control.Exception (AsyncException (ThreadKilled), throwIO, try)+import Control.Lens ((^.))+import Data.Aeson (eitherDecode, encode) import Data.IORef (newIORef, readIORef) import Data.Ratio ((%)) import Effectful (runEff) import Effectful.Concurrent (runConcurrent) import Effectful.Concurrent.Async (mapConcurrently) import Effectful.Error.Static (runErrorNoCallStack)-import Shikumi.Error (ShikumiError (..))+import Shikumi.Error (ShikumiError (..), fromBaikaiError) import Shikumi.LLM   ( LLMConfig (..),     RetryPolicy (..),@@ -25,15 +29,19 @@     stream,   ) import Shikumi.LLM.Budget (newBudget, spentUSD)+import Shikumi.LLM.Observation qualified as O import StubProvider   ( budgetBarrierStubRegistry,+    classifiedStubRegistry,     concurrencyStubRegistry,     costStubRegistry,+    exceptionStubRegistry,     failingStreamCostStubRegistry,     failingStreamStubRegistry,     failingStubRegistry,     flattenAssistantText,     invalidStubRegistry,+    retryStreamCostStubRegistry,     stubContext,     stubModel,     stubOptions,@@ -45,8 +53,61 @@ tests =   testGroup     "ResilienceSpec"-    [ testCase "retry recovers after 2 failures" $ do+    [ testCase "EP-61: retry emits two correlated attempts and retains failure cost" $ do+        (observe, snapshot) <- O.newBillingCollectorWithLimit 10         ref <- newIORef 0+        reg <- retryStreamCostStubRegistry ref 1 (1 % 3) "ok"+        let cfg = (defaultLLMConfig reg) {observer = Just observe, retryPolicy = RetryPolicy 2 0 0}+        result <-+          runEff . runConcurrent . runErrorNoCallStack @ShikumiError . runLLMResilient cfg $+            stream stubModel stubContext stubOptions+        assertBool "retry succeeded" (either (const False) (const True) result)+        summary <- snapshot+        O.completedAttempts summary @?= 1+        O.failedAttempts summary @?= 1+        (O.getUsage (O.observedUsage summary) ^. #cost . #usd) @?= (1 % 3)+        map O.attempt (O.retainedAttempts summary) @?= [1, 2]+        case O.retainedAttempts summary of+          [a, b] -> O.callId a @?= O.callId b+          _ -> assertFailure "expected two attempts"+        eitherDecode (encode summary) @?= Right summary,+      testCase "EP-61: thrown transport failure has no invented usage" $ do+        ref <- newIORef 0+        reg <- failingStubRegistry ref 1 "ok"+        (observe, snapshot) <- O.newBillingCollectorWithLimit 2+        _ <- runText ((defaultLLMConfig reg) {observer = Just observe, retryPolicy = RetryPolicy 2 0 0})+        summary <- snapshot+        case O.retainedAttempts summary of+          first : _ -> O.usage first @?= Nothing+          [] -> assertFailure "no attempt observed",+      testCase "EP-61: bounded collector keeps exact totals after detail truncation" $ do+        (observe, snapshot) <- O.newBillingCollectorWithLimit 1+        reg <- costStubRegistry (1 % 3) "ok"+        let cfg = (defaultLLMConfig reg) {observer = Just observe}+        _ <- runText cfg+        _ <- runText cfg+        summary <- snapshot+        O.completedAttempts summary @?= 2+        O.unknownUsageAttempts summary @?= 2+        length (O.retainedAttempts summary) @?= 1+        O.detailTruncated summary @?= True+        (O.getUsage (O.observedUsage summary) ^. #cost . #usd) @?= (2 % 3),+      testCase "EP-61: callback BaikaiError propagates without another transport attempt" $ do+        ref <- newIORef 0+        reg <- failingStubRegistry ref 0 "ok"+        let err = (providerError "observer failed") {category = TransientError}+            cfg = (defaultLLMConfig reg) {observer = Just (\_ -> throwIO err)}+        result <- try @BaikaiError (runText cfg)+        result @?= Left err+        readIORef ref >>= (@?= 1),+      testCase "EP-61: cancellation emits no synthetic success" $ do+        (observe, snapshot) <- O.newBillingCollector+        reg <- exceptionStubRegistry (throwIO ThreadKilled)+        result <- try @AsyncException (runText ((defaultLLMConfig reg) {observer = Just observe}))+        result @?= Left ThreadKilled+        snapshot >>= (@?= O.emptyBillingSummary),+      testCase "retry recovers after 2 failures" $ do+        ref <- newIORef 0         reg <- failingStubRegistry ref 2 "ok"         let cfg = (defaultLLMConfig reg) {retryPolicy = RetryPolicy 3 1 5}         res <- runText cfg@@ -59,7 +120,7 @@         let cfg = (defaultLLMConfig reg) {retryPolicy = RetryPolicy 2 1 5}         res <- runText cfg         case res of-          Left (ProviderFailure _) -> pure ()+          Left (ProviderError _) -> pure ()           other -> assertFailure ("expected Left (ProviderFailure ...), got " <> show other)         n <- readIORef ref         n @?= 2,@@ -156,7 +217,29 @@         res2 <- runStream cfg         case res2 of           Left (BudgetExceeded _) -> pure ()-          other -> assertFailure ("expected Left (BudgetExceeded ...) after the failed call charged, got " <> show other)+          other -> assertFailure ("expected Left (BudgetExceeded ...) after the failed call charged, got " <> show other),+      testGroup+        "classified attempts across APIs"+        [ testCase (show (cat, streaming, thrown)) $ do+            ref <- newIORef 0+            let err = (providerError "refused") {category = cat, refusalCategory = Just "policy_example"}+                retryable = cat `elem` [RateLimited, TransientError]+                cost = 1 % 100+            reg <- classifiedStubRegistry ref 1 err thrown cost+            b <- newBudget Nothing+            let cfg = (defaultLLMConfig reg) {retryPolicy = RetryPolicy 3 1 5, budget = Just b}+            result <- if streaming then fmap (fmap (const "ok")) (runStream cfg) else runText cfg+            result @?= if retryable then Right "ok" else Left (fromBaikaiError err)+            readIORef ref >>= (@?= if retryable then 2 else 1)+            spentUSD b >>= (@?= if thrown then 0 else cost)+        | cat <- [AuthError, RateLimited, ContentFiltered, TransientError, ProviderUnavailable, ProcessFailure, OtherError, InvalidRequest, ContextOverflow, DecodeFailure],+          (streaming, thrown) <- [(False, False), (False, True), (True, False)]+        ],+      testCase "cancellation escapes without retry" $ do+        reg <- exceptionStubRegistry (throwIO ThreadKilled)+        let cfg = (defaultLLMConfig reg) {retryPolicy = RetryPolicy 3 1 5}+        result <- try @AsyncException (runText cfg)+        result @?= Left ThreadKilled     ]   where     runText cfg =
+ test/ResponsesSpec.hs view
@@ -0,0 +1,45 @@+module ResponsesSpec (tests) where++import Baikai qualified as B+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..), object, (.=))+import Data.Generics.Labels ()+import Data.IORef+import Effectful (liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (runErrorNoCallStack)+import Shikumi.Adapter (ModelCapability (..), attachSchema, capabilityFor)+import Shikumi.Error (ShikumiError)+import Shikumi.LLM qualified as L+import Shikumi.Routing (routeLLM, runRouting)+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests = testCase "Responses routes strict native schemas for both operations" $ do+  let model = B.mkModel B.OpenAIResponses "fixture" "https://example.invalid" & #provider .~ "openai"+      schema = object ["type" .= String "object", "properties" .= object []]+  case capabilityFor model of NativeSchema -> pure (); _ -> assertFailure "Responses must be native"+  seen <- newIORef []+  result <- runEff+    . runErrorNoCallStack @ShikumiError+    . runRouting model+    . interpret+      ( \_ -> \case+          L.Complete m _ o -> liftIO (modifyIORef' seen (<> [(m, o)])) >> pure B.emptyResponse+          L.Stream m _ o -> liftIO (modifyIORef' seen (<> [(m, o)])) >> pure []+      )+    . routeLLM+    $ do+      _ <- L.complete B.emptyModel B.emptyContext (attachSchema schema B.emptyOptions)+      _ <- L.stream B.emptyModel B.emptyContext (attachSchema schema B.emptyOptions)+      pure ()+  result @?= Right ()+  recorded <- readIORef seen+  length recorded @?= 2+  mapM_+    ( \(m, o) -> do+        m ^. #api @?= B.OpenAIResponses+        o ^. #responseFormat @?= Just (B.JsonSchema (B.jsonSchemaFormat "output" schema & #strict .~ True))+    )+    recorded
test/RoutingSpec.hs view
@@ -25,6 +25,7 @@     jsonSchemaFormat,   ) import Baikai.Models.Generated (openai_gpt_4o_mini)+import Baikai.Speed (Speed (..)) import Control.Lens ((^.)) import Data.Aeson (Value (..), eitherDecodeStrict, object, (.=)) import Data.Aeson.KeyMap qualified as KM@@ -47,6 +48,7 @@ import Shikumi.Combinator (majorityVote, majorityVoteBy) import Shikumi.Error (ShikumiError) import Shikumi.LLM (LLM (..), Response)+import Shikumi.LLM.Defaults import Shikumi.Module (predict) import Shikumi.Program   ( Demo (..),@@ -112,10 +114,13 @@       . runErrorNoCallStack @ShikumiError       . runRouting model       . runCapturingLLM ref outlineResponse+      . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedFast})       . routeLLM       $ runProgram prog input   assertBool "routed program decodes without error" (isRight res)-  readIORef ref+  captured <- readIORef ref+  map (\(_, _, o) -> o ^. #speed) captured @?= replicate (length captured) (Just SpeedFast)+  pure captured  -- | As 'captureRouted' but under the concurrent executor. captureRoutedConc :: Model -> Program Topic Outline -> Topic -> IO [(Model, Context, Options)]@@ -127,10 +132,13 @@       . runConcurrent       . runRouting model       . runCapturingLLM ref outlineResponse+      . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedFast})       . routeLLM       $ runProgramConc prog input   assertBool "routed program decodes without error" (isRight res)-  readIORef ref+  captured <- readIORef ref+  map (\(_, _, o) -> o ^. #speed) captured @?= replicate (length captured) (Just SpeedFast)+  pure captured  isRight :: Either a b -> Bool isRight = either (const False) (const True)@@ -259,12 +267,14 @@         . runErrorNoCallStack @ShikumiError         . runRouting openai_gpt_4o_mini         . runCapturingLLM ref outlineResponse+        . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedFast})         . routeLLM         $ streamProgram (predict topicToOutline) (Topic "cats") (\_ -> pure ())     assertBool "routed streaming program decodes without error" (isRight res)     captured <- readIORef ref     case captured of       [(m, _, o)] -> do+        o ^. #speed @?= Just SpeedFast         m ^. #modelId @?= openai_gpt_4o_mini ^. #modelId         o ^. #responseFormat           @?= Just (JsonSchema ((jsonSchemaFormat "output" (deriveSchema @Outline)) {strict = True}))
test/StubProvider.hs view
@@ -17,8 +17,11 @@     failingStubRegistry,     failingStreamStubRegistry,     failingStreamCostStubRegistry,+    retryStreamCostStubRegistry,     invalidStubRegistry,     concurrencyStubRegistry,+    classifiedStubRegistry,+    exceptionStubRegistry,   ) where @@ -152,7 +155,7 @@       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+-- | A registry whose @complete@ throws a typed 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@@ -160,7 +163,7 @@   mkRegistry t $ \_ _ _ -> do     n <- atomicModifyIORef' ref (\k -> (k + 1, k + 1))     if n <= failTimes-      then throwIO (providerError ("stub transient failure #" <> T.pack (show n)))+      then throwIO ((providerError ("stub transient failure #" <> T.pack (show n))) {category = TransientError})       else pure (stubResponse t)  -- | A terminal-failing event sequence: an 'EventError' whose assembled message@@ -206,6 +209,27 @@       }   pure reg +retryStreamCostStubRegistry :: IORef Int -> Int -> Rational -> Text -> IO ProviderRegistry+retryStreamCostStubRegistry ref failTimes cost t = do+  reg <- newProviderRegistry+  registerApiProviderWith+    reg+    ( apiProviderWith+        stubApi+        ( \_ _ _ -> Stream.concatEffect $ do+            n <- atomicModifyIORef' ref (\k -> (k + 1, k + 1))+            pure $+              Stream.fromList $+                if n <= failTimes+                  then streamErrorEvents cost ("stub stream failure #" <> T.pack (show n))+                  else stubEvents t+        )+        (\_ _ _ -> pure (stubResponse t))+    )+      { describeThinking = stubDescribeThinking+      }+  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.@@ -245,3 +269,36 @@       c <- readTVar cur       modifyTVar' mx (max c)     leave = atomically (modifyTVar' cur (subtract 1))++-- | Exercise in-band, thrown, and streamed classified failures with attempt costs.+classifiedStubRegistry :: IORef Int -> Int -> BaikaiError -> Bool -> Rational -> IO ProviderRegistry+classifiedStubRegistry ref failTimes err thrown cost = do+  let next = atomicModifyIORef' ref (\n -> (n + 1, n < failTimes))+      payload = stubPayloadWith "partial" & #usage . #cost . #usd .~ cost & #errorMessage .~ Just "legacy text disagrees" & #stopReason .~ ErrorReason+      response = stubResponse "partial" & #message .~ payload & #errorInfo .~ Just err+  reg <- newProviderRegistry+  registerApiProviderWith+    reg+    ( apiProviderWith+        stubApi+        ( \_ _ _ -> Stream.concatEffect $ do+            failed <- next+            pure+              ( Stream.fromList+                  ( if failed+                      then [EventStart (StartPayload (AssistantMessage (stubPayloadWith "")) Nothing), EventError (errorTerminal Nothing Nothing ErrorReason (AssistantMessage payload) err)]+                      else stubEvents "ok"+                  )+              )+        )+        ( \_ _ _ -> do+            failed <- next+            if failed then if thrown then throwIO err else pure response else pure (stubResponse "ok")+        )+    )+      { describeThinking = stubDescribeThinking+      }+  pure reg++exceptionStubRegistry :: IO Response -> IO ProviderRegistry+exceptionStubRegistry action = mkRegistry "" (\_ _ _ -> action)
test/TwoStepSpec.hs view
@@ -11,6 +11,7 @@     emptyResponse,     emptyTextContent,   )+import Baikai.Error (contentFiltered) import Control.Lens ((&), (.~)) import Data.Generics.Labels () import Data.IORef (newIORef, readIORef)@@ -18,14 +19,18 @@ import Data.Text qualified as T import Data.Vector qualified as V import Effectful (runEff)+import Effectful.Concurrent (runConcurrent) import Effectful.Error.Static (runErrorNoCallStack) import Fixtures (Article, Author (..), Sentiment (..), Summary (..), sampleArticle, sampleSummary) import ProgramFixtures (runScriptedLLM)-import Shikumi.Error (ShikumiError)+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLMConfig (..), RetryPolicy (..), defaultLLMConfig, runLLMResilient) import Shikumi.Module (twoStep) import Shikumi.Program (Program, runProgram)+import Shikumi.Routing (routeLLM, runRouting) import Shikumi.Schema.Types (field) import Shikumi.Signature (Demo (..), Signature, mkSignature, setDemos)+import StubProvider (classifiedStubRegistry, stubModel) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) @@ -93,5 +98,13 @@           runEff . runErrorNoCallStack @ShikumiError . runScriptedLLM ref $             runProgram prog sampleArticle         remaining <- readIORef ref-        length remaining @?= 0+        length remaining @?= 0,+      testCase "refusal prevents the extraction call" $ do+        ref <- newIORef 0+        let err = contentFiltered "refused"+        reg <- classifiedStubRegistry ref 1 err False 0+        let cfg = (defaultLLMConfig reg) {retryPolicy = RetryPolicy 3 1 5}+        out <- runEff . runConcurrent . runErrorNoCallStack @ShikumiError . runRouting stubModel . runLLMResilient cfg . routeLLM $ runProgram prog sampleArticle+        out @?= Left (ProviderError err)+        readIORef ref >>= (@?= 1)     ]
test/XmlAdapterSpec.hs view
@@ -1,28 +1,32 @@--- | EP-26 M1: 'Shikumi.Adapter.xmlAdapter'. A third wire format on the typed seam.--- @render@ asks for @<field>…</field>@ tags; @parse@ reads them back into the--- typed output via the same 'sectionsToObject' + decode path the fallback adapter--- uses, so a missing tag yields the same located 'MissingField'.+{-# LANGUAGE DataKinds #-}+ module XmlAdapterSpec (tests) where  import Baikai   ( AssistantContent (..),+    Context,+    Message (..),     Response,+    TextContent (..),     emptyResponse,     emptyTextContent,   ) import Control.Lens ((&), (.~), (^.))+import Data.Aeson (ToJSON, object, (.=)) import Data.Generics.Labels () import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Vector qualified as V import Fixtures (Article, Author (..), Sentiment (..), Summary (..), sampleArticle, sampleSummary)-import Shikumi.Adapter (Adapter (..), xmlAdapter)+import GHC.Generics (Generic)+import Shikumi.Adapter (Adapter (..), ToPrompt, nestedXmlAdapter, xmlAdapter) import Shikumi.Error (ShikumiError (..))-import Shikumi.Schema.Types (field)+import Shikumi.Schema (FromModel, ToSchema (..), Validatable)+import Shikumi.Schema.Types (Constrained, Constraint (..), field) import Shikumi.Signature (Demo (..), Signature, mkSignature, setDemos) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))  sig :: Signature Article Summary sig = setDemos [Demo sampleArticle sampleSummary] (mkSignature "Summarize the article")@@ -90,7 +94,8 @@ tests =   testGroup     "XmlAdapterSpec"-    [ testCase "xml render: system prompt has the instruction and an XML tag" $ do+    [ nestedTests,+      testCase "xml render: system prompt has the instruction and an XML tag" $ do         T.isInfixOf "Summarize the article" (sysOf xmlAdapter) @?= True         T.isInfixOf "<headline>" (sysOf xmlAdapter) @?= True,       testCase "xml parse: tagged body decodes to the expected Summary" $@@ -98,3 +103,148 @@       testCase "xml parse: a missing tag -> MissingField (located)" $         parse xmlAdapter sig (mkResponse xmlBodyNoBullets) @?= Left (MissingField "bullets")     ]++-- Separate fixtures keep the richer codec independent of ToPrompt flattening.+data Person = Person {label :: Text, count :: Int}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON, ToSchema, FromModel)++newtype Meta = Meta {optional :: Maybe Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON, ToSchema, FromModel)++data Envelope = Envelope+  { people :: [Person],+    matrix :: [[Int]],+    metadata :: Meta,+    literal :: Text,+    maybeText :: Maybe Text,+    flag :: Bool,+    ratio :: Double+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON, ToSchema, FromModel, Validatable)++newtype Limited = Limited {value :: Constrained '[ 'MinLen 3] Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToPrompt, ToSchema, FromModel, Validatable)++-- A hand-written union is outside the generated-schema subset and uses JSON.+newtype Manual = Manual {manual :: Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON, FromModel, Validatable)++instance ToSchema Manual where+  toSchema _ = object ["type" .= ("object" :: Text), "properties" .= object ["manual" .= object ["anyOf" .= [object ["type" .= ("string" :: Text)], object ["type" .= ("integer" :: Text)]]]]]++envelopeSig :: Signature Article Envelope+envelopeSig = mkSignature "Encode all nested fields"++example :: Envelope+example = Envelope [Person "Ada & </author>" 2, Person "Lin" 3] [[1, 2], [], [3]] (Meta Nothing) "null" (Just "null") True 1.25++assistantBodies :: Context -> [Text]+assistantBodies ctx = [T.concat [t | AssistantText (TextContent t) <- V.toList (p ^. #content)] | AssistantMessage p <- V.toList (ctx ^. #messages)]++rendered :: Envelope -> [Text]+rendered value = assistantBodies (fst (render nestedXmlAdapter (setDemos [Demo sampleArticle value] envelopeSig) sampleArticle))++decodeEnvelope :: Text -> Either ShikumiError Envelope+decodeEnvelope = parse nestedXmlAdapter envelopeSig . mkResponse++nestedBody :: Text+nestedBody = T.replace "{\"name\": \"Ada\"}" "<name>Ada</name>" $ T.replace "[\"records in\", \"records out\", \"errors are typed\"]" "<item>records in</item><item>records out</item><item>errors are typed</item>" xmlBody++nestedTests :: TestTree+nestedTests =+  testGroup+    "nested codec"+    [ testCase "nested record and list agree with legacy JSON containers" $+        parse xmlAdapter sig (mkResponse nestedBody) @?= Right expectedSummary,+      testCase "same-name nesting balances and unknown properties are ignored" $+        parse xmlAdapter sig (mkResponse (T.replace "<author>" "<author><author/>" nestedBody)) @?= Right expectedSummary,+      testCase "first complete duplicate wins, unknown top-level ignored, prose allowed" $+        parse xmlAdapter sig (mkResponse ("Here is the answer & details. <unknown/>" <> nestedBody <> "<headline>wrong</headline>")) @?= Right expectedSummary,+      testCase "nested occurrence cannot satisfy missing outer field" $+        parse xmlAdapter sig (mkResponse (T.replace "<author>" "<author><bullets/>" (T.replace "{\"name\": \"Ada\"}" "<name>Ada</name>" xmlBodyNoBullets))) @?= Left (MissingField "bullets"),+      testCase "missing nested field retains path" $+        parse xmlAdapter sig (mkResponse (T.replace "<name>Ada</name>" "" nestedBody)) @?= Left (MissingField "author.name"),+      testCase "record validation still executes" $+        parse xmlAdapter sig (mkResponse (T.replace "<item>records in</item>" "" nestedBody)) @?= Left (ValidationFailure "bullets: must have 3 to 5 items"),+      testCase "declared field constraints execute" $+        parse xmlAdapter (mkSignature "Validate" :: Signature Article Limited) (mkResponse "<value>x</value>") @?= Left (ValidationFailure "value: minLength 3 violated"),+      testCase "nested demos round-trip records, matrices, null strings and numeric/boolean scalars" $+        mapM_+          ( \v -> case rendered v of+              [body] -> decodeEnvelope body @?= Right v+              bodies -> assertFailure (show bodies)+          )+          [example, example {people = [], matrix = [], literal = "", maybeText = Nothing, flag = False, ratio = -2.5}, example {literal = "a ]]> b & <tag>  c\nd", maybeText = Just ""}],+      testCase "manual union schemas use escaped JSON fallback" $ do+        let manualSig = setDemos [Demo sampleArticle (Manual "a < b")] (mkSignature "Manual")+            ctx = fst (render nestedXmlAdapter manualSig sampleArticle)+        assistantBodies ctx @?= ["<manual>\"a &lt; b\"</manual>\n"]+        map (parse nestedXmlAdapter manualSig . mkResponse) (assistantBodies ctx) @?= [Right (Manual "a < b")],+      testCase "renderer preserves top-level order and distinguishes null spellings" $+        case rendered example of+          [body] -> do+            T.isPrefixOf "<people>" body @?= True+            T.isInfixOf "<literal>null</literal>" body @?= True+            T.isInfixOf "<maybeText><![CDATA[null]]></maybeText>" body @?= True+            T.isInfixOf "<item><count>2</count><label>" body @?= True+          bodies -> assertFailure (show bodies),+      testCase "guide contains nested fields and repeated items" $ do+        let guide = fromMaybe "" (fst (render nestedXmlAdapter envelopeSig sampleArticle) ^. #systemPrompt)+        T.isInfixOf "<people><item><count>" guide @?= True+        T.isInfixOf "<![CDATA[null]]>" guide @?= True,+      testCase "nullable omission, self-closing empty object, comments and entities" $+        decodeEnvelope "<people/><matrix/><metadata/><!-- ok --><literal>&amp;&lt;&gt;&quot;&apos;&#65;&#x1F600;</literal><flag>false</flag><ratio>2</ratio>"+          @?= Right (Envelope [] [] (Meta Nothing) "&<>\"'A😀" Nothing False 2),+      testCase "CDATA stays literal and preserves inner whitespace" $+        decodeEnvelope "<people/><matrix/><metadata/><literal><![CDATA[a < b  & c]]></literal><maybeText><![CDATA[null]]></maybeText><flag>true</flag><ratio>1</ratio>"+          @?= Right (Envelope [] [] (Meta Nothing) "a < b  & c" (Just "null") True 1),+      testCase "nested scalar errors carry record and array indices" $ do+        let prefix = "<people><item><label>Ada</label><count>wrong</count></item></people>"+        decodeEnvelope prefix @?= Left (SchemaMismatch "people.[0].count: expected integer, got string")+        decodeEnvelope "<people/><matrix><item><item>1</item><item>wrong</item></item></matrix>" @?= Left (SchemaMismatch "matrix.[0].[1]: expected integer, got string"),+      testCase "depth 64 accepted; 65 rejected" $ do+        parse xmlAdapter sig (mkResponse (T.replicate 64 "<x>" <> T.replicate 64 "</x>" <> nestedBody)) @?= Right expectedSummary+        xmlFailure "depth limit 64" (T.replicate 65 "<x>" <> T.replicate 65 "</x>"),+      testCase "size limit counts Unicode code points" $ do+        let body = nestedBody <> T.replicate (1048576 - T.length nestedBody) "😀"+        parse xmlAdapter sig (mkResponse body) @?= Right expectedSummary+        xmlFailure "input length limit 1048576" (body <> "😀"),+      testGroup+        "malformed fragments fail with located XML errors"+        [ testCase (T.unpack bad) (xmlFailure "" bad)+        | bad <-+            [ "<author><name>Ada</author></name>",+              "<headline>unfinished",+              "<x/",+              "</x>",+              "<!DOCTYPE x>",+              "<?xml version='1.0'?>",+              "<x a='b'/>",+              "<ns:x/>",+              "<x>&bogus;</x>",+              "<x>&amp</x>",+              "<x>&#xD800;</x>",+              "<x>&#0;</x>",+              "<x>&#x110000;</x>",+              "<x>&#9999999999999999999999;</x>",+              "<x>&#;</x>",+              "<!-- unclosed",+              "<!-- bad -- comment -->",+              "<x><![CDATA[unfinished</x>",+              "<x>]]></x>",+              "<author>mixed<name>Ada</name></author>",+              "<bullets><wrong/></bullets>"+            ]+        ]+    ]+  where+    xmlFailure expected body = case parse xmlAdapter sig (mkResponse body) of+      Left (SchemaMismatch msg) -> do+        T.isPrefixOf "XML: offset " msg @?= True+        T.isInfixOf expected msg @?= True+      result -> assertFailure (show result)