Skip to content

Commit 9f5d8ed

Browse files
test: guarantee cache config isolation across providers
1 parent 861cad6 commit 9f5d8ed

12 files changed

Lines changed: 342 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Supported values for `warnings.contextSize` are `auto`, `off`, `result-only`, `c
136136
- **Folder defaults**`defaults.md` inheritance for shared provider, model, metadata, and system instructions
137137
- **Overrides** — Environment and tier-based overrides (base → env → tier → runtime)
138138
- **4 provider adapters** — OpenAI, Anthropic, Gemini, OpenRouter — body-only output
139+
- **Provider-aware input caching controls** — optional `cache` front matter maps to OpenAI prompt cache hints, Anthropic `cache_control`, and Gemini `cachedContent`
139140
- **Validation** — Zod schema validation, Levenshtein-based "did you mean?" for typos, variable usage checks
140141
- **Context hardening** — structured regexes with flags, `/pattern/i` convenience syntax, and built-in `non_empty` / `reject_secrets` validators
141142
- **Optional short-circuit messages** — validators can return a structured `returnMessage` instead of throwing when configured

docs/prompt-format.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Supported default fields:
5454

5555
- `provider` (front matter) — default provider for the folder
5656
- `model` (front matter) — default model for the folder
57+
- `cache` (front matter) — default provider-specific caching hints
5758
- `metadata` (front matter) — merged with prompt-local metadata
5859
- `# System instructions` (body section) — used when the prompt has none
5960

@@ -75,6 +76,10 @@ prompts/
7576
---
7677
provider: openai
7778
model: gpt-5.4
79+
cache:
80+
openai:
81+
prompt_cache_key: support-v1
82+
retention: in_memory
7883
metadata:
7984
owner: platform
8085
review_required: true
@@ -101,10 +106,32 @@ Use support tone and escalation policy.
101106
`prompts/support/reply.md` (no local `metadata.owner` and no local system section) will use:
102107
- `provider: openai` (inherited from root defaults)
103108
- `model: gpt-5.4` (inherited from root defaults)
109+
- `cache.openai.prompt_cache_key: support-v1` (inherited from root defaults)
104110
- `metadata.owner: support` (nearest override)
105111
- `metadata.review_required: true` (inherited from parent defaults)
106112
- system instructions from `support/defaults.md`
107113

114+
## Caching configuration
115+
116+
Use the optional `cache` front matter block to pass vendor-specific caching hints:
117+
118+
```yaml
119+
cache:
120+
openai:
121+
prompt_cache_key: support-v2
122+
retention: 24h
123+
anthropic:
124+
mode: automatic
125+
ttl: 5m
126+
gemini:
127+
cached_content: cachedContents/1234567890
128+
```
129+
130+
- `openai.prompt_cache_key` and `openai.retention` map to OpenAI prompt caching fields.
131+
- `anthropic.mode: automatic` sets top-level `cache_control`; `explicit` applies block-level cache controls to configured sections/tools.
132+
- `gemini.cached_content` (or `google.cached_content`) maps to `cachedContent` for requests that reuse a previously created Gemini cache.
133+
- You can safely include multiple provider blocks in the same prompt. Each adapter only reads its own block (`openai`, `anthropic`, or `gemini`/`google`) and ignores the others.
134+
108135
## Sections
109136

110137
The Markdown body is split on **H1 headings** into named sections. Three section names are recognized (case-insensitive):

