Skip to content

Commit 393a98a

Browse files
ericdalloeca-agent
andcommitted
Add cacheRetention provider config for 1-hour Anthropic prompt cache TTL
Add configurable prompt cache retention for the Anthropic provider. Set `"cacheRetention": "long"` on the provider config to use 1-hour TTL instead of the default 5-minute TTL. Only applies when hitting the direct Anthropic API (not proxies). Ref: #396 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent f266225 commit 393a98a

File tree

6 files changed

+94
-37
lines changed

6 files changed

+94
-37
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add `cacheRetention` provider config for Anthropic to support 1-hour prompt cache TTL. Set to `"long"` for sessions with pauses longer than 5 minutes.
6+
57
## 0.124.0
68

79
- Add `chatRetentionDays` config to control chat and cache cleanup retention period, default changed from 7 to 14 days. Set to 0 to disable cleanup. #393

docs/config.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,13 @@
559559
"additionalProperties": {
560560
"$ref": "#/definitions/model"
561561
}
562+
},
563+
"cacheRetention": {
564+
"type": "string",
565+
"description": "Prompt cache retention for Anthropic. 'short' uses 5-min TTL (default), 'long' uses 1-hour TTL at higher write cost. Only applies when hitting the direct Anthropic API.",
566+
"markdownDescription": "Prompt cache retention for Anthropic. `short` uses 5-min TTL (default), `long` uses 1-hour TTL at higher write cost. Only applies when hitting the direct Anthropic API.",
567+
"enum": ["short", "long"],
568+
"default": "short"
562569
}
563570
},
564571
"additionalProperties": false

docs/config/models.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Schema:
8484
| `models <model> extraHeaders` | map | Extra headers sent to LLM request | No |
8585
| `models <model> modelName` | string | Override model name, useful to have multiple models with different configs and names that use same LLM model | No |
8686
| `models <model> reasoningHistory` | string | Controls reasoning in conversation history: `"all"` (default), `"turn"`, or `"off"` | No |
87+
| `cacheRetention` | string | Prompt cache retention for Anthropic: `"short"` (5-min, default) or `"long"` (1-hour, higher write cost). Only applies to direct Anthropic API. | No |
8788
| `fetchModels` | boolean | Enable/disable automatic model loading from `models.dev` (enabled by default when `api` is set) | No |
8889

8990
_* url and key will be searched as envs `<provider>_API_URL` and `<provider>_API_KEY`, they require the env to be found or config to work._
@@ -279,7 +280,19 @@ When a retry rule matches, the chat shows a progress message like:
279280
5. Paste and send the code and done!
280281

281282
Warning: Using your Claude Pro/Max subscription in ECA is not officially supported by [Anthropic](https://anthropic.com) and ECA are not responsible for any actions on your account.
282-
283+
284+
**Cache retention:** By default, Anthropic prompt cache uses a 5-minute TTL. If your sessions have frequent pauses longer than 5 minutes, you can set `"cacheRetention": "long"` on the provider config to use a 1-hour TTL. This costs 2× base input price for cache writes (vs 1.25× for 5-min) but avoids full re-write costs after pauses. Only applies when using the direct Anthropic API (not proxies).
285+
286+
```javascript title="~/.config/eca/config.json"
287+
{
288+
"providers": {
289+
"anthropic": {
290+
"cacheRetention": "long"
291+
}
292+
}
293+
}
294+
```
295+
283296
=== "Azure OpenAI"
284297

285298
1. Login via the chat command `/login`.

src/eca/llm_api.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@
237237
:api-key api-key
238238
:auth-type auth-type
239239
:cancelled? cancelled?
240+
:cache-retention (:cacheRetention provider-config)
240241
:stream-idle-timeout-seconds (:streamIdleTimeoutSeconds config)}
241242
callbacks)
242243

src/eca/llm_providers/anthropic.clj

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,24 @@
6868
:name "web_search"
6969
:max_uses 10})))
7070

71+
(defn ^:private cache-control-value
72+
"Returns the cache_control map based on cache retention config and API URL.
73+
Only applies 1-hour TTL when hitting the direct Anthropic API."
74+
[api-url cache-retention]
75+
(if (and (= "long" cache-retention)
76+
(or (nil? api-url)
77+
(string/includes? api-url "api.anthropic.com")))
78+
{:type "ephemeral" :ttl "1h"}
79+
{:type "ephemeral"}))
80+
7181
(defn ^:private add-cache-to-last-tool
7282
"Adds cache_control to the last tool in the tools array, ensuring
7383
the full tools list is part of the cached prefix."
74-
[tools]
84+
[tools cache-control]
7585
(if (seq tools)
7686
(shared/update-last
7787
(vec tools)
78-
(fn [tool] (assoc tool :cache_control {:type "ephemeral"})))
88+
(fn [tool] (assoc tool :cache_control cache-control)))
7989
tools))
8090

