Skip to content

Commit 4b4faa4

Browse files
authored
feat(cloud-proxy): optional Anthropic prompt-cache breakpoints in translate mode (#11158)
The Anthropic translate provider builds the upstream request from scratch and never emitted cache_control, so prompt caching was impossible for OpenAI-format clients routed through cloud-proxy — even though the entire system prompt + tools prefix is re-sent on every agentic turn. Add an opt-in cache_prompt flag (ProxyOptions.cache_prompt; model YAML proxy.cache_prompt: true). On a translate+anthropic model, buildAnthropicRequest injects cache_control:{type:ephemeral} on the stable prefix — the system block, the last tool, and the last message block (at most 3 of Anthropic's 4 allowed breakpoints). Anthropic then serves the repeated prefix at the cache-read rate (0.1x input) on subsequent calls, cutting cost on multi-turn/agentic workloads. No effect in passthrough mode, for non-Anthropic providers, or when unset. System is widened to any so it can carry the block form required to attach cache_control, while still marshalling as a bare string when caching is off. Adds a unit test asserting exactly three breakpoints when on and none when off, and documents the option in docs/content/operations/cloud-proxy.md. Assisted-by: Claude:opus-4.8 Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
1 parent 0f7186f commit 4b4faa4

8 files changed

Lines changed: 187 additions & 7 deletions

File tree

backend/backend.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,12 @@ message ProxyOptions {
508508
string api_key_file = 5;
509509
string upstream_model = 6;
510510
int32 request_timeout_seconds = 7;
511+
// cache_prompt enables automatic Anthropic prompt-cache breakpoints
512+
// (cache_control: ephemeral) on the stable prefix — system, tools, and
513+
// the last message block — when translating to the Anthropic provider.
514+
// Cuts input cost on repeated/agentic calls (cache read = 0.1x). Only
515+
// meaningful for mode=translate + provider=anthropic; ignored otherwise.
516+
bool cache_prompt = 8;
511517
}
512518

513519
message Result {

backend/go/cloud-proxy/provider_anthropic.go

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import (
3232
type anthropicRequest struct {
3333
Model string `json:"model"`
3434
MaxTokens int32 `json:"max_tokens"`
35-
System string `json:"system,omitempty"`
35+
// System is `any`: a bare string normally, or []anthropicSystemBlock
36+
// when cache_prompt is on (the block form carries cache_control).
37+
System any `json:"system,omitempty"`
3638
Messages []anthropicMessage `json:"messages"`
3739
Stream bool `json:"stream,omitempty"`
3840
Temperature *float64 `json:"temperature,omitempty"`
@@ -52,9 +54,30 @@ type anthropicMessage struct {
5254
}
5355

5456
type anthropicTool struct {
55-
Name string `json:"name"`
56-
Description string `json:"description,omitempty"`
57-
InputSchema json.RawMessage `json:"input_schema"`
57+
Name string `json:"name"`
58+
Description string `json:"description,omitempty"`
59+
InputSchema json.RawMessage `json:"input_schema"`
60+
CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
61+
}
62+
63+
// anthropicCacheControl marks a prompt-cache breakpoint. Anthropic caches
64+
// everything up to and including a block tagged {"type":"ephemeral"} (5-min
65+
// TTL) and serves that prefix at the cache-read rate (0.1x input) on later
66+
// calls that share it — the win on agentic/multi-turn workloads.
67+
type anthropicCacheControl struct {
68+
Type string `json:"type"` // "ephemeral"
69+
}
70+
71+
// ephemeralCacheControl is the single reused breakpoint marker.
72+
var ephemeralCacheControl = &anthropicCacheControl{Type: "ephemeral"}
73+
74+
// anthropicSystemBlock is the block form of the top-level system field.
75+
// Anthropic accepts system as a bare string OR a list of text blocks; the
76+
// block form is required to attach cache_control to the system prompt.
77+
type anthropicSystemBlock struct {
78+
Type string `json:"type"` // "text"
79+
Text string `json:"text"`
80+
CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
5881
}
5982

6083
// anthropicToolChoice mirrors the four shapes Anthropic accepts:
@@ -81,8 +104,9 @@ type anthropicContentBlock struct {
81104
// Tool-result block fields. tool_result uses `content` (not
82105
// `text`) and pairs with `tool_use_id`; modelling them as
83106
// distinct fields avoids ambiguity at marshal time.
84-
ToolUseID string `json:"tool_use_id,omitempty"`
85-
ResultContent string `json:"content,omitempty"`
107+
ToolUseID string `json:"tool_use_id,omitempty"`
108+
ResultContent string `json:"content,omitempty"`
109+
CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
86110
}
87111

88112
type anthropicResponse struct {
@@ -156,6 +180,11 @@ func buildAnthropicRequest(opts *pb.PredictOptions, cfg *proxyConfig, stream boo
156180
if req.ToolChoice != nil && req.ToolChoice.Type == anthropicToolChoiceNone {
157181
req.Tools, req.ToolChoice = nil, nil
158182
}
183+
// Prompt-cache breakpoint on the last tool: Anthropic caches the entire
184+
// tool block up to the marked tool — usually a large, fully stable prefix.
185+
if cfg.cachePrompt && len(req.Tools) > 0 {
186+
req.Tools[len(req.Tools)-1].CacheControl = ephemeralCacheControl
187+
}
159188

160189
var systemParts []string
161190
for _, m := range opts.GetMessages() {
@@ -189,15 +218,54 @@ func buildAnthropicRequest(opts *pb.PredictOptions, cfg *proxyConfig, stream boo
189218
})
190219
}
191220
}
192-
req.System = strings.Join(systemParts, "\n\n")
221+
// System: block form (with cache_control) when caching is on, else the
222+
// bare string. Only set when non-empty so `omitempty` still drops it.
223+
if len(systemParts) > 0 {
224+
joined := strings.Join(systemParts, "\n\n")
225+
if cfg.cachePrompt {
226+
req.System = []anthropicSystemBlock{{Type: "text", Text: joined, CacheControl: ephemeralCacheControl}}
227+
} else {
228+
req.System = joined
229+
}
230+
}
193231

194232
if len(req.Messages) == 0 && opts.GetPrompt() != "" {
195233
req.Messages = []anthropicMessage{{Role: "user", Content: opts.GetPrompt()}}
196234
}
197235

236+
// Prompt-cache breakpoint on the final message block caches the whole
237+
// conversation prefix up to the newest turn. With the system + tools
238+
// breakpoints above, Anthropic serves the entire stable head at the
239+
// cache-read rate on the next agentic iteration (max 4 breakpoints; we
240+
// use at most 3, so we never exceed the limit).
241+
if cfg.cachePrompt {
242+
markLastMessageCacheable(req.Messages)
243+
}
244+
198245
return json.Marshal(req)
199246
}
200247

248+
// markLastMessageCacheable tags the final block of the last message with a
249+
// cache_control breakpoint. String content is promoted to a single text
250+
// block so the marker has somewhere to attach; block content gets the marker
251+
// on its last element.
252+
func markLastMessageCacheable(msgs []anthropicMessage) {
253+
if len(msgs) == 0 {
254+
return
255+
}
256+
last := &msgs[len(msgs)-1]
257+
switch c := last.Content.(type) {
258+
case string:
259+
if c != "" {
260+
last.Content = []anthropicContentBlock{{Type: "text", Text: c, CacheControl: ephemeralCacheControl}}
261+
}
262+
case []anthropicContentBlock:
263+
if len(c) > 0 {
264+
c[len(c)-1].CacheControl = ephemeralCacheControl
265+
}
266+
}
267+
}
268+
201269
// appendToolResult appends a tool_result block as a user message,
202270
// merging into a preceding user message that already carries blocks.
203271
// Anthropic concatenates consecutive same-role messages on its end,

backend/go/cloud-proxy/provider_anthropic_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,62 @@ func TestBuildAnthropic_RoundTripsAssistantToolCalls(t *testing.T) {
328328
g.Expect(r0["tool_use_id"]).To(Equal("call_abc"))
329329
g.Expect(r0["content"]).To(Equal(`{"models":["a","b"]}`))
330330
}
331+
332+
// TestPredict_Anthropic_PromptCache verifies that cache_prompt injects
333+
// exactly the intended cache_control breakpoints (system, last tool, last
334+
// message) when on, and none when off — asserting on the raw upstream body
335+
// because System becomes a block list that the typed struct hides.
336+
func TestPredict_Anthropic_PromptCache(t *testing.T) {
337+
g := NewWithT(t)
338+
339+
// run issues one translate Predict and returns the raw body the fake
340+
// Anthropic upstream received.
341+
run := func(cachePrompt bool) string {
342+
var rawBody string
343+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344+
b, _ := io.ReadAll(r.Body)
345+
rawBody = string(b)
346+
w.Header().Set("Content-Type", "application/json")
347+
_, _ = io.WriteString(w, `{"id":"m","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-3-5-sonnet-20241022","usage":{"input_tokens":5,"output_tokens":2}}`)
348+
}))
349+
defer srv.Close()
350+
351+
t.Setenv("CLOUD_PROXY_ANTHROPIC_FAKE", "sk-ant-fake")
352+
cp := NewCloudProxy()
353+
err := cp.Load(&pb.ModelOptions{
354+
Model: "claude-local",
355+
Proxy: &pb.ProxyOptions{
356+
UpstreamUrl: srv.URL,
357+
Mode: modeTranslate,
358+
Provider: providerAnthropic,
359+
ApiKeyEnv: "CLOUD_PROXY_ANTHROPIC_FAKE",
360+
UpstreamModel: "claude-3-5-sonnet-20241022",
361+
CachePrompt: cachePrompt,
362+
},
363+
})
364+
g.Expect(err).NotTo(HaveOccurred())
365+
366+
_, err = cp.Predict(&pb.PredictOptions{
367+
Messages: []*pb.Message{
368+
{Role: "system", Content: "be brief"},
369+
{Role: "user", Content: "hello"},
370+
},
371+
Tools: `[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}]`,
372+
Tokens: 32,
373+
})
374+
g.Expect(err).NotTo(HaveOccurred())
375+
return rawBody
376+
}
377+
378+
// cache_prompt ON: three ephemeral breakpoints (system + last tool +
379+
// last message), and system is emitted in block form.
380+
on := run(true)
381+
g.Expect(strings.Count(on, `"cache_control":{"type":"ephemeral"}`)).To(Equal(3),
382+
"expected 3 breakpoints (system, tool, last message); body=%s", on)
383+
g.Expect(on).To(ContainSubstring(`"system":[{"type":"text","text":"be brief"`))
384+
385+
// cache_prompt OFF: no breakpoints, system stays a bare string.
386+
off := run(false)
387+
g.Expect(off).NotTo(ContainSubstring("cache_control"))
388+
g.Expect(off).To(ContainSubstring(`"system":"be brief"`))
389+
}

backend/go/cloud-proxy/proxy.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type proxyConfig struct {
4848
upstreamModel string
4949
localModel string // ModelOptions.Model — fallback when upstream_model is unset
5050
apiKey string // resolved at Load time
51+
cachePrompt bool // inject Anthropic prompt-cache breakpoints (translate+anthropic)
5152
}
5253

5354
func NewCloudProxy() *CloudProxy {
@@ -106,6 +107,7 @@ func (c *CloudProxy) Load(opts *pb.ModelOptions) error {
106107
upstreamModel: po.GetUpstreamModel(),
107108
localModel: opts.GetModel(),
108109
apiKey: key,
110+
cachePrompt: po.GetCachePrompt(),
109111
})
110112
xlog.Info("cloud-proxy: ready",
111113
"upstream", po.GetUpstreamUrl(),

core/backend/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
475475
ApiKeyFile: c.Proxy.APIKeyFile,
476476
UpstreamModel: c.Proxy.UpstreamModel,
477477
RequestTimeoutSeconds: int32(c.Proxy.RequestTimeoutSeconds),
478+
CachePrompt: c.Proxy.CachePrompt,
478479
}
479480
}
480481