docs/providers.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const { request } = result;
3636
```
3737

3838
The provider passed to `renderPrompt` determines which adapter shapes the body. The `provider` field in front matter is informational — the render-time provider controls output.
39+
When a prompt includes multiple cache blocks (for example `cache.openai` + `cache.anthropic`), adapters ignore non-matching blocks so cross-provider settings never leak into the wrong payload.
3940

4041
## Direct adapter imports
4142

@@ -208,10 +209,17 @@ Field mapping:
208209
| `reasoning.effort` | `reasoning_effort` |
209210
| `response.format: json` | `response_format: { type: "json_object" }` |
210211
| `response.stream` | `stream` |
212+
| `cache.openai.prompt_cache_key` | `prompt_cache_key` |
213+
| `cache.openai.retention` | `prompt_cache_retention` |
211214

212215
Warnings:
213216
- `reasoning.budget_tokens` is ignored (OpenAI uses `reasoning_effort` instead)
214217

218+
Caching notes:
219+
- Prompt caching is already automatic for eligible OpenAI requests.
220+
- `cache.openai.prompt_cache_key` helps route similar prefixes together.
221+
- `cache.openai.retention` can be `in_memory` (default) or `24h`.
222+
215223
## Anthropic
216224

217225
Body shape: [Messages API](https://docs.anthropic.com/en/api/messages)
@@ -233,6 +241,9 @@ Key differences from OpenAI:
233241
- `max_tokens` is **required** — defaults to `4096` if `sampling.max_output_tokens` is not set.
234242
- `sampling.stop` maps to `stop_sequences`.
235243
- `reasoning.budget_tokens` maps to `thinking: { type: "enabled", budget_tokens }`.
244+
- `cache.anthropic.mode: automatic` maps to top-level `cache_control`.
245+
- `cache.anthropic.mode: explicit` applies `cache_control` at block level for selected sections/tools.
246+
- `cache.anthropic.ttl` supports `5m` (default) or `1h`.
236247

237248
Warnings:
238249
- `frequency_penalty` and `presence_penalty` are not supported — ignored with a warning.
@@ -266,6 +277,7 @@ Key differences:
266277
- `top_p` maps to `topP`, `max_output_tokens` maps to `maxOutputTokens`, `stop` maps to `stopSequences`.
267278
- `response.format: json` maps to `generationConfig.responseMimeType: "application/json"`.
268279
- `reasoning.effort` maps to `thinkingConfig.thinkingBudget` (high=8192, medium=4096, low=1024).
280+
- `cache.gemini.cached_content` (or `cache.google.cached_content`) maps to top-level `cachedContent`.
269281

270282
Warnings:
271283
- `frequency_penalty` and `presence_penalty` are not supported — ignored with a warning.

docs/schema.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Prompt files use YAML front matter. This page documents every supported field.
1515
| `reasoning` | `object` | No | Reasoning/thinking configuration |
1616
| `sampling` | `object` | No | Sampling parameters |
1717
| `response` | `object` | No | Response format and streaming |
18+
| `cache` | `object` | No | Provider-specific prompt/context caching options |
1819
| `tools` | `array` | No | Tool references (strings or inline definitions) |
1920
| `mcp` | `object` | No | MCP server references |
2021
| `context` | `object` | No | Declare expected variables and history settings |
@@ -31,6 +32,7 @@ Prompt files use YAML front matter. This page documents every supported field.
3132
|-------|------|-------------|
3233
| `provider` | `enum` | Default provider (`openai`, `anthropic`, `google`, `gemini`, `openrouter`, `any`) |
3334
| `model` | `string` | Default model identifier |
35+
| `cache` | `object` | Same as prompt-level `cache` block |
3436
| `metadata` | `object` | Same as the prompt `metadata` block (`owner`, `tags`, `review_required`, `stable`) |
3537
| `# System instructions` | section | System instructions inherited by prompts in this folder |
3638

@@ -114,6 +116,37 @@ Inline tool definition fields:
114116
| `description` | `string` | No | Tool description |
115117
| `input_schema` | `object` | No | JSON Schema for tool input |
116118

119+
## `cache`
120+
121+
```yaml
122+
cache:
123+
openai:
124+
prompt_cache_key: support-v1
125+
retention: in_memory # in_memory | 24h
126+
anthropic:
127+
mode: automatic # automatic | explicit
128+
ttl: 5m # 5m | 1h
129+
cache_system_instructions: true
130+
cache_tools: true
131+
cache_prompt_template: false
132+
gemini:
133+
cached_content: cachedContents/1234567890
134+
```
135+
136+
| Field | Type | Description |
137+
|-------|------|-------------|
138+
| `openai.prompt_cache_key` | `string` | Optional routing key to improve cache-hit locality on shared prefixes |
139+
| `openai.retention` | `'in_memory' \| '24h'` | Prompt cache retention policy |
140+
| `anthropic.mode` | `'automatic' \| 'explicit'` | Automatic top-level caching or explicit block-level cache breakpoints |
141+
| `anthropic.type` | `'ephemeral'` | Cache type (currently only `ephemeral`) |
142+
| `anthropic.ttl` | `'5m' \| '1h'` | Anthropic cache duration |
143+
| `anthropic.cache_system_instructions` | `boolean` | In explicit mode, cache system instructions block |
144+
| `anthropic.cache_tools` | `boolean` | In explicit mode, cache tool declarations |
145+
| `anthropic.cache_prompt_template` | `boolean` | In explicit mode, cache prompt-template user block |
146+
| `gemini.cached_content` / `google.cached_content` | `string` | Previously created Gemini cache resource name used as `cachedContent` |
147+
148+
You can define multiple provider cache blocks in one prompt; each adapter reads only its own cache settings.
149+
117150
## `mcp`
118151

119152
```yaml
@@ -190,7 +223,7 @@ tiers:
190223
model: gpt-5.4
191224
```
192225

193-
Each environment/tier key maps to an overrides object. Overridable fields: `model`, `fallback_models`, `reasoning`, `sampling`, `response`, `tools`. See [Overrides](./overrides.md).
226+
Each environment/tier key maps to an overrides object. Overridable fields: `model`, `fallback_models`, `reasoning`, `sampling`, `response`, `cache`, `tools`. See [Overrides](./overrides.md).
194227