8191
(defn ^:private base-request! [{:keys [rid body api-url api-key auth-type url-relative-path content-block* on-error on-stream http-client extra-headers cancelled? stream-idle-timeout-seconds]}]
@@ -288,7 +298,7 @@
288298
[]
289299
messages))
290300

291-
(defn ^:private add-cache-to-last-message [messages]
301+
(defn ^:private add-cache-to-last-message [messages cache-control]
292302
;; TODO add cache_control to last non thinking message
293303
(shared/update-last
294304
(vec messages)
@@ -297,35 +307,36 @@
297307
(if (string? content)
298308
(assoc-in message [:content] [{:type :text
299309
:text content
300-
:cache_control {:type "ephemeral"}}])
301-
(assoc-in message [:content (dec (count content)) :cache_control] {:type "ephemeral"}))))))
310+
:cache_control cache-control}])
311+
(assoc-in message [:content (dec (count content)) :cache_control] cache-control))))))
302312

303313
(defn chat!
304314
[{:keys [model user-messages instructions max-output-tokens
305315
api-url api-key auth-type url-relative-path reason? past-messages
306316
tools web-search extra-payload extra-headers supports-image? http-client cancelled?
307-
stream-idle-timeout-seconds]}
317+
stream-idle-timeout-seconds cache-retention]}
308318
{:keys [on-message-received on-error on-reason on-prepare-tool-call on-tools-called on-usage-updated on-server-web-search] :as callbacks}]
309319
(let [messages (-> (concat past-messages (fix-non-thinking-assistant-messages user-messages))
310320
group-parallel-tool-calls
311321
(normalize-messages supports-image?)
312322
merge-adjacent-assistants
313323
merge-adjacent-tool-results)
314324
stream? (boolean callbacks)
325+
cache-control (cache-control-value api-url cache-retention)
315326
{:keys [static dynamic]} (if (map? instructions)
316327
instructions
317328
{:static instructions :dynamic nil})
318329
system-blocks (cond-> [{:type "text" :text "You are Claude Code, Anthropic's official CLI for Claude."}
319-
{:type "text" :text static :cache_control {:type "ephemeral"}}]
330+
{:type "text" :text static :cache_control cache-control}]
320331
(not (string/blank? dynamic))
321-
(conj {:type "text" :text dynamic :cache_control {:type "ephemeral"}}))
332+
(conj {:type "text" :text dynamic :cache_control cache-control}))
322333
body (merge
323334
(assoc-some
324335
{:model model
325-
:messages (add-cache-to-last-message messages)
336+
:messages (add-cache-to-last-message messages cache-control)
326337
:max_tokens (or max-output-tokens 32000)
327338
:stream stream?
328-
:tools (add-cache-to-last-tool (->tools tools web-search))
339+
:tools (add-cache-to-last-tool (->tools tools web-search) cache-control)
329340
:system system-blocks}
330341
:thinking (when reason?
331342
{:type "enabled" :budget_tokens 2048}))
@@ -441,7 +452,7 @@
441452
(normalize-messages supports-image?)
442453
merge-adjacent-assistants
443454
merge-adjacent-tool-results
444-
add-cache-to-last-message)]
455+
(add-cache-to-last-message cache-control))]
445456
(reset! content-block* {})
446457
(base-request!
447458
{:rid (llm-util/gen-rid)

test/eca/llm_providers/anthropic_test.clj

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -330,35 +330,58 @@
330330
{:type "tool_result" :tool_use_id "c2" :content "content-b\n"}]}]
331331
result)))))
332332