core/config/meta/registry.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
822822
Min: f64(0),
823823
Order: 213,
824824
},
825+
"proxy.cache_prompt": {
826+
Section: "proxy",
827+
Label: "Proxy Anthropic Prompt Cache",
828+
Description: "Inject Anthropic prompt-cache breakpoints (cache_control: ephemeral) on the stable prefix (system, tools, last message) when mode is translate and provider is anthropic. Serves the repeated prefix at the cache-read rate on multi-turn/agentic calls. No effect otherwise.",
829+
Component: "checkbox",
830+
Order: 214,
831+
},
825832

826833
// --- MITM intercept hosts ---
827834
// Each host listed here is claimed by this model config; the

core/config/model_config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ type ProxyConfig struct {
232232
// means no per-request timeout (only the request context, which
233233
// is bound to the client connection, applies).
234234
RequestTimeoutSeconds int `yaml:"request_timeout_seconds,omitempty" json:"request_timeout_seconds,omitempty"`
235+
236+
// CachePrompt enables automatic Anthropic prompt-cache breakpoints
237+
// (cache_control: ephemeral) on the stable prefix — system prompt,
238+
// tools, and the last message block — when mode=translate and
239+
// provider=anthropic. Anthropic then serves the repeated prefix at
240+
// the cache-read rate (0.1x input), which sharply cuts cost on
241+
// agentic/multi-turn workloads that re-send a large stable prefix.
242+
// No effect for passthrough mode or non-Anthropic providers.
243+
CachePrompt bool `yaml:"cache_prompt,omitempty" json:"cache_prompt,omitempty"`
235244
}
236245

