Skip to content

Commit 16b972b

Browse files
Copilotlpcoxgithub-advanced-security[bot]
authored
refactor: split anthropic-transforms.js into focused sub-modules (#3478)
* Initial plan * refactor: split anthropic-transforms.js into focused sub-modules Move the four independent transform concerns out of the monolithic anthropic-transforms.js into dedicated files under transforms/: - transforms/ansi-strip.js — stripAnsi, applyAnsiStrip (~70 lines) - transforms/cache-control.js — withCacheControl, injectCacheBreakpoints, upgradeEphemeralTtl, MAX_CACHE_BREAKPOINTS, EXTENDED_CACHE_BETA (~195 lines) - transforms/tool-drop.js — buildToolScrubPattern, applyToolDrop (~73 lines) anthropic-transforms.js is now a thin composition layer (~183 lines) that imports from the sub-modules and retains loadCustomTransform + makeAnthropicTransform. All existing exports are preserved for backward compatibility with providers/anthropic.js and the test suite. * Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: harden anthropic transforms against malformed entries --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 475a7d2 commit 16b972b

6 files changed

Lines changed: 410 additions & 305 deletions

File tree

containers/api-proxy/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ COPY server.js logging.js metrics.js rate-limiter.js \
2626
otel.js ./
2727
COPY guards/ ./guards/
2828
COPY providers/ ./providers/
29+
COPY transforms/ ./transforms/
2930

3031
# Create non-root user
3132
RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy

containers/api-proxy/anthropic-transforms.js

Lines changed: 13 additions & 305 deletions
Original file line numberDiff line numberDiff line change
@@ -15,315 +15,23 @@
1515
*
1616
* All transforms are pure functions (no I/O, no side-effects) and are
1717
* 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):
17718
*
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
19023
*/
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-
}
21224

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');
32426

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');
32735

32836
// ── Feature 5: Custom transform hook ─────────────────────────────────────────
32937

0 commit comments

Comments
 (0)