shikumi-cache 0.1.1.0 → 0.1.2.0
raw patch · 8 files changed
+299/−38 lines, 8 filesdep ~baikaidep ~shikumidep ~shikumi-cachePVP ok
version bump matches the API change (PVP)
Dependency ranges changed: baikai, shikumi, shikumi-cache
API changes (from Hackage documentation)
+ Shikumi.Cache: CacheConfig :: Maybe NominalDiffTime -> CacheConfig
+ Shikumi.Cache: [entryTTL] :: CacheConfig -> Maybe NominalDiffTime
+ Shikumi.Cache: cachedLLMWith :: forall (es :: [Effect]) a. (Cache :> es, LLM :> es, Time :> es) => CacheConfig -> Eff es a -> Eff es a
+ Shikumi.Cache: defaultCacheConfig :: CacheConfig
+ Shikumi.Cache: instance GHC.Classes.Eq Shikumi.Cache.CacheConfig
+ Shikumi.Cache: instance GHC.Internal.Generics.Generic Shikumi.Cache.CacheConfig
+ Shikumi.Cache: instance GHC.Internal.Show.Show Shikumi.Cache.CacheConfig
+ Shikumi.Cache: newtype CacheConfig
+ Shikumi.Cache.Backend.Effort: bestEffortIO :: a -> IO a -> IO a
+ Shikumi.Cache.Key: stripMessageTimestamps :: Value -> Value
Files
- CHANGELOG.md +19/−0
- shikumi-cache.cabal +8/−6
- src/Shikumi/Cache.hs +65/−8
- src/Shikumi/Cache/Backend/Effort.hs +15/−0
- src/Shikumi/Cache/Backend/SQLite.hs +10/−4
- src/Shikumi/Cache/Key.hs +44/−10
- src/Shikumi/Cache/ResponseJSON.hs +6/−1
- test/Main.hs +132/−9
CHANGELOG.md view
@@ -2,6 +2,25 @@ ## Unreleased +## 0.1.2.0 - 2026-07-05++### Added++- `CacheConfig`, `defaultCacheConfig`, and `cachedLLMWith` for a shared policy+ layer across cache backends, including optional TTL-based entry expiry.+- `Shikumi.Cache.Backend.Effort` for best-effort backend error handling and+ `stripMessageTimestamps` for canonical cache-key inspection.++### Changed++- Cache keys now include endpoint identity, default/per-call headers, provider+ compatibility settings, and omit message construction timestamps. This bumps+ `currentKeyVersion` to `shikumi-cache/v2`, invalidating old cache entries and+ old trace replay keys.+- `cachedLLM` no longer stores in-band provider error responses, and persistent+ backend lookup/store failures degrade to cache misses or no-ops.+- Refreshed the internal `shikumi` bound for the `0.3` series.+ ## 0.1.1.0 - 2026-06-28 ### Changed
shikumi-cache.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: shikumi-cache-version: 0.1.1.0+version: 0.1.2.0 synopsis: Content-addressed response caching for shikumi (EP-6) category: AI description:@@ -36,6 +36,7 @@ hs-source-dirs: src exposed-modules: Shikumi.Cache+ Shikumi.Cache.Backend.Effort Shikumi.Cache.Backend.Memory Shikumi.Cache.Backend.SQLite Shikumi.Cache.Key@@ -44,7 +45,7 @@ build-depends: , aeson- , baikai >=0.2 && <0.3+ , baikai >=0.3 && <0.4 , base >=4.20 && <5 , blake3 , bytestring@@ -54,7 +55,7 @@ , generic-lens , lens ^>=5.3 , scientific- , shikumi ^>=0.2.0.0+ , shikumi ^>=0.3.0.0 , stm , text ^>=2.1 , time@@ -68,18 +69,19 @@ ghc-options: -threaded -with-rtsopts=-N build-depends: , aeson- , baikai >=0.2 && <0.3+ , baikai >=0.3 && <0.4 , base , bytestring , containers+ , direct-sqlite , directory , effectful , filepath , generic-lens , lens , process- , shikumi ^>=0.2.0.0- , shikumi-cache ^>=0.1.1.0+ , shikumi ^>=0.3.0.0+ , shikumi-cache ^>=0.1.2.0 , stm , tasty , tasty-hunit
src/Shikumi/Cache.hs view
@@ -16,7 +16,10 @@ storeCache, -- * The memoizing policy+ CacheConfig (..),+ defaultCacheConfig, cachedLLM,+ cachedLLMWith, -- * Re-exports CacheKey (..),@@ -26,8 +29,15 @@ ) where +import Baikai (Response, StopReason (ErrorReason))+import Control.Lens ((^.))+import Control.Monad (when)+import Data.Generics.Labels ()+import Data.Maybe (isNothing)+import Data.Time.Clock (NominalDiffTime, diffUTCTime) import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>)) import Effectful.Dispatch.Dynamic (interpose, passthrough, send)+import GHC.Generics (Generic) import Shikumi.Cache.Key (CacheKey (..), cacheKey, currentKeyVersion) import Shikumi.Cache.Types (CachedResponse (..)) import Shikumi.Effect.Time (Time, getCurrentTime)@@ -48,25 +58,72 @@ storeCache :: (Cache :> es) => CacheKey -> CachedResponse -> Eff es () storeCache k v = send (StoreCache k v) +-- | Policy knobs for 'cachedLLMWith', shared by every backend.+--+-- 'entryTTL' is the maximum age of a usable entry, measured against+-- 'CachedResponse.storedAt' at lookup time. 'Nothing' (the default) means+-- entries never expire, which is the uniform default across Memory, SQLite,+-- Redis, and Postgres. Expiry is enforced here, at the policy layer, so it+-- behaves identically no matter which backend interprets the 'Cache' effect; an+-- expired entry is treated as a MISS and overwritten by the fresh response.+newtype CacheConfig = CacheConfig+ { entryTTL :: Maybe NominalDiffTime+ }+ deriving stock (Eq, Show, Generic)++-- | No expiry.+defaultCacheConfig :: CacheConfig+defaultCacheConfig = CacheConfig {entryTTL = Nothing}+ -- | Memoize EP-1's @LLM@ @complete@ through the 'Cache' effect: on a HIT (a key--- present whose 'keyVersion' matches the live namespace) return the stored--- response without contacting the provider; on a MISS delegate to the underlying--- @LLM@ handler, store the result, and return it. A stale-version entry is--- treated as a MISS (it is overwritten on re-fetch). The streaming op is passed+-- present whose 'keyVersion' matches the live namespace and whose age satisfies+-- 'defaultCacheConfig') return the stored response without contacting the+-- provider; on a MISS delegate to the underlying @LLM@ handler, store the+-- result if it is successful, and return it. Cache backends are best-effort:+-- lookup failures are MISSes and store failures are no-ops. Entries never+-- expire unless 'entryTTL' is set via 'cachedLLMWith'. In-band error responses+-- are never cached. Under concurrent identical requests both callers may miss+-- and call the provider; this accepted check-then-act race is harmless because+-- stores are idempotent upserts keyed by content. The streaming op is passed -- through unchanged — streams are not cached. cachedLLM :: (Cache :> es, LLM :> es, Time :> es) => Eff es a -> Eff es a-cachedLLM = interpose $ \env -> \case+cachedLLM = cachedLLMWith defaultCacheConfig++-- | A configured variant of 'cachedLLM'. See 'CacheConfig' for the shared TTL+-- policy.+cachedLLMWith ::+ (Cache :> es, LLM :> es, Time :> es) =>+ CacheConfig ->+ Eff es a ->+ Eff es a+cachedLLMWith cfg = interpose $ \env -> \case Complete model ctx opts -> do let key = cacheKey model ctx opts hit <- lookupCache key+ now <- getCurrentTime case hit of- Just cr | keyVersion cr == currentKeyVersion -> pure (response cr)+ Just cr+ | keyVersion cr == currentKeyVersion,+ fresh (entryTTL cfg) now (storedAt cr) ->+ pure (response cr) _ -> do resp <- complete model ctx opts- now <- getCurrentTime- storeCache key (CachedResponse resp now currentKeyVersion)+ stored <- getCurrentTime+ when (cacheable resp) $+ storeCache key (CachedResponse resp stored currentKeyVersion) pure resp other -> passthrough env other+ where+ fresh Nothing _ _ = True+ fresh (Just ttl) now written = diffUTCTime now written <= ttl++-- | Only successful responses are memoized. An in-band error response reports a+-- transient provider failure; caching it would replay the outage as the+-- permanent answer.+cacheable :: Response -> Bool+cacheable resp =+ (resp ^. #message . #stopReason) /= ErrorReason+ && isNothing (resp ^. #errorInfo)
+ src/Shikumi/Cache/Backend/Effort.hs view
@@ -0,0 +1,15 @@+-- | The one error posture every persistent cache backend shares: best-effort.+-- A failed storage action degrades to its fallback (a MISS for lookups, a no-op+-- for stores) instead of throwing. Asynchronous exceptions such as thread+-- cancellation and timeouts are re-thrown so structured concurrency keeps+-- working correctly.+module Shikumi.Cache.Backend.Effort (bestEffortIO) where++import Control.Exception (SomeAsyncException (..), SomeException, catch, fromException, throwIO)++bestEffortIO :: a -> IO a -> IO a+bestEffortIO fallback act =+ act `catch` \(e :: SomeException) ->+ case fromException e of+ Just (SomeAsyncException _) -> throwIO e+ Nothing -> pure fallback
src/Shikumi/Cache/Backend/SQLite.hs view
@@ -11,7 +11,9 @@ -- The store is a thin embedded SQLite database (no server) accessed via -- @direct-sqlite@. A single 'Database.SQLite3.Database' handle is guarded by an -- 'MVar' so the 'Cache' effect's lookups and stores are serialized (SQLite's--- default threading mode does not allow concurrent use of one connection).+-- default threading mode does not allow concurrent use of one connection). WAL+-- mode and a busy timeout make separate processes cooperate better. Lookup and+-- store failures are best-effort: they degrade to a MISS or no-op. module Shikumi.Cache.Backend.SQLite ( SQLiteCache, openSQLiteCache,@@ -39,6 +41,7 @@ import Effectful (Eff, IOE, liftIO, (:>)) import Effectful.Dispatch.Dynamic (interpret) import Shikumi.Cache (Cache (..), CacheKey (unCacheKey), CachedResponse (..))+import Shikumi.Cache.Backend.Effort (bestEffortIO) -- | A handle to an open SQLite-backed cache. The 'Database' is behind an 'MVar' -- so all access through the 'Cache' effect is serialized.@@ -46,7 +49,8 @@ -- | The schema. The @key@ is the 64-hex 'CacheKey' (already version-namespaced -- via the @version@ field baked into the hash). @value@ is the UTF-8 JSON of--- 'CachedResponse'. @stored_at@ is ISO-8601 for inspection/eviction.+-- 'CachedResponse'. @stored_at@ is ISO-8601 for inspection; policy-layer TTL+-- uses the same timestamp inside the JSON value. createTableSQL :: Text createTableSQL = """@@ -78,6 +82,8 @@ openSQLiteCache path = do db <- SQL.open (T.pack path) SQL.exec db createTableSQL+ SQL.exec db "PRAGMA journal_mode=WAL;"+ SQL.exec db "PRAGMA busy_timeout=5000;" SQLiteCache <$> newMVar db -- | Close the underlying database handle.@@ -93,8 +99,8 @@ -- simply re-fetched and overwritten — never returned. runCacheSQLite :: (IOE :> es) => SQLiteCache -> Eff (Cache : es) a -> Eff es a runCacheSQLite (SQLiteCache mv) = interpret $ \_ -> \case- LookupCache k -> liftIO (withMVar mv (sqliteLookup k))- StoreCache k v -> liftIO (withMVar mv (\db -> sqliteStore db k v))+ LookupCache k -> liftIO (bestEffortIO Nothing (withMVar mv (sqliteLookup k)))+ StoreCache k v -> liftIO (bestEffortIO () (withMVar mv (\db -> sqliteStore db k v))) -- | SELECT the JSON for a key and decode it; 'Nothing' on absent key or decode -- failure.
src/Shikumi/Cache/Key.hs view
@@ -3,13 +3,18 @@ -- | The content-addressed cache key (EP-6) — the MasterPlan's integration point -- #7. A 'CacheKey' is a BLAKE3 256-bit digest (64 lowercase hex chars) over a -- /canonical/ JSON serialization of everything about a request that can change--- the model's answer: the model routing identity, the rendered prompt, the--- tools, and the per-call options. Latency, response ids, API keys, timeouts,--- headers, and per-call metadata are excluded — they do not affect the output.+-- the model's answer: the model routing identity including base URL, model+-- default headers, and compat shim; per-call headers; the rendered prompt with+-- message timestamps excluded; tools; and sampling options. Latency, response+-- ids, API keys, timeouts, and per-call metadata are excluded — they do not+-- affect a successful response's content. -- -- The serialization is canonical (object keys sorted by Unicode code point, no -- insignificant whitespace, UTF-8) so the bytes — and therefore the key — are--- reproducible. The replay engine in+-- reproducible. Any change to the field set requires bumping+-- 'currentKeyVersion'; a bump invalidates all cache entries and makes previously+-- recorded @shikumi-trace@ files unreplayable, with replay failing closed via+-- @ReplayDivergence@. The replay engine in -- @docs/plans/7-hierarchical-tracing-observability-and-replay.md@ (EP-7) reuses -- 'cacheKey' verbatim, so both plans agree byte-for-byte; the golden test in the -- test suite pins the exact hex for a fixed request to catch any drift.@@ -20,6 +25,7 @@ requestToCanonicalValue, requestToCanonicalValueVersioned, currentKeyVersion,+ stripMessageTimestamps, ) where @@ -44,9 +50,12 @@ -- | The current cache key-namespace version. Baked into every hashed request -- (the @version@ field) and into 'Shikumi.Cache.Types.CachedResponse.keyVersion'. -- Bumping it changes every key, making all prior entries unreachable — a clean--- invalidation with no row deletion.+-- invalidation with no row deletion. Because @shikumi-trace@ stores cache keys+-- in trace files and recomputes them during replay, a version bump also makes+-- previously recorded trace files unreplayable; replay fails closed with+-- @ReplayDivergence@ rather than serving stale responses. currentKeyVersion :: Text-currentKeyVersion = "shikumi-cache/v1"+currentKeyVersion = "shikumi-cache/v2" -- | A content-addressed cache key: the 64-hex BLAKE3 digest of a request's -- canonical serialization. A @newtype@ so it cannot be confused with arbitrary@@ -71,9 +80,10 @@ -- | Assemble the canonical request 'Value' under an explicit version string. -- Exposed so a test can prove that bumping the version changes the key. The -- top-level object carries exactly the integration-point-#7 field set:--- @api, maxTokens, messages, model, provider, responseFormat, systemPrompt,--- temperature, thinking, toolChoice, tools, version@. 'canonicalJSON' sorts the--- keys, so the listing order here is irrelevant.+-- @api, baseUrl, compat, maxTokens, messages, model, modelHeaders,+-- optionsHeaders, provider, responseFormat, systemPrompt, temperature,+-- thinking, toolChoice, tools, version@. 'canonicalJSON' sorts the keys, so the+-- listing order here is irrelevant. requestToCanonicalValueVersioned :: Text -> Model -> Context -> Options -> Value requestToCanonicalValueVersioned version m ctx opts = object@@ -81,12 +91,16 @@ "model" .= (m ^. #modelId), "provider" .= (m ^. #provider), "api" .= toJSON (m ^. #api),+ "baseUrl" .= (m ^. #baseUrl),+ "modelHeaders" .= toJSON (m ^. #headers),+ "compat" .= toJSON (m ^. #compat), "systemPrompt" .= (ctx ^. #systemPrompt),- "messages" .= toJSON (ctx ^. #messages),+ "messages" .= stripMessageTimestamps (toJSON (ctx ^. #messages)), "tools" .= toJSON (ctx ^. #tools), "toolChoice" .= toJSON (opts ^. #toolChoice), "temperature" .= (toScientific <$> (opts ^. #temperature)), "maxTokens" .= (opts ^. #maxTokens),+ "optionsHeaders" .= toJSON (opts ^. #headers), "thinking" .= toJSON (opts ^. #thinking), "responseFormat" .= toJSON (opts ^. #responseFormat) ]@@ -95,6 +109,26 @@ -- temperature serialize identically regardless of how the Double was built. toScientific :: Double -> Scientific toScientific = realToFrac++-- | Delete the payload-level @timestamp@ of every message in a serialized+-- message vector. baikai's 'Baikai.Message.Message' encodes as+-- @{"tag": ..., "contents": {..., "timestamp": ...}}@; the timestamp records+-- when the message value was built, never what the provider sees, so two+-- requests differing only in it must share a cache key. Only the+-- @contents.timestamp@ level is deleted — a @"timestamp"@ key nested inside+-- content blocks or tool arguments is real request data and is preserved.+stripMessageTimestamps :: Value -> Value+stripMessageTimestamps (Array msgs) = Array (fmap stripOne msgs)+ where+ stripOne (Object msg) =+ Object $+ case KM.lookup "contents" msg of+ Nothing -> msg+ Just contents -> KM.insert "contents" (dropTs contents) msg+ stripOne v = v+ dropTs (Object payload) = Object (KM.delete "timestamp" payload)+ dropTs v = v+stripMessageTimestamps v = v -- | Render a 'Value' to canonical JSON bytes: every object's keys are emitted in -- ascending Unicode code-point order, with no insignificant whitespace. We do
src/Shikumi/Cache/ResponseJSON.hs view
@@ -57,7 +57,12 @@ usageOptions = defaultOptions {fieldLabelModifier = camelTo2 '_'} -- | Parse a required object field as 'Scientific' and lift it to 'Rational',--- inverting baikai's @Rational -> Scientific@ cost encoding.+-- inverting baikai's @Rational -> Scientific@ cost encoding. A synthetic cost+-- with a non-terminating decimal expansion, such as @1 % 3@, does not satisfy+-- 'Eq' after an encode/decode round-trip because baikai's encoder drops the+-- repetend index. Real USD pricing rates are decimal, so real provider costs+-- terminate; do not rely on round-tripped 'CachedResponse' equality for+-- synthetic non-decimal costs. ratField :: Object -> Key -> Parser Rational ratField o k = toRational <$> (o .: k :: Parser Scientific)
test/Main.hs view
@@ -12,13 +12,32 @@ -- process. module Main (main) where -import Baikai (Context, Model, Options, Response, user, _Context, _Model, _Options, _Response)+import Baikai+ ( Compat (CompatAnthropicMessages),+ Context,+ Model,+ Options,+ Response,+ StopReason (ErrorReason),+ defaultAnthropicMessagesCompat,+ user,+ userAt,+ _Context,+ _Model,+ _Options,+ _Response,+ )+import Control.Exception (bracket) import Control.Lens ((&), (.~))+import Data.Aeson (object, toJSON, (.=)) import Data.Generics.Labels () import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Map.Strict qualified as Map import Data.Text qualified as T import Data.Time.Clock (UTCTime) import Data.Vector qualified as V+import Database.SQLite3 (Database, SQLData (SQLText), StepResult (Done))+import Database.SQLite3 qualified as SQL import Effectful (Eff, IOE, liftIO, runEff, type (:>)) import Effectful.Concurrent (runConcurrent) import Effectful.Dispatch.Dynamic (interpret)@@ -27,13 +46,16 @@ CachedResponse (..), cacheKey, cachedLLM,+ cachedLLMWith, currentKeyVersion,+ defaultCacheConfig,+ entryTTL, lookupCache, storeCache, ) import Shikumi.Cache.Backend.Memory (newMemoryCache, runCacheMemory) import Shikumi.Cache.Backend.SQLite (runCacheSQLite, withSQLiteCache)-import Shikumi.Cache.Key (canonicalJSON, requestToCanonicalValueVersioned)+import Shikumi.Cache.Key (canonicalJSON, requestToCanonicalValueVersioned, stripMessageTimestamps) import Shikumi.Effect.Time (runTime) import Shikumi.LLM (LLM (..), complete) import System.Environment (getEnvironment, getExecutablePath, lookupEnv)@@ -64,7 +86,7 @@ -- reproduce. Captured from a first run; any drift in field set, canonical JSON, -- or hash breaks this test. pinnedKey :: T.Text-pinnedKey = "30b2015562ec8b5cd4fdb64c7cc671c84f56f80d24891deec6676c521f008113"+pinnedKey = "b31fd70140abbd0198c6b7caec748a8389bf93be909164bdcc340731b7032564" stubResponse :: Response stubResponse = _Response@@ -148,7 +170,48 @@ testCase "a different request yields a different key" $ assertBool "temperature change must change the key"- (cacheKey fixModel fixCtx fixOpts /= cacheKey fixModel fixCtx (fixOpts & #temperature .~ Just 0.7))+ (cacheKey fixModel fixCtx fixOpts /= cacheKey fixModel fixCtx (fixOpts & #temperature .~ Just 0.7)),+ testCase "baseUrl changes the key" $+ assertBool+ "endpoint routing must be part of the key"+ (cacheKey fixModel fixCtx fixOpts /= cacheKey (fixModel & #baseUrl .~ "https://proxy.internal") fixCtx fixOpts),+ testCase "model headers change the key" $+ assertBool+ "model default headers must be part of the key"+ ( cacheKey fixModel fixCtx fixOpts+ /= cacheKey (fixModel & #headers .~ Map.singleton "anthropic-beta" "context-1m-2025-08-07") fixCtx fixOpts+ ),+ testCase "options headers change the key" $+ assertBool+ "per-call headers must be part of the key"+ ( cacheKey fixModel fixCtx fixOpts+ /= cacheKey fixModel fixCtx (fixOpts & #headers .~ Map.singleton "anthropic-beta" "output-128k-2025-02-19")+ ),+ testCase "compat shim changes the key" $+ assertBool+ "compat request-shaping flags must be part of the key"+ ( cacheKey fixModel fixCtx fixOpts+ /= cacheKey (fixModel & #compat .~ CompatAnthropicMessages defaultAnthropicMessagesCompat) fixCtx fixOpts+ ),+ testCase "message timestamps do not affect the key" $ do+ let t1 = read "2026-01-01 00:00:00 UTC" :: UTCTime+ t2 = read "2026-06-30 12:34:56 UTC" :: UTCTime+ ctxAt t = fixCtx & #messages .~ V.singleton (userAt t "ping")+ cacheKey fixModel (ctxAt t1) fixOpts @?= cacheKey fixModel (ctxAt t2) fixOpts+ cacheKey fixModel fixCtx fixOpts @?= cacheKey fixModel (ctxAt t1) fixOpts,+ testCase "stripMessageTimestamps removes only the payload-level timestamp" $ do+ let msg inner =+ object+ [ "tag" .= ("UserMessage" :: T.Text),+ "contents"+ .= object+ [ "timestamp" .= ("2026-01-01T00:00:00Z" :: T.Text),+ "content" .= [object ["args" .= object ["timestamp" .= inner]]]+ ]+ ]+ stripped x = stripMessageTimestamps (toJSON [msg (x :: T.Text)])+ assertBool "nested timestamps still discriminate" (stripped "a" /= stripped "b")+ stripped "a" @?= stripped "a" ] memoryTests :: TestTree@@ -195,9 +258,44 @@ (wc, _, werr) <- runPhase "write" assertBool ("write phase failed: " <> werr) (wc == ExitSuccess) (rc, _, rerr) <- runPhase "read"- assertBool ("read phase did not find the on-disk entry: " <> rerr) (rc == ExitSuccess)+ assertBool ("read phase did not find the on-disk entry: " <> rerr) (rc == ExitSuccess),+ testCase "a corrupt row decodes as a MISS" $+ withSystemTempDirectory "shikumi-sqlite" $ \dir -> do+ let file = dir </> "corrupt.db"+ got <- withSQLiteCache file $ \c -> do+ insertRawSQLiteRow file restartKey "not json"+ runEff . runCacheSQLite c $ lookupCache restartKey+ got @?= Nothing,+ testCase "a dropped table degrades lookup/store to MISS / no-op" $+ withSystemTempDirectory "shikumi-sqlite" $ \dir -> do+ let file = dir </> "dropped.db"+ withSQLiteCache file $ \c -> do+ withRawSQLite file (`SQL.exec` "DROP TABLE shikumi_cache;")+ got <- runEff . runCacheSQLite c $ lookupCache restartKey+ got @?= Nothing+ runEff . runCacheSQLite c $ storeCache restartKey restartEntry ] +withRawSQLite :: FilePath -> (Database -> IO a) -> IO a+withRawSQLite file = bracket (SQL.open (T.pack file)) SQL.close++insertRawSQLiteRow :: FilePath -> CacheKey -> T.Text -> IO ()+insertRawSQLiteRow file key rawValue =+ withRawSQLite file $ \db ->+ bracket+ ( SQL.prepare+ db+ """+ INSERT OR REPLACE INTO shikumi_cache (key, value, stored_at)+ VALUES (?, ?, ?);+ """+ )+ SQL.finalize+ $ \stmt -> do+ SQL.bind stmt [SQLText (unCacheKey key), SQLText rawValue, SQLText "2026-06-08T00:00:00Z"]+ res <- SQL.step stmt+ res @?= Done+ memoizeTests :: TestTree memoizeTests = testGroup@@ -221,7 +319,32 @@ _ <- complete fixModel fixCtx fixOpts complete fixModel fixCtx (fixOpts & #temperature .~ Just 0.7) n <- readIORef ref- n @?= 2+ n @?= 2,+ testCase "an in-band error response is not memoized" $ do+ tv <- newMemoryCache+ ref <- newIORef 0+ let errResp = stubResponse & #message . #stopReason .~ ErrorReason+ _ <-+ runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref errResp . cachedLLM $ do+ _ <- complete fixModel fixCtx fixOpts+ complete fixModel fixCtx fixOpts+ n <- readIORef ref+ n @?= 2,+ testCase "an entry older than entryTTL is a MISS; the refetched entry is a HIT" $ do+ tv <- newMemoryCache+ ref <- newIORef 0+ let key = cacheKey fixModel fixCtx fixOpts+ cfg = defaultCacheConfig {entryTTL = Just 3600}+ runEff . runConcurrent . runCacheMemory tv $+ storeCache key (CachedResponse stubResponse someTime currentKeyVersion)+ _ <-+ runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref stubResponse . cachedLLMWith cfg $+ complete fixModel fixCtx fixOpts+ _ <-+ runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref stubResponse . cachedLLMWith cfg $+ complete fixModel fixCtx fixOpts+ n <- readIORef ref+ n @?= 1 ] versioningTests :: TestTree@@ -246,8 +369,8 @@ n @?= 1, testCase "bumping the namespace version changes the hashed bytes" $ assertBool- "v1 and v2 canonical serializations must differ"- ( canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v1" fixModel fixCtx fixOpts)- /= canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v2" fixModel fixCtx fixOpts)+ "current and next-version canonical serializations must differ"+ ( canonicalJSON (requestToCanonicalValueVersioned currentKeyVersion fixModel fixCtx fixOpts)+ /= canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v3" fixModel fixCtx fixOpts) ) ]