summarize's cache gate is derived from Ctx.CacheTTLMs, which apply.cacheTTL computes. That function is exact for the Anthropic family — the request itself declares the tier — and a guess for everything else. The guess is wrong for OpenAI-shaped backends in both cache modes, in opposite directions, with no setting in between.
Under cache_mode: auto
cacheAware is false, so CacheTTLMs and MaxCachedIdx are never set. CachePhase returns Unknown, and CacheAllows permits Unknown — which is correct and deliberate for a backend with no prompt cache, but OpenAI has an automatic prefix cache we are not modelling. So the gate permits compaction on every turn while silently discarding a real cache the provider maintains on its own.
(Note: CacheAllows now refuses Unknown when MaxCachedIdx >= 0, added in #234. It does not help here, because on this path MaxCachedIdx is never set either.)
Under cache_mode: on
apply.cacheTTL's default: arm returns extendedTTL — one hour — on the documented reasoning that an OpenAI-shaped cache "clears after minutes of inactivity and always within the hour". As an upper bound on lifetime that is the safe direction for a cold claim. As the input to a pre-expiry window it is the unsafe one: remaining = 3600s − idle stays far above the 60s window for essentially every turn, so CachePhasePreExpiry is unreachable and the gate never permits. The component is effectively disabled.
So the two modes are "compacts every turn, ignoring a real cache" and "never compacts at all", and there is no third setting.
A second hardcoded five minutes
dash/event.go:868 carries its own providerCacheTTLMs = 5 * 60 * 1000, independent of apply.cacheTTL. That is a third reader of a fact this repo has already had trouble keeping in one place — cachephase.go's own comment says it exists so there is "one fact, one reader" — and it is on the dashboard side, so a divergence shows up as the panel and the gate disagreeing about the same turn.
Explicitly fine
Bedrock and Vertex-fronted Claude are correct. Both are in cacheTTL's Anthropic-family switch, and dispatch is on the provider argument rather than on the model string, so an aws/ prefix never reaches the default: arm. A review verified this specifically, and it is worth recording because the model-id prefix makes it look as though it might not be.
What a fix probably needs
- A per-provider TTL and a statement of whether that TTL is derived or assumed, so
CachePhase can decline to classify rather than classify on a guess — the same measured/assumed split modelinfo.Exact already makes for context windows, and for the same reason.
- A decision about OpenAI's automatic cache specifically: either model it (a real TTL, a real boundary) or state that
summarize's cache gate is Anthropic-family-only and have the component say so rather than behaving differently in each mode.
dash/event.go's constant reading from wherever the first bullet puts the fact.
Scope
Pre-existing. apply.cacheTTL and its default: arm are older than #234; that PR is what made the value load-bearing for a firing decision rather than only for a cold/warm label, which is how the asymmetry became visible. Its own branch off main.
Found by the review on #234.
summarize's cache gate is derived fromCtx.CacheTTLMs, whichapply.cacheTTLcomputes. That function is exact for the Anthropic family — the request itself declares the tier — and a guess for everything else. The guess is wrong for OpenAI-shaped backends in both cache modes, in opposite directions, with no setting in between.Under
cache_mode: autocacheAwareis false, soCacheTTLMsandMaxCachedIdxare never set.CachePhasereturnsUnknown, andCacheAllowspermits Unknown — which is correct and deliberate for a backend with no prompt cache, but OpenAI has an automatic prefix cache we are not modelling. So the gate permits compaction on every turn while silently discarding a real cache the provider maintains on its own.(Note:
CacheAllowsnow refuses Unknown whenMaxCachedIdx >= 0, added in #234. It does not help here, because on this pathMaxCachedIdxis never set either.)Under
cache_mode: onapply.cacheTTL'sdefault:arm returnsextendedTTL— one hour — on the documented reasoning that an OpenAI-shaped cache "clears after minutes of inactivity and always within the hour". As an upper bound on lifetime that is the safe direction for a cold claim. As the input to a pre-expiry window it is the unsafe one:remaining = 3600s − idlestays far above the 60s window for essentially every turn, soCachePhasePreExpiryis unreachable and the gate never permits. The component is effectively disabled.So the two modes are "compacts every turn, ignoring a real cache" and "never compacts at all", and there is no third setting.
A second hardcoded five minutes
dash/event.go:868carries its ownproviderCacheTTLMs = 5 * 60 * 1000, independent ofapply.cacheTTL. That is a third reader of a fact this repo has already had trouble keeping in one place —cachephase.go's own comment says it exists so there is "one fact, one reader" — and it is on the dashboard side, so a divergence shows up as the panel and the gate disagreeing about the same turn.Explicitly fine
Bedrock and Vertex-fronted Claude are correct. Both are in
cacheTTL's Anthropic-family switch, and dispatch is on the provider argument rather than on the model string, so anaws/prefix never reaches thedefault:arm. A review verified this specifically, and it is worth recording because the model-id prefix makes it look as though it might not be.What a fix probably needs
CachePhasecan decline to classify rather than classify on a guess — the same measured/assumed splitmodelinfo.Exactalready makes for context windows, and for the same reason.summarize's cache gate is Anthropic-family-only and have the component say so rather than behaving differently in each mode.dash/event.go's constant reading from wherever the first bullet puts the fact.Scope
Pre-existing.
apply.cacheTTLand itsdefault:arm are older than #234; that PR is what made the value load-bearing for a firing decision rather than only for a cold/warm label, which is how the asymmetry became visible. Its own branch offmain.Found by the review on #234.