333+
(deftest cache-control-value-test
334+
(let [cache-control #'llm-providers.anthropic/cache-control-value]
335+
(testing "default 5-min TTL when no cache-retention set"
336+
(is (= {:type "ephemeral"} (cache-control "https://api.anthropic.com" nil)))
337+
(is (= {:type "ephemeral"} (cache-control nil nil))))
338+
(testing "default 5-min TTL for short retention"
339+
(is (= {:type "ephemeral"} (cache-control "https://api.anthropic.com" "short"))))
340+
(testing "1-hour TTL for long retention on direct Anthropic API"
341+
(is (= {:type "ephemeral" :ttl "1h"} (cache-control "https://api.anthropic.com" "long")))
342+
(is (= {:type "ephemeral" :ttl "1h"} (cache-control "https://api.anthropic.com/v1" "long"))))
343+
(testing "1-hour TTL when api-url is nil (default direct API)"
344+
(is (= {:type "ephemeral" :ttl "1h"} (cache-control nil "long"))))
345+
(testing "falls back to 5-min when using a proxy"
346+
(is (= {:type "ephemeral"} (cache-control "https://my-proxy.example.com" "long"))))))
347+
333348
(deftest add-cache-to-last-message-test
334-
(is (match?
335-
[]
336-
(#'llm-providers.anthropic/add-cache-to-last-message [])))
337-
(testing "when message content is a vector"
338-
(is (match?
339-
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
340-
(#'llm-providers.anthropic/add-cache-to-last-message
341-
[{:role "user" :content [{:type :text :text "Hey"}]}])))
342-
(is (match?
343-
[{:role "user" :content [{:type :text :text "Hey"}]}
344-
{:role "user" :content [{:type :text :text "Ho" :cache_control {:type "ephemeral"}}]}]
345-
(#'llm-providers.anthropic/add-cache-to-last-message
346-
[{:role "user" :content [{:type :text :text "Hey"}]}
347-
{:role "user" :content [{:type :text :text "Ho"}]}]))))
348-
(testing "when message content is string"
349-
(is (match?
350-
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
351-
(#'llm-providers.anthropic/add-cache-to-last-message
352-
[{:role "user" :content "Hey"}])))
349+
(let [default-cache {:type "ephemeral"}]
353350
(is (match?
354-
[{:role "user" :content "Hey"}
355-
{:role "user" :content [{:type :text :text "Ho" :cache_control {:type "ephemeral"}}]}]
356-
(#'llm-providers.anthropic/add-cache-to-last-message
357-
[{:role "user" :content "Hey"}
358-
{:role "user" :content "Ho"}])))))
351+
[]
352+
(#'llm-providers.anthropic/add-cache-to-last-message [] default-cache)))
353+
(testing "when message content is a vector"
354+
(is (match?
355+
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
356+
(#'llm-providers.anthropic/add-cache-to-last-message
357+
[{:role "user" :content [{:type :text :text "Hey"}]}] default-cache)))
358+
(is (match?
359+
[{:role "user" :content [{:type :text :text "Hey"}]}
360+
{:role "user" :content [{:type :text :text "Ho" :cache_control {:type "ephemeral"}}]}]
361+
(#'llm-providers.anthropic/add-cache-to-last-message
362+
[{:role "user" :content [{:type :text :text "Hey"}]}
363+
{:role "user" :content [{:type :text :text "Ho"}]}] default-cache))))
364+
(testing "when message content is string"
365+
(is (match?
366+
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
367+
(#'llm-providers.anthropic/add-cache-to-last-message
368+
[{:role "user" :content "Hey"}] default-cache)))
369+
(is (match?
370+
[{:role "user" :content "Hey"}
371+
{:role "user" :content [{:type :text :text "Ho" :cache_control {:type "ephemeral"}}]}]
372+
(#'llm-providers.anthropic/add-cache-to-last-message
373+
[{:role "user" :content "Hey"}
374+
{:role "user" :content "Ho"}] default-cache))))
375+
(testing "with 1-hour TTL"
376+
(let [long-cache {:type "ephemeral" :ttl "1h"}]
377+
(is (match?
378+
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral" :ttl "1h"}}]}]
379+
(#'llm-providers.anthropic/add-cache-to-last-message
380+
[{:role "user" :content [{:type :text :text "Hey"}]}] long-cache)))))))
359381

360382
(deftest add-cache-to-last-tool-test
361-
(let [add-cache #'llm-providers.anthropic/add-cache-to-last-tool]
383+
(let [default-cache {:type "ephemeral"}
384+
add-cache (fn [tools] (#'llm-providers.anthropic/add-cache-to-last-tool tools default-cache))]
362385
(testing "empty tools returns empty"
363386
(is (match? [] (add-cache [])))
364387
(is (match? nil (add-cache nil))))

0 commit comments

Comments
 (0)