237246
// Proxy mode names. Validate() normalises an empty Mode to

docs/content/operations/cloud-proxy.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,34 @@ image blocks, and per-request usage tokens are dropped through the
164164
internal `Predict()` signature. Use passthrough mode when your clients need
165165
the upstream's full feature set.
166166

167+
#### Anthropic prompt caching
168+
169+
`proxy.cache_prompt: true` makes the translator add Anthropic
170+
[prompt-cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching)
171+
breakpoints (`cache_control: {type: ephemeral}`) to the stable prefix of every
172+
request: the system block, the last tool, and the final message block (at most
173+
three of Anthropic's four allowed breakpoints). Anthropic then serves that
174+
repeated prefix at the cache-read rate (~0.1x input) on subsequent calls, which
175+
sharply cuts cost on agentic or multi-turn workloads that re-send a large,
176+
unchanging system-plus-tools prefix each turn.
177+
178+
The flag only applies with `mode: translate` and `provider: anthropic`; it has
179+
no effect in passthrough mode, for other providers, or when unset (the system
180+
field is then still emitted as a bare string).
181+
182+
```yaml
183+
name: claude-cached
184+
backend: cloud-proxy
185+
186+
proxy:
187+
mode: translate
188+
provider: anthropic
189+
upstream_url: https://api.anthropic.com/v1/messages
190+
api_key_env: ANTHROPIC_API_KEY
191+
upstream_model: claude-3-5-sonnet-20241022
192+
cache_prompt: true
193+
```
194+
167195
## Loading secrets from a file
168196

169197
`api_key_file` is an alternative to `api_key_env` when your secret manager

0 commit comments

Comments
 (0)