|
15 | 15 | * |
16 | 16 | * All transforms are pure functions (no I/O, no side-effects) and are |
17 | 17 | * idempotent: applying them twice yields the same result as applying once. |
18 | | - */ |
19 | | - |
20 | | -const path = require('path'); |
21 | | - |
22 | | -/** Maximum number of cache breakpoints Anthropic allows per request. */ |
23 | | -const MAX_CACHE_BREAKPOINTS = 4; |
24 | | - |
25 | | -/** |
26 | | - * The Anthropic beta-feature header value required to use 1-hour TTL caching. |
27 | | - * Must be added to the `anthropic-beta` request header when AWF_ANTHROPIC_AUTO_CACHE=1. |
28 | | - */ |
29 | | -const EXTENDED_CACHE_BETA = 'extended-cache-ttl-2025-04-11'; |
30 | | - |
31 | | -// ── Utility helpers ────────────────────────────────────────────────────────── |
32 | | - |
33 | | -/** |
34 | | - * Strip ANSI SGR (Select Graphic Rendition) escape sequences from a string. |
35 | | - * These are the colour/formatting codes of the form ESC [ <params> m. |
36 | | - * |
37 | | - * @param {string} text |
38 | | - * @returns {string} |
39 | | - */ |
40 | | -function stripAnsi(text) { |
41 | | - // ESC [ followed by any mix of digits and semicolons, ending with 'm' |
42 | | - return text.replace(/\x1B\[[\d;]*m/g, ''); |
43 | | -} |
44 | | - |
45 | | -/** |
46 | | - * Return a new content block with `cache_control` set. |
47 | | - * Any existing cache_control on the block is replaced. |
48 | | - * |
49 | | - * @param {object} block - Anthropic content block |
50 | | - * @param {{ type: string, ttl: string }} cacheControl |
51 | | - * @returns {object} |
52 | | - */ |
53 | | -function withCacheControl(block, cacheControl) { |
54 | | - return { ...block, cache_control: cacheControl }; |
55 | | -} |
56 | | - |
57 | | -/** |
58 | | - * Build a regex that matches any of the given tool names as whole words |
59 | | - * (not as substrings of longer identifiers). |
60 | | - * |
61 | | - * Note: JavaScript's `g`-flag RegExp objects track `lastIndex` but |
62 | | - * `String.prototype.replace` resets it to 0 before each use, so the |
63 | | - * same compiled pattern is safe to reuse across multiple calls. |
64 | | - * |
65 | | - * @param {string[]} toolNames |
66 | | - * @returns {RegExp} |
67 | | - */ |
68 | | -function buildToolScrubPattern(toolNames) { |
69 | | - const escaped = toolNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
70 | | - return new RegExp(`(?<![\\w])(?:${escaped.join('|')})(?![\\w])`, 'g'); |
71 | | -} |
72 | | - |
73 | | -// ── Feature 4: Strip ANSI from tool_result blocks ──────────────────────────── |
74 | | - |
75 | | -/** |
76 | | - * Walk every `tool_result` content block in a /v1/messages body and strip |
77 | | - * ANSI SGR escape sequences from text content. |
78 | | - * |
79 | | - * Roughly halves token counts in colour-heavy terminal outputs and enables |
80 | | - * cache hits across turns that differ only in escape codes. |
81 | | - * |
82 | | - * @param {object} body - Parsed /v1/messages request body |
83 | | - * @returns {object} New body object with ANSI stripped from tool_result blocks |
84 | | - */ |
85 | | -function applyAnsiStrip(body) { |
86 | | - if (!Array.isArray(body.messages)) return body; |
87 | | - |
88 | | - const messages = body.messages.map(msg => { |
89 | | - if (!Array.isArray(msg.content)) return msg; |
90 | | - |
91 | | - const content = msg.content.map(block => { |
92 | | - if (block.type !== 'tool_result') return block; |
93 | | - |
94 | | - // tool_result.content may be a plain string … |
95 | | - if (typeof block.content === 'string') { |
96 | | - return { ...block, content: stripAnsi(block.content) }; |
97 | | - } |
98 | | - |
99 | | - // … or an array of typed sub-blocks |
100 | | - if (Array.isArray(block.content)) { |
101 | | - const inner = block.content.map(b => { |
102 | | - if (b.type === 'text' && typeof b.text === 'string') { |
103 | | - return { ...b, text: stripAnsi(b.text) }; |
104 | | - } |
105 | | - return b; |
106 | | - }); |
107 | | - return { ...block, content: inner }; |
108 | | - } |
109 | | - |
110 | | - return block; |
111 | | - }); |
112 | | - |
113 | | - return { ...msg, content }; |
114 | | - }); |
115 | | - |
116 | | - return { ...body, messages }; |
117 | | -} |
118 | | - |
119 | | -// ── Feature 3: Drop unused tools ───────────────────────────────────────────── |
120 | | - |
121 | | -/** |
122 | | - * Remove named tools from the `tools` array and scrub their names from |
123 | | - * `system` prompt text blocks. |
124 | | - * |
125 | | - * Independent of caching: with caching in place, dropping tools also shrinks |
126 | | - * each cache-write slot. |
127 | | - * |
128 | | - * @param {object} body - Parsed /v1/messages request body |
129 | | - * @param {string[]} toolNames - Tool names to drop (exact string match) |
130 | | - * @param {RegExp} [scrubPattern] - Pre-compiled regex for system-prompt scrubbing. |
131 | | - * When omitted the pattern is derived from toolNames on each call. |
132 | | - * Pass a pre-compiled pattern (from makeAnthropicTransform) to avoid per-request |
133 | | - * regex compilation overhead. |
134 | | - * @returns {object} New body object with the specified tools removed |
135 | | - */ |
136 | | -function applyToolDrop(body, toolNames, scrubPattern = null) { |
137 | | - if (!toolNames || toolNames.length === 0) return body; |
138 | | - |
139 | | - const dropSet = new Set(toolNames); |
140 | | - let result = { ...body }; |
141 | | - |
142 | | - // Remove matching entries from the tools array |
143 | | - if (Array.isArray(result.tools)) { |
144 | | - const filtered = result.tools.filter(tool => !dropSet.has(tool.name)); |
145 | | - if (filtered.length < result.tools.length) { |
146 | | - if (filtered.length === 0) { |
147 | | - result = { ...result }; |
148 | | - delete result.tools; |
149 | | - } else { |
150 | | - result.tools = filtered; |
151 | | - } |
152 | | - } |
153 | | - } |
154 | | - |
155 | | - // Scrub tool-name references from system-prompt text blocks. |
156 | | - // We remove bare occurrences; surrounding punctuation/whitespace is left intact |
157 | | - // to avoid corrupting sentence structure. |
158 | | - if (Array.isArray(result.system)) { |
159 | | - const pattern = scrubPattern || buildToolScrubPattern([...dropSet]); |
160 | | - result.system = result.system.map(block => { |
161 | | - if (block.type !== 'text' || typeof block.text !== 'string') return block; |
162 | | - const scrubbed = block.text.replace(pattern, ''); |
163 | | - return scrubbed === block.text ? block : { ...block, text: scrubbed }; |
164 | | - }); |
165 | | - } |
166 | | - |
167 | | - return result; |
168 | | -} |
169 | | - |
170 | | -// ── Feature 1: Inject cache breakpoints ────────────────────────────────────── |
171 | | - |
172 | | -/** |
173 | | - * Inject up to {@link MAX_CACHE_BREAKPOINTS} prompt-cache breakpoints into a |
174 | | - * /v1/messages request body. |
175 | | - * |
176 | | - * Slot allocation (high-value → low-value, in priority order): |
177 | 18 | * |
178 | | - * Slot 1 — last entry in `tools` → 1h TTL (~24 k tokens / turn) |
179 | | - * Slot 2 — last block in `system` → 1h TTL (~8 k tokens / turn) |
180 | | - * Slot 3 — last block of `messages[0]` → 1h TTL (~5 k tokens / turn) |
181 | | - * Slot 4 — last block of last message → tailTtl (~15 k tokens / turn) |
182 | | - * (rolling tail; skipped when same position as slot 3) |
183 | | - * |
184 | | - * Running this function twice on the same body produces the same result as |
185 | | - * running it once (idempotent). |
186 | | - * |
187 | | - * @param {object} body - Parsed /v1/messages request body |
188 | | - * @param {string} tailTtl - TTL for the rolling-tail slot ('5m' | '1h') |
189 | | - * @returns {object} New body with cache_control injected at the chosen slots |
| 19 | + * Implementation is split across focused sub-modules: |
| 20 | + * - transforms/ansi-strip.js — ANSI escape-code stripping |
| 21 | + * - transforms/cache-control.js — cache breakpoint injection and TTL upgrading |
| 22 | + * - transforms/tool-drop.js — tool removal and system-prompt scrubbing |
190 | 23 | */ |
191 | | -function injectCacheBreakpoints(body, tailTtl = '5m') { |
192 | | - let result = { ...body }; |
193 | | - let slotsUsed = 0; |
194 | | - |
195 | | - // Slot 1: last tools entry |
196 | | - if (slotsUsed < MAX_CACHE_BREAKPOINTS && |
197 | | - Array.isArray(result.tools) && result.tools.length > 0) { |
198 | | - const tools = [...result.tools]; |
199 | | - tools[tools.length - 1] = withCacheControl(tools[tools.length - 1], { type: 'ephemeral', ttl: '1h' }); |
200 | | - result.tools = tools; |
201 | | - slotsUsed++; |
202 | | - } |
203 | | - |
204 | | - // Slot 2: last system block |
205 | | - if (slotsUsed < MAX_CACHE_BREAKPOINTS && |
206 | | - Array.isArray(result.system) && result.system.length > 0) { |
207 | | - const system = [...result.system]; |
208 | | - system[system.length - 1] = withCacheControl(system[system.length - 1], { type: 'ephemeral', ttl: '1h' }); |
209 | | - result.system = system; |
210 | | - slotsUsed++; |
211 | | - } |
212 | 24 |
|
213 | | - // Slot 3: last block of messages[0] |
214 | | - const msgs = result.messages; |
215 | | - if (slotsUsed < MAX_CACHE_BREAKPOINTS && |
216 | | - Array.isArray(msgs) && msgs.length > 0 && |
217 | | - Array.isArray(msgs[0].content) && msgs[0].content.length > 0) { |
218 | | - const content = [...msgs[0].content]; |
219 | | - content[content.length - 1] = withCacheControl(content[content.length - 1], { type: 'ephemeral', ttl: '1h' }); |
220 | | - const messages = [...msgs]; |
221 | | - messages[0] = { ...msgs[0], content }; |
222 | | - result.messages = messages; |
223 | | - slotsUsed++; |
224 | | - } |
225 | | - |
226 | | - // Slot 4: last block of the last message (rolling tail) |
227 | | - // Only used when the last message is different from messages[0] (i.e. ≥2 messages). |
228 | | - if (slotsUsed < MAX_CACHE_BREAKPOINTS && |
229 | | - Array.isArray(result.messages) && result.messages.length > 1) { |
230 | | - const messages = result.messages; |
231 | | - const lastMsg = messages[messages.length - 1]; |
232 | | - if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) { |
233 | | - const content = [...lastMsg.content]; |
234 | | - content[content.length - 1] = withCacheControl( |
235 | | - content[content.length - 1], |
236 | | - { type: 'ephemeral', ttl: tailTtl } |
237 | | - ); |
238 | | - const newMessages = [...messages]; |
239 | | - newMessages[newMessages.length - 1] = { ...lastMsg, content }; |
240 | | - result.messages = newMessages; |
241 | | - slotsUsed++; |
242 | | - } |
243 | | - } |
244 | | - |
245 | | - return result; |
246 | | -} |
247 | | - |
248 | | -// ── Feature 2: Upgrade existing ephemeral TTLs ──────────────────────────────── |
249 | | - |
250 | | -/** |
251 | | - * Upgrade any existing `{type: "ephemeral"}` cache breakpoints that lack a |
252 | | - * `ttl` field to use a 1-hour TTL — except for the rolling tail. |
253 | | - * |
254 | | - * The "rolling tail" is defined as the last cache_control block found in the |
255 | | - * `messages` array (scanning backwards). Because this breakpoint moves every |
256 | | - * turn it is kept at `tailTtl` to avoid paying the 2× cache-write surcharge |
257 | | - * on a breakpoint that never stabilises. |
258 | | - * |
259 | | - * Blocks that already have a `ttl` set are left unchanged. |
260 | | - * |
261 | | - * @param {object} body - Parsed /v1/messages request body |
262 | | - * @param {string} tailTtl - TTL for the rolling tail ('5m' | '1h') |
263 | | - * @returns {object} New body with upgraded ephemeral TTLs |
264 | | - */ |
265 | | -function upgradeEphemeralTtl(body, tailTtl = '5m') { |
266 | | - // Locate the rolling-tail position: last ephemeral cache_control in messages[] |
267 | | - let tailMsgIdx = -1; |
268 | | - let tailBlockIdx = -1; |
269 | | - if (Array.isArray(body.messages)) { |
270 | | - outer: for (let i = body.messages.length - 1; i >= 0; i--) { |
271 | | - const msg = body.messages[i]; |
272 | | - if (!Array.isArray(msg.content)) continue; |
273 | | - for (let j = msg.content.length - 1; j >= 0; j--) { |
274 | | - const b = msg.content[j]; |
275 | | - if (b && b.cache_control && b.cache_control.type === 'ephemeral') { |
276 | | - tailMsgIdx = i; |
277 | | - tailBlockIdx = j; |
278 | | - break outer; |
279 | | - } |
280 | | - } |
281 | | - } |
282 | | - } |
283 | | - |
284 | | - let result = { ...body }; |
285 | | - |
286 | | - // Upgrade tools — these are always static, so always use 1h |
287 | | - if (Array.isArray(result.tools)) { |
288 | | - const tools = result.tools.map(tool => { |
289 | | - if (!tool.cache_control || |
290 | | - tool.cache_control.type !== 'ephemeral' || |
291 | | - tool.cache_control.ttl) return tool; |
292 | | - return withCacheControl(tool, { type: 'ephemeral', ttl: '1h' }); |
293 | | - }); |
294 | | - result.tools = tools; |
295 | | - } |
296 | | - |
297 | | - // Upgrade system blocks — also static, always use 1h |
298 | | - if (Array.isArray(result.system)) { |
299 | | - const system = result.system.map(block => { |
300 | | - if (!block.cache_control || |
301 | | - block.cache_control.type !== 'ephemeral' || |
302 | | - block.cache_control.ttl) return block; |
303 | | - return withCacheControl(block, { type: 'ephemeral', ttl: '1h' }); |
304 | | - }); |
305 | | - result.system = system; |
306 | | - } |
307 | | - |
308 | | - // Upgrade messages — tail keeps tailTtl; everything else gets 1h |
309 | | - if (Array.isArray(result.messages)) { |
310 | | - const messages = result.messages.map((msg, mi) => { |
311 | | - if (!Array.isArray(msg.content)) return msg; |
312 | | - const content = msg.content.map((block, bi) => { |
313 | | - if (!block || |
314 | | - !block.cache_control || |
315 | | - block.cache_control.type !== 'ephemeral' || |
316 | | - block.cache_control.ttl) return block; |
317 | | - const isTail = (mi === tailMsgIdx && bi === tailBlockIdx); |
318 | | - return withCacheControl(block, { type: 'ephemeral', ttl: isTail ? tailTtl : '1h' }); |
319 | | - }); |
320 | | - return { ...msg, content }; |
321 | | - }); |
322 | | - result.messages = messages; |
323 | | - } |
| 25 | +const path = require('path'); |
324 | 26 |
|
325 | | - return result; |
326 | | -} |
| 27 | +const { stripAnsi, applyAnsiStrip } = require('./transforms/ansi-strip'); |
| 28 | +const { |
| 29 | + injectCacheBreakpoints, |
| 30 | + upgradeEphemeralTtl, |
| 31 | + MAX_CACHE_BREAKPOINTS, |
| 32 | + EXTENDED_CACHE_BETA, |
| 33 | +} = require('./transforms/cache-control'); |
| 34 | +const { buildToolScrubPattern, applyToolDrop } = require('./transforms/tool-drop'); |
327 | 35 |
|
328 | 36 | // ── Feature 5: Custom transform hook ───────────────────────────────────────── |
329 | 37 |
|
|
0 commit comments