13 KiB
Prompt Cache Control Implementation Plan
Scope
This plan implements the feature defined in docs/roadmap/cache.md.
The implementation target is message-level Anthropic/OpenRouter-style cache control for rendered prompt messages, plus cache usage observability. Do not implement deferred roadmap items such as content_blocks, top-level cache_control, session_id, or general extra_params serialization.
Follow the policy documents under docs/policy/ while implementing:
- Keep prompt/profile/HTTP/config decoding strict.
- Keep adapters thin.
- Keep provider request serialization in
internal/llm. - Keep prompt loading in
internal/promptdef. - Update canonical non-roadmap docs only when the behavior is implemented.
- Do not accept or emit raw secret values.
Stage 1: Domain Types And Prompt Loader
Objective: make prompt YAML accept and validate optional messages[].cache_control, and carry normalized cache-control metadata in domain prompt definitions.
Files to update:
internal/domain/domain.gointernal/promptdef/filesystem_repository.gointernal/promptdef/repository_test.gointernal/promptdef/testdata/
Domain decisions:
- Add a typed cache-control value in
internal/domain. - Use a pointer on message structs so absence is distinguishable from an empty object.
- Use
omitemptyon JSON tags for the new field so existing no-cache prompt and render hashes do not gainnullfields.
Use this domain shape:
type CacheControlType string
const (
CacheControlEphemeral CacheControlType = "ephemeral"
)
type CacheControl struct {
Type CacheControlType `yaml:"type" json:"type"`
TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"`
}
Add cache control to:
type PromptMessageTemplate struct {
Role string `yaml:"role"`
Content string `yaml:"content"`
ContentFile string `yaml:"content_file"`
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
}
Prompt loader changes:
- Add this field to
promptMessageFile:
CacheControl *cacheControlFile `yaml:"cache_control"`
- Add a private
cacheControlFilestruct withtypeandttl. - In
normalizePromptDefinition, validatecache_controlper message. - Trim whitespace from
typeandttl. - Reject present
cache_controlwhentypeis empty. - Reject any
typeother thanephemeral. - Accept omitted or empty
ttl. - Reject any non-empty
ttlother than1h. - Copy normalized values into
domain.PromptMessageTemplate.CacheControl. - Preserve the existing rule that each message must set exactly one of
contentorcontent_file.
Testing requirements:
- Add a valid fixture with
cache_control: {type: ephemeral, ttl: 1h}. - Add a valid fixture with
cache_control: {type: ephemeral}. - Add invalid fixtures for:
- empty
cache_control.type - unsupported
cache_control.type - unsupported
cache_control.ttl - unknown field under
cache_control
- empty
- Assert valid fixtures load with the expected
CacheControlvalues. - Assert invalid fixtures fail with
promptdef.ErrInvalidPromptDefinitionorpromptdef.ErrInvalidYAMLas appropriate. - Re-run:
go test ./internal/promptdef
Do not update renderer, formatter, LLM serialization, adapters, or user-facing docs in this stage.
Stage 2: Rendering, Prepared Output, And Render Hashing
Objective: preserve cache-control metadata through rendering, expose it in prepared-run output, and make rendered_prompt_hash reflect outbound-affecting cache metadata.
Files to update:
internal/domain/domain.gointernal/prompt/go_renderer.gointernal/prompt/renderer_test.gointernal/usecase/runner.gointernal/usecase/runner_test.gointernal/domain/prepared_run_test.gointernal/format/prepared_run.gointernal/format/prepared_run_test.go
Domain changes:
- Add cache control to rendered messages:
type RenderedMessage struct {
Role string `json:"role"`
Content string `json:"content"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
Renderer changes:
- When a
PromptMessageTemplatehasCacheControl, copy it to the returnedRenderedMessage. - Do not mutate the template's pointer in place.
- Add and use a small helper such as
cloneCacheControlto avoid aliasing. - Do not change template execution behavior for
{{input "name"}}or{{.var}}.
Rendered hash decision:
rendered_prompt_hashmust change when cache-control metadata changes, because the outbound provider request changes.- Preserve existing hashes for no-cache prompts by keeping the current role/content hash flow.
- Append normalized cache-control metadata only when present, for example:
cache_control.type=<type>cache_control.ttl=<ttl>only when non-empty
- Include cache-control fields in a stable order.
Prepared-run formatting:
- JSON prepared-run output should include
cache_controlon messages only when present. - Text prepared-run output should show cache-control metadata under the specific message, before
content, for example:
cache_control: ephemeral ttl=1h
content: |
- If
ttlis empty, omit thettl=...suffix. - Do not print cache-control lines for messages without cache control.
Testing requirements:
- Renderer test: cache control is copied to rendered messages.
- Renderer test: changing returned rendered cache control does not mutate the source template.
- Runner test:
rendered_prompt_hashchanges when cache control differs and remains stable for identical rendered messages. - Format JSON test:
cache_controlappears only when present. - Format text test: cache control appears under the correct message.
- Existing render/format tests still pass.
- Re-run:
go test ./internal/prompt ./internal/usecase ./internal/domain ./internal/format
Do not update outbound LLM serialization or adapter response surfaces in this stage.
Stage 3: Outbound Chat Serialization And Usage Parsing
Objective: serialize cache-controlled messages as one-item text content-block arrays and parse cache usage fields from compatible provider responses.
Files to update:
internal/llm/openai_compatible_client.gointernal/llm/openai_compatible_client_test.gointernal/domain/domain.go
Request serialization decisions:
- Keep string content for messages without cache control.
- Use content blocks only when
RenderedMessage.CacheControlis non-nil. - Do not introduce
content_blocksprompt syntax. - Do not serialize top-level
cache_control, top-levelsession_id, orextra_params. - Split request and response wire message structs. The current
openAIChatMessageis used in both request and response paths; changing request content toanyshould not make response decoding less strict than necessary.
Use request wire types equivalent to:
type openAIChatRequestMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type openAIChatTextContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
CacheControl *openAICacheControl `json:"cache_control,omitempty"`
}
type openAICacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
When cache control is present, content should serialize as:
[
{
"type": "text",
"text": "rendered text",
"cache_control": {
"type": "ephemeral",
"ttl": "1h"
}
}
]
When ttl is empty, omit ttl.
Usage parsing decisions:
- Extend
domain.TokenUsage:
type TokenUsage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CachedTokens int
CacheWriteTokens int
}
- Parse
usage.prompt_tokens_details.cached_tokensintoCachedTokens. - Parse
usage.cache_write_tokensintoCacheWriteTokens. - Treat absent cache fields as zero.
- Do not fail response decoding if cache fields are absent.
- If a provider later returns a different cache-write field, add it in a later change after observing a real response.
Testing requirements:
- Existing request serialization test still sees string message content for no-cache messages.
- New request serialization test sees content-block array for cache-controlled messages.
- New request serialization test verifies empty
ttlis omitted. - Response parsing test verifies cached and cache-write tokens are mapped.
- Response parsing test verifies absent cache usage fields remain zero.
- Existing structured-output and service-tier behavior remains unchanged.
- Re-run:
go test ./internal/llm
Do not update CLI or HTTP observability in this stage unless required by compilation after extending TokenUsage.
Stage 4: CLI And HTTP Cache Usage Observability
Objective: surface parsed cache usage through existing CLI and HTTP result paths without adding request knobs.
Files to update:
internal/adapter/cli/run.gointernal/adapter/cli/run_test.gointernal/adapter/http/dto.gointernal/adapter/http/handler.gointernal/adapter/http/handler_test.go
CLI decisions:
- Do not add CLI flags.
- Keep success summaries concise.
- Preserve the existing summary output when cache usage is zero.
- When either cache field is non-zero, append:
cached_tokens=<n> cache_write_tokens=<n>
HTTP decisions:
- Do not add request fields.
- Add numeric fields to
metadata.usage:cached_tokenscache_write_tokens
- Always include these fields as numbers, matching the current non-omitempty numeric usage fields.
- Continue omitting raw model output unless
include_raw_outputis true.
Testing requirements:
- CLI summary omits cache fields when both are zero.
- CLI summary includes cache fields when either is non-zero.
- HTTP success response includes
metadata.usage.cached_tokens. - HTTP success response includes
metadata.usage.cache_write_tokens. - Existing HTTP request mapping is unchanged.
- Re-run:
go test ./internal/adapter/cli ./internal/adapter/http
Stage 5: Canonical Documentation
Objective: update implemented-behavior docs after the feature works and tests pass.
Files to update:
docs/config.mddocs/integrations/openai-compatible-chat.mddocs/cli.mddocs/troubleshooting.mddocs/internal/runner.mddocs/internal/adapters.md, if adapter response behavior needs an explicit notedocs/roadmap/cache.md, only if the accepted target changes during implementation
Documentation requirements:
- Keep all unimplemented ideas under
docs/roadmap/. - Document
messages[].cache_controlin the prompt definition reference indocs/config.md. - Document the outbound message serialization rule in
docs/integrations/openai-compatible-chat.md. - Document parsed cache usage fields in
docs/integrations/openai-compatible-chat.md. - In
docs/cli.md, note that prompt cache control is configured in prompt YAML, not with CLI flags. - In
docs/troubleshooting.md, add cache miss guidance:- stable context should appear before the cache breakpoint
- dynamic input before the breakpoint changes the cache key
- providers may impose minimum token thresholds and breakpoint limits
- use cached/cache-write token counts to verify behavior
- Do not claim support for deferred roadmap items.
Testing and verification:
go test ./...
If documentation examples include new YAML snippets, ensure they match the strict prompt loader schema.
Stage 6: Final Verification
Objective: verify the whole change set end to end.
Required commands:
go test ./...
go build ./cmd/scriptorium
Recommended smoke checks:
go run ./cmd/scriptorium render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json
If adding a new example prompt with cache_control, also smoke-render that prompt with --format json and inspect that the rendered message includes cache_control.
Final review checklist:
- Existing prompt definitions without
cache_controlstill load. - Prompt definitions with
cache_controlrender successfully. - Unknown cache-control YAML fields are rejected.
- No-cache outbound messages still serialize with string
content. - Cache-controlled outbound messages serialize with text content-block arrays.
rendered_prompt_hashchanges when cache-control metadata changes.- Cache usage fields are zero when providers omit them.
- CLI and HTTP request surfaces have no new knobs.
- CLI and HTTP response surfaces expose cache usage.
- Non-roadmap docs describe only implemented behavior.
docs/roadmap/cache.mdstill defines target state and deferred work, not task steps.
Stage Boundaries
Stages 1 through 4 can be implemented in separate prompts if needed. Stage 5 should happen only after behavior is implemented. Stage 6 should be performed after all implementation and documentation changes are complete.
If a stage fails because prior stage changes were not implemented, stop and complete the earlier stage first rather than papering over missing domain fields or DTO mappings.
Open Questions
None required for the initial implementation.
The plan chooses message-level cache_control, string content for legacy messages, text-block array content only when cache control is present, and no new CLI/HTTP request knobs. These choices match docs/roadmap/cache.md and preserve existing prompt-definition compatibility.