195228
## `metadata`
196229

src/cli/commands/init.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ context:
2222
- app_context
2323
includes:
2424
- ./shared/tone.md
25+
cache:
26+
openai:
27+
# Keep this stable across requests that share a long static prefix.
28+
prompt_cache_key: hello-v1
29+
retention: in_memory
2530
reasoning:
2631
effort: high
2732
environments:

src/providers/anthropic.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({
4646
});
4747

4848
const messages: Array<Record<string, unknown>> = [];
49+
const anthropicCacheConfig = resolvedAsset.cache?.anthropic;
50+
const cacheType = anthropicCacheConfig?.type ?? 'ephemeral';
51+
const cacheControl = anthropicCacheConfig
52+
? {
53+
type: cacheType,
54+
...(anthropicCacheConfig.ttl ? { ttl: anthropicCacheConfig.ttl } : {}),
55+
}
56+
: undefined;
57+
const cacheMode = anthropicCacheConfig?.mode ?? 'automatic';
4958

5059
// History
5160
if (runtime.history) {
@@ -56,7 +65,14 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({
5665

5766
// User message (prompt template)
5867
if (sections.prompt_template) {
59-
messages.push({ role: 'user', content: sections.prompt_template });
68+
if (cacheControl && cacheMode === 'explicit' && anthropicCacheConfig?.cache_prompt_template) {
69+
messages.push({
70+
role: 'user',
71+
content: [{ type: 'text', text: sections.prompt_template, cache_control: cacheControl }],
72+
});
73+
} else {
74+
messages.push({ role: 'user', content: sections.prompt_template });
75+
}
6076
}
6177

6278
const body: Record<string, unknown> = {
@@ -66,7 +82,11 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({
6682

6783
// System goes as top-level field in Anthropic
6884
if (sections.system_instructions) {
69-
body.system = sections.system_instructions;
85+
if (cacheControl && cacheMode === 'explicit' && anthropicCacheConfig?.cache_system_instructions !== false) {
86+
body.system = [{ type: 'text', text: sections.system_instructions, cache_control: cacheControl }];
87+
} else {
88+
body.system = sections.system_instructions;
89+
}
7090
}
7191

7292
// Sampling params
@@ -93,18 +113,35 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({
93113
body.stream = resolvedAsset.response.stream;
94114
}
95115

116+
if (cacheControl && cacheMode === 'automatic') {
117+
body.cache_control = cacheControl;
118+
}
119+
96120
// Tools
97121
if (resolvedAsset.tools && resolvedAsset.tools.length > 0) {
98122
body.tools = resolvedAsset.tools.map((tool) => {
99123
if (typeof tool === 'string') {
100124
const def = runtime.toolRegistry?.[tool];
101-
if (def) return def;
102-
return { name: tool };
125+
if (def) {
126+
if (cacheControl && cacheMode === 'explicit' && anthropicCacheConfig?.cache_tools) {
127+
return { ...(def as Record<string, unknown>), cache_control: cacheControl };
128+
}
129+
return def;
130+
}
131+
return {
132+
name: tool,
133+
...(cacheControl && cacheMode === 'explicit' && anthropicCacheConfig?.cache_tools
134+
? { cache_control: cacheControl }
135+
: {}),
136+
};
103137
}
104138
return {
105139
name: tool.name,
106140
description: tool.description,
107141
input_schema: tool.input_schema ?? { type: 'object', properties: {} },
142+
...(cacheControl && cacheMode === 'explicit' && anthropicCacheConfig?.cache_tools
143+
? { cache_control: cacheControl }
144+
: {}),
108145
};
109146
});
110147
}

src/providers/gemini.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({
2020
const resolvedAsset = resolveAssetForProvider(asset, runtime);
2121
const errors: string[] = [];
2222
const warnings: string[] = [];
23+
const geminiCache = resolvedAsset.cache?.gemini?.cached_content;
24+
const googleCache = resolvedAsset.cache?.google?.cached_content;
2325

2426
if (!resolvedAsset.model) {
2527
errors.push('Gemini adapter requires a model to be specified.');
@@ -31,6 +33,9 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({
3133
if (resolvedAsset.sampling?.presence_penalty !== undefined) {
3234
warnings.push('Gemini does not support presence_penalty. It will be ignored.');
3335
}
36+
if (geminiCache && googleCache && geminiCache !== googleCache) {
37+
warnings.push('Both cache.gemini.cached_content and cache.google.cached_content are set. Gemini uses cache.gemini.cached_content.');
38+
}
3439

3540
return { valid: errors.length === 0, errors, warnings };
3641
},
@@ -65,6 +70,7 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({
6570
const body: Record<string, unknown> = {
6671
contents,
6772
};
73+
const geminiCacheConfig = resolvedAsset.cache?.gemini ?? resolvedAsset.cache?.google;
6874

6975
// System instruction
7076
if (sections.system_instructions) {
@@ -96,6 +102,10 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({
96102
body.generationConfig = generationConfig;
97103
}
98104

105+
if (geminiCacheConfig?.cached_content) {
106+
body.cachedContent = geminiCacheConfig.cached_content;
107+
}
108+
99109
// Tools
100110
if (resolvedAsset.tools && resolvedAsset.tools.length > 0) {
101111
const functionDeclarations = resolvedAsset.tools.map((tool) => {

src/providers/openai.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const openaiAdapter: ProviderAdapter = withPromptInputSupport({
6262
model: resolvedAsset.model,
6363
messages,
6464
};
65+
const openaiCacheConfig = resolvedAsset.cache?.openai;
6566

6667
// Sampling params
6768
if (resolvedAsset.sampling?.temperature !== undefined) body.temperature = resolvedAsset.sampling.temperature;
@@ -86,6 +87,13 @@ export const openaiAdapter: ProviderAdapter = withPromptInputSupport({
8687
body.stream = resolvedAsset.response.stream;
8788
}
8889

90+
if (openaiCacheConfig?.prompt_cache_key) {
91+
body.prompt_cache_key = openaiCacheConfig.prompt_cache_key;
92+
}
93+
if (openaiCacheConfig?.retention) {
94+
body.prompt_cache_retention = openaiCacheConfig.retention;
95+
}
96+
8997
// Tools
9098
if (resolvedAsset.tools && resolvedAsset.tools.length > 0) {
9199
body.tools = resolvedAsset.tools.map((tool) => {

src/schema/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ export {
44
ReasoningSchema,
55
SamplingSchema,
66
ResponseSchema,
7+
CacheSchema,
8+
OpenAICacheSchema,
9+
AnthropicCacheSchema,
10+
GeminiCacheSchema,
711
ContextSchema,
812
ContextInputDefinitionSchema,
913
ContextInputDefinitionObjectSchema,

src/schema/schema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ export const ResponseSchema = z.object({
4949
stream: z.boolean().optional(),
5050
});
5151

52+
// --- Cache controls ---
53+
54+
export const OpenAICacheSchema = z.object({
55+
prompt_cache_key: z.string().min(1).optional(),
56+
retention: z.enum(['in_memory', '24h']).optional(),
57+
});
58+
59+
export const AnthropicCacheSchema = z.object({
60+
mode: z.enum(['automatic', 'explicit']).optional(),
61+
type: z.literal('ephemeral').optional(),
62+
ttl: z.enum(['5m', '1h']).optional(),
63+
cache_system_instructions: z.boolean().optional(),
64+
cache_tools: z.boolean().optional(),
65+
cache_prompt_template: z.boolean().optional(),
66+
});
67+
68+
export const GeminiCacheSchema = z.object({
69+
cached_content: z.string().min(1).optional(),
70+
});
71+
72+
export const CacheSchema = z.object({
73+
openai: OpenAICacheSchema.optional(),
74+
anthropic: AnthropicCacheSchema.optional(),
75+
gemini: GeminiCacheSchema.optional(),
76+
google: GeminiCacheSchema.optional(),
77+
});
78+
5279
// --- Context ---
5380

5481
export const HistorySchema = z.object({
@@ -118,6 +145,7 @@ export const PromptAssetOverridesSchema = z.object({
118145
reasoning: ReasoningSchema.optional(),
119146
sampling: SamplingSchema.optional(),
120147
response: ResponseSchema.optional(),
148+
cache: CacheSchema.optional(),
121149
tools: z.array(ToolRefSchema).optional(),
122150
});
123151

@@ -143,6 +171,7 @@ export const SectionsSchema = z.object({
143171
export const PromptDefaultsSchema = z.object({
144172
provider: z.enum(['openai', 'anthropic', 'google', 'gemini', 'openrouter', 'any']).optional(),
145173
model: z.string().optional(),
174+
cache: CacheSchema.optional(),
146175
metadata: MetadataSchema.optional(),
147176
sections: z.object({
148177
system_instructions: z.string().optional(),
@@ -165,6 +194,7 @@ export const PromptAssetSchema = z.object({
165194
reasoning: ReasoningSchema.optional(),
166195
sampling: SamplingSchema.optional(),
167196
response: ResponseSchema.optional(),
197+
cache: CacheSchema.optional(),
168198

169199
tools: z.array(ToolRefSchema).optional(),
170200
mcp: MCPSchema.optional(),

0 commit comments

Comments
 (0)