From ec50c5cf1a101993e46413429e49656ff892b6cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 23:10:14 +0000 Subject: [PATCH 1/4] Initial plan From 4511581d02a60f77ffa488805b62135b54fb9436 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 23:15:51 +0000 Subject: [PATCH 2/4] refactor: split anthropic-transforms.js into focused sub-modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- containers/api-proxy/anthropic-transforms.js | 319 +----------------- containers/api-proxy/transforms/ansi-strip.js | 70 ++++ .../api-proxy/transforms/cache-control.js | 195 +++++++++++ containers/api-proxy/transforms/tool-drop.js | 73 ++++ 4 files changed, 352 insertions(+), 305 deletions(-) create mode 100644 containers/api-proxy/transforms/ansi-strip.js create mode 100644 containers/api-proxy/transforms/cache-control.js create mode 100644 containers/api-proxy/transforms/tool-drop.js diff --git a/containers/api-proxy/anthropic-transforms.js b/containers/api-proxy/anthropic-transforms.js index 1e84473b0..b62cc951d 100644 --- a/containers/api-proxy/anthropic-transforms.js +++ b/containers/api-proxy/anthropic-transforms.js @@ -15,315 +15,24 @@ * * All transforms are pure functions (no I/O, no side-effects) and are * idempotent: applying them twice yields the same result as applying once. - */ - -const path = require('path'); - -/** Maximum number of cache breakpoints Anthropic allows per request. */ -const MAX_CACHE_BREAKPOINTS = 4; - -/** - * The Anthropic beta-feature header value required to use 1-hour TTL caching. - * Must be added to the `anthropic-beta` request header when AWF_ANTHROPIC_AUTO_CACHE=1. - */ -const EXTENDED_CACHE_BETA = 'extended-cache-ttl-2025-04-11'; - -// ── Utility helpers ────────────────────────────────────────────────────────── - -/** - * Strip ANSI SGR (Select Graphic Rendition) escape sequences from a string. - * These are the colour/formatting codes of the form ESC [ m. - * - * @param {string} text - * @returns {string} - */ -function stripAnsi(text) { - // ESC [ followed by any mix of digits and semicolons, ending with 'm' - return text.replace(/\x1B\[[\d;]*m/g, ''); -} - -/** - * Return a new content block with `cache_control` set. - * Any existing cache_control on the block is replaced. - * - * @param {object} block - Anthropic content block - * @param {{ type: string, ttl: string }} cacheControl - * @returns {object} - */ -function withCacheControl(block, cacheControl) { - return { ...block, cache_control: cacheControl }; -} - -/** - * Build a regex that matches any of the given tool names as whole words - * (not as substrings of longer identifiers). - * - * Note: JavaScript's `g`-flag RegExp objects track `lastIndex` but - * `String.prototype.replace` resets it to 0 before each use, so the - * same compiled pattern is safe to reuse across multiple calls. - * - * @param {string[]} toolNames - * @returns {RegExp} - */ -function buildToolScrubPattern(toolNames) { - const escaped = toolNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - return new RegExp(`(? { - if (!Array.isArray(msg.content)) return msg; - - const content = msg.content.map(block => { - if (block.type !== 'tool_result') return block; - - // tool_result.content may be a plain string … - if (typeof block.content === 'string') { - return { ...block, content: stripAnsi(block.content) }; - } - - // … or an array of typed sub-blocks - if (Array.isArray(block.content)) { - const inner = block.content.map(b => { - if (b.type === 'text' && typeof b.text === 'string') { - return { ...b, text: stripAnsi(b.text) }; - } - return b; - }); - return { ...block, content: inner }; - } - - return block; - }); - - return { ...msg, content }; - }); - - return { ...body, messages }; -} - -// ── Feature 3: Drop unused tools ───────────────────────────────────────────── - -/** - * Remove named tools from the `tools` array and scrub their names from - * `system` prompt text blocks. - * - * Independent of caching: with caching in place, dropping tools also shrinks - * each cache-write slot. - * - * @param {object} body - Parsed /v1/messages request body - * @param {string[]} toolNames - Tool names to drop (exact string match) - * @param {RegExp} [scrubPattern] - Pre-compiled regex for system-prompt scrubbing. - * When omitted the pattern is derived from toolNames on each call. - * Pass a pre-compiled pattern (from makeAnthropicTransform) to avoid per-request - * regex compilation overhead. - * @returns {object} New body object with the specified tools removed - */ -function applyToolDrop(body, toolNames, scrubPattern = null) { - if (!toolNames || toolNames.length === 0) return body; - - const dropSet = new Set(toolNames); - let result = { ...body }; - - // Remove matching entries from the tools array - if (Array.isArray(result.tools)) { - const filtered = result.tools.filter(tool => !dropSet.has(tool.name)); - if (filtered.length < result.tools.length) { - if (filtered.length === 0) { - result = { ...result }; - delete result.tools; - } else { - result.tools = filtered; - } - } - } - - // Scrub tool-name references from system-prompt text blocks. - // We remove bare occurrences; surrounding punctuation/whitespace is left intact - // to avoid corrupting sentence structure. - if (Array.isArray(result.system)) { - const pattern = scrubPattern || buildToolScrubPattern([...dropSet]); - result.system = result.system.map(block => { - if (block.type !== 'text' || typeof block.text !== 'string') return block; - const scrubbed = block.text.replace(pattern, ''); - return scrubbed === block.text ? block : { ...block, text: scrubbed }; - }); - } - - return result; -} - -// ── Feature 1: Inject cache breakpoints ────────────────────────────────────── - -/** - * Inject up to {@link MAX_CACHE_BREAKPOINTS} prompt-cache breakpoints into a - * /v1/messages request body. - * - * Slot allocation (high-value → low-value, in priority order): * - * Slot 1 — last entry in `tools` → 1h TTL (~24 k tokens / turn) - * Slot 2 — last block in `system` → 1h TTL (~8 k tokens / turn) - * Slot 3 — last block of `messages[0]` → 1h TTL (~5 k tokens / turn) - * Slot 4 — last block of last message → tailTtl (~15 k tokens / turn) - * (rolling tail; skipped when same position as slot 3) - * - * Running this function twice on the same body produces the same result as - * running it once (idempotent). - * - * @param {object} body - Parsed /v1/messages request body - * @param {string} tailTtl - TTL for the rolling-tail slot ('5m' | '1h') - * @returns {object} New body with cache_control injected at the chosen slots + * Implementation is split across focused sub-modules: + * - transforms/ansi-strip.js — ANSI escape-code stripping + * - transforms/cache-control.js — cache breakpoint injection and TTL upgrading + * - transforms/tool-drop.js — tool removal and system-prompt scrubbing */ -function injectCacheBreakpoints(body, tailTtl = '5m') { - let result = { ...body }; - let slotsUsed = 0; - - // Slot 1: last tools entry - if (slotsUsed < MAX_CACHE_BREAKPOINTS && - Array.isArray(result.tools) && result.tools.length > 0) { - const tools = [...result.tools]; - tools[tools.length - 1] = withCacheControl(tools[tools.length - 1], { type: 'ephemeral', ttl: '1h' }); - result.tools = tools; - slotsUsed++; - } - - // Slot 2: last system block - if (slotsUsed < MAX_CACHE_BREAKPOINTS && - Array.isArray(result.system) && result.system.length > 0) { - const system = [...result.system]; - system[system.length - 1] = withCacheControl(system[system.length - 1], { type: 'ephemeral', ttl: '1h' }); - result.system = system; - slotsUsed++; - } - // Slot 3: last block of messages[0] - const msgs = result.messages; - if (slotsUsed < MAX_CACHE_BREAKPOINTS && - Array.isArray(msgs) && msgs.length > 0 && - Array.isArray(msgs[0].content) && msgs[0].content.length > 0) { - const content = [...msgs[0].content]; - content[content.length - 1] = withCacheControl(content[content.length - 1], { type: 'ephemeral', ttl: '1h' }); - const messages = [...msgs]; - messages[0] = { ...msgs[0], content }; - result.messages = messages; - slotsUsed++; - } - - // Slot 4: last block of the last message (rolling tail) - // Only used when the last message is different from messages[0] (i.e. ≥2 messages). - if (slotsUsed < MAX_CACHE_BREAKPOINTS && - Array.isArray(result.messages) && result.messages.length > 1) { - const messages = result.messages; - const lastMsg = messages[messages.length - 1]; - if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) { - const content = [...lastMsg.content]; - content[content.length - 1] = withCacheControl( - content[content.length - 1], - { type: 'ephemeral', ttl: tailTtl } - ); - const newMessages = [...messages]; - newMessages[newMessages.length - 1] = { ...lastMsg, content }; - result.messages = newMessages; - slotsUsed++; - } - } - - return result; -} - -// ── Feature 2: Upgrade existing ephemeral TTLs ──────────────────────────────── - -/** - * Upgrade any existing `{type: "ephemeral"}` cache breakpoints that lack a - * `ttl` field to use a 1-hour TTL — except for the rolling tail. - * - * The "rolling tail" is defined as the last cache_control block found in the - * `messages` array (scanning backwards). Because this breakpoint moves every - * turn it is kept at `tailTtl` to avoid paying the 2× cache-write surcharge - * on a breakpoint that never stabilises. - * - * Blocks that already have a `ttl` set are left unchanged. - * - * @param {object} body - Parsed /v1/messages request body - * @param {string} tailTtl - TTL for the rolling tail ('5m' | '1h') - * @returns {object} New body with upgraded ephemeral TTLs - */ -function upgradeEphemeralTtl(body, tailTtl = '5m') { - // Locate the rolling-tail position: last ephemeral cache_control in messages[] - let tailMsgIdx = -1; - let tailBlockIdx = -1; - if (Array.isArray(body.messages)) { - outer: for (let i = body.messages.length - 1; i >= 0; i--) { - const msg = body.messages[i]; - if (!Array.isArray(msg.content)) continue; - for (let j = msg.content.length - 1; j >= 0; j--) { - const b = msg.content[j]; - if (b && b.cache_control && b.cache_control.type === 'ephemeral') { - tailMsgIdx = i; - tailBlockIdx = j; - break outer; - } - } - } - } - - let result = { ...body }; - - // Upgrade tools — these are always static, so always use 1h - if (Array.isArray(result.tools)) { - const tools = result.tools.map(tool => { - if (!tool.cache_control || - tool.cache_control.type !== 'ephemeral' || - tool.cache_control.ttl) return tool; - return withCacheControl(tool, { type: 'ephemeral', ttl: '1h' }); - }); - result.tools = tools; - } - - // Upgrade system blocks — also static, always use 1h - if (Array.isArray(result.system)) { - const system = result.system.map(block => { - if (!block.cache_control || - block.cache_control.type !== 'ephemeral' || - block.cache_control.ttl) return block; - return withCacheControl(block, { type: 'ephemeral', ttl: '1h' }); - }); - result.system = system; - } - - // Upgrade messages — tail keeps tailTtl; everything else gets 1h - if (Array.isArray(result.messages)) { - const messages = result.messages.map((msg, mi) => { - if (!Array.isArray(msg.content)) return msg; - const content = msg.content.map((block, bi) => { - if (!block || - !block.cache_control || - block.cache_control.type !== 'ephemeral' || - block.cache_control.ttl) return block; - const isTail = (mi === tailMsgIdx && bi === tailBlockIdx); - return withCacheControl(block, { type: 'ephemeral', ttl: isTail ? tailTtl : '1h' }); - }); - return { ...msg, content }; - }); - result.messages = messages; - } +const path = require('path'); - return result; -} +const { stripAnsi, applyAnsiStrip } = require('./transforms/ansi-strip'); +const { + withCacheControl, + injectCacheBreakpoints, + upgradeEphemeralTtl, + MAX_CACHE_BREAKPOINTS, + EXTENDED_CACHE_BETA, +} = require('./transforms/cache-control'); +const { buildToolScrubPattern, applyToolDrop } = require('./transforms/tool-drop'); // ── Feature 5: Custom transform hook ───────────────────────────────────────── diff --git a/containers/api-proxy/transforms/ansi-strip.js b/containers/api-proxy/transforms/ansi-strip.js new file mode 100644 index 000000000..e884122de --- /dev/null +++ b/containers/api-proxy/transforms/ansi-strip.js @@ -0,0 +1,70 @@ +'use strict'; + +/** + * ANSI escape-code stripping utilities. + * + * Generic utility — not Anthropic-specific — so it can be reused across + * multiple provider adapters (e.g. OpenAI, Copilot). + * + * The only sequences stripped are ANSI SGR (Select Graphic Rendition) codes of + * the form ESC [ m (i.e. colour/formatting codes). Other escape + * sequences (cursor movement, terminal modes, etc.) are left intact. + */ + +/** + * Strip ANSI SGR (Select Graphic Rendition) escape sequences from a string. + * These are the colour/formatting codes of the form ESC [ m. + * + * @param {string} text + * @returns {string} + */ +function stripAnsi(text) { + // ESC [ followed by any mix of digits and semicolons, ending with 'm' + return text.replace(/\x1B\[[\d;]*m/g, ''); +} + +/** + * Walk every `tool_result` content block in a /v1/messages body and strip + * ANSI SGR escape sequences from text content. + * + * Roughly halves token counts in colour-heavy terminal outputs and enables + * cache hits across turns that differ only in escape codes. + * + * @param {object} body - Parsed /v1/messages request body + * @returns {object} New body object with ANSI stripped from tool_result blocks + */ +function applyAnsiStrip(body) { + if (!Array.isArray(body.messages)) return body; + + const messages = body.messages.map(msg => { + if (!Array.isArray(msg.content)) return msg; + + const content = msg.content.map(block => { + if (block.type !== 'tool_result') return block; + + // tool_result.content may be a plain string … + if (typeof block.content === 'string') { + return { ...block, content: stripAnsi(block.content) }; + } + + // … or an array of typed sub-blocks + if (Array.isArray(block.content)) { + const inner = block.content.map(b => { + if (b.type === 'text' && typeof b.text === 'string') { + return { ...b, text: stripAnsi(b.text) }; + } + return b; + }); + return { ...block, content: inner }; + } + + return block; + }); + + return { ...msg, content }; + }); + + return { ...body, messages }; +} + +module.exports = { stripAnsi, applyAnsiStrip }; diff --git a/containers/api-proxy/transforms/cache-control.js b/containers/api-proxy/transforms/cache-control.js new file mode 100644 index 000000000..14feff94a --- /dev/null +++ b/containers/api-proxy/transforms/cache-control.js @@ -0,0 +1,195 @@ +'use strict'; + +/** + * Anthropic prompt-cache control utilities. + * + * Implements the two caching transforms used by the AWF Anthropic adapter: + * + * 1. `injectCacheBreakpoints` — inject up to four standard cache-breakpoint + * slots into a /v1/messages request body. + * 2. `upgradeEphemeralTtl` — upgrade any pre-existing ephemeral breakpoints + * (without a TTL) to 1-hour TTL, except for the rolling-tail slot. + */ + +/** Maximum number of cache breakpoints Anthropic allows per request. */ +const MAX_CACHE_BREAKPOINTS = 4; + +/** + * The Anthropic beta-feature header value required to use 1-hour TTL caching. + * Must be added to the `anthropic-beta` request header when AWF_ANTHROPIC_AUTO_CACHE=1. + */ +const EXTENDED_CACHE_BETA = 'extended-cache-ttl-2025-04-11'; + +/** + * Return a new content block with `cache_control` set. + * Any existing cache_control on the block is replaced. + * + * @param {object} block - Anthropic content block + * @param {{ type: string, ttl: string }} cacheControl + * @returns {object} + */ +function withCacheControl(block, cacheControl) { + return { ...block, cache_control: cacheControl }; +} + +/** + * Inject up to {@link MAX_CACHE_BREAKPOINTS} prompt-cache breakpoints into a + * /v1/messages request body. + * + * Slot allocation (high-value → low-value, in priority order): + * + * Slot 1 — last entry in `tools` → 1h TTL (~24 k tokens / turn) + * Slot 2 — last block in `system` → 1h TTL (~8 k tokens / turn) + * Slot 3 — last block of `messages[0]` → 1h TTL (~5 k tokens / turn) + * Slot 4 — last block of last message → tailTtl (~15 k tokens / turn) + * (rolling tail; skipped when same position as slot 3) + * + * Running this function twice on the same body produces the same result as + * running it once (idempotent). + * + * @param {object} body - Parsed /v1/messages request body + * @param {string} tailTtl - TTL for the rolling-tail slot ('5m' | '1h') + * @returns {object} New body with cache_control injected at the chosen slots + */ +function injectCacheBreakpoints(body, tailTtl = '5m') { + let result = { ...body }; + let slotsUsed = 0; + + // Slot 1: last tools entry + if (slotsUsed < MAX_CACHE_BREAKPOINTS && + Array.isArray(result.tools) && result.tools.length > 0) { + const tools = [...result.tools]; + tools[tools.length - 1] = withCacheControl(tools[tools.length - 1], { type: 'ephemeral', ttl: '1h' }); + result.tools = tools; + slotsUsed++; + } + + // Slot 2: last system block + if (slotsUsed < MAX_CACHE_BREAKPOINTS && + Array.isArray(result.system) && result.system.length > 0) { + const system = [...result.system]; + system[system.length - 1] = withCacheControl(system[system.length - 1], { type: 'ephemeral', ttl: '1h' }); + result.system = system; + slotsUsed++; + } + + // Slot 3: last block of messages[0] + const msgs = result.messages; + if (slotsUsed < MAX_CACHE_BREAKPOINTS && + Array.isArray(msgs) && msgs.length > 0 && + Array.isArray(msgs[0].content) && msgs[0].content.length > 0) { + const content = [...msgs[0].content]; + content[content.length - 1] = withCacheControl(content[content.length - 1], { type: 'ephemeral', ttl: '1h' }); + const messages = [...msgs]; + messages[0] = { ...msgs[0], content }; + result.messages = messages; + slotsUsed++; + } + + // Slot 4: last block of the last message (rolling tail) + // Only used when the last message is different from messages[0] (i.e. ≥2 messages). + if (slotsUsed < MAX_CACHE_BREAKPOINTS && + Array.isArray(result.messages) && result.messages.length > 1) { + const messages = result.messages; + const lastMsg = messages[messages.length - 1]; + if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) { + const content = [...lastMsg.content]; + content[content.length - 1] = withCacheControl( + content[content.length - 1], + { type: 'ephemeral', ttl: tailTtl } + ); + const newMessages = [...messages]; + newMessages[newMessages.length - 1] = { ...lastMsg, content }; + result.messages = newMessages; + slotsUsed++; + } + } + + return result; +} + +/** + * Upgrade any existing `{type: "ephemeral"}` cache breakpoints that lack a + * `ttl` field to use a 1-hour TTL — except for the rolling tail. + * + * The "rolling tail" is defined as the last cache_control block found in the + * `messages` array (scanning backwards). Because this breakpoint moves every + * turn it is kept at `tailTtl` to avoid paying the 2× cache-write surcharge + * on a breakpoint that never stabilises. + * + * Blocks that already have a `ttl` set are left unchanged. + * + * @param {object} body - Parsed /v1/messages request body + * @param {string} tailTtl - TTL for the rolling tail ('5m' | '1h') + * @returns {object} New body with upgraded ephemeral TTLs + */ +function upgradeEphemeralTtl(body, tailTtl = '5m') { + // Locate the rolling-tail position: last ephemeral cache_control in messages[] + let tailMsgIdx = -1; + let tailBlockIdx = -1; + if (Array.isArray(body.messages)) { + outer: for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i]; + if (!Array.isArray(msg.content)) continue; + for (let j = msg.content.length - 1; j >= 0; j--) { + const b = msg.content[j]; + if (b && b.cache_control && b.cache_control.type === 'ephemeral') { + tailMsgIdx = i; + tailBlockIdx = j; + break outer; + } + } + } + } + + let result = { ...body }; + + // Upgrade tools — these are always static, so always use 1h + if (Array.isArray(result.tools)) { + const tools = result.tools.map(tool => { + if (!tool.cache_control || + tool.cache_control.type !== 'ephemeral' || + tool.cache_control.ttl) return tool; + return withCacheControl(tool, { type: 'ephemeral', ttl: '1h' }); + }); + result.tools = tools; + } + + // Upgrade system blocks — also static, always use 1h + if (Array.isArray(result.system)) { + const system = result.system.map(block => { + if (!block.cache_control || + block.cache_control.type !== 'ephemeral' || + block.cache_control.ttl) return block; + return withCacheControl(block, { type: 'ephemeral', ttl: '1h' }); + }); + result.system = system; + } + + // Upgrade messages — tail keeps tailTtl; everything else gets 1h + if (Array.isArray(result.messages)) { + const messages = result.messages.map((msg, mi) => { + if (!Array.isArray(msg.content)) return msg; + const content = msg.content.map((block, bi) => { + if (!block || + !block.cache_control || + block.cache_control.type !== 'ephemeral' || + block.cache_control.ttl) return block; + const isTail = (mi === tailMsgIdx && bi === tailBlockIdx); + return withCacheControl(block, { type: 'ephemeral', ttl: isTail ? tailTtl : '1h' }); + }); + return { ...msg, content }; + }); + result.messages = messages; + } + + return result; +} + +module.exports = { + withCacheControl, + injectCacheBreakpoints, + upgradeEphemeralTtl, + MAX_CACHE_BREAKPOINTS, + EXTENDED_CACHE_BETA, +}; diff --git a/containers/api-proxy/transforms/tool-drop.js b/containers/api-proxy/transforms/tool-drop.js new file mode 100644 index 000000000..bb92d4012 --- /dev/null +++ b/containers/api-proxy/transforms/tool-drop.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * Tool-dropping utilities for the AWF Anthropic adapter. + * + * Removes named tools from the `tools` array and scrubs their names from + * `system` prompt text blocks. Independent of caching: with caching in + * place, dropping tools also shrinks each cache-write slot. + */ + +/** + * Build a regex that matches any of the given tool names as whole words + * (not as substrings of longer identifiers). + * + * Note: JavaScript's `g`-flag RegExp objects track `lastIndex` but + * `String.prototype.replace` resets it to 0 before each use, so the + * same compiled pattern is safe to reuse across multiple calls. + * + * @param {string[]} toolNames + * @returns {RegExp} + */ +function buildToolScrubPattern(toolNames) { + const escaped = toolNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`(? !dropSet.has(tool.name)); + if (filtered.length < result.tools.length) { + if (filtered.length === 0) { + result = { ...result }; + delete result.tools; + } else { + result.tools = filtered; + } + } + } + + // Scrub tool-name references from system-prompt text blocks. + // We remove bare occurrences; surrounding punctuation/whitespace is left intact + // to avoid corrupting sentence structure. + if (Array.isArray(result.system)) { + const pattern = scrubPattern || buildToolScrubPattern([...dropSet]); + result.system = result.system.map(block => { + if (block.type !== 'text' || typeof block.text !== 'string') return block; + const scrubbed = block.text.replace(pattern, ''); + return scrubbed === block.text ? block : { ...block, text: scrubbed }; + }); + } + + return result; +} + +module.exports = { buildToolScrubPattern, applyToolDrop }; From 853a1fdd7284108c869bcd8828017740e4b38fe0 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 20 May 2026 17:20:30 -0700 Subject: [PATCH 3/4] 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> --- containers/api-proxy/anthropic-transforms.js | 1 - 1 file changed, 1 deletion(-) diff --git a/containers/api-proxy/anthropic-transforms.js b/containers/api-proxy/anthropic-transforms.js index b62cc951d..c556d4db6 100644 --- a/containers/api-proxy/anthropic-transforms.js +++ b/containers/api-proxy/anthropic-transforms.js @@ -26,7 +26,6 @@ const path = require('path'); const { stripAnsi, applyAnsiStrip } = require('./transforms/ansi-strip'); const { - withCacheControl, injectCacheBreakpoints, upgradeEphemeralTtl, MAX_CACHE_BREAKPOINTS, From be36ece1e1b63147d48311442763c21fbe9bb12b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 00:24:06 +0000 Subject: [PATCH 4/4] fix: harden anthropic transforms against malformed entries --- containers/api-proxy/Dockerfile | 1 + .../api-proxy/anthropic-transforms.test.js | 37 +++++++++++++++++++ containers/api-proxy/transforms/ansi-strip.js | 3 ++ .../api-proxy/transforms/cache-control.js | 34 ++++++++++++----- containers/api-proxy/transforms/tool-drop.js | 6 ++- 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 0c71ff1e1..ee8514633 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -25,6 +25,7 @@ COPY server.js logging.js metrics.js rate-limiter.js \ oidc-refresh-utils.js body-transform.js rate-limit.js websocket-proxy.js ./ COPY guards/ ./guards/ COPY providers/ ./providers/ +COPY transforms/ ./transforms/ # Create non-root user RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy diff --git a/containers/api-proxy/anthropic-transforms.test.js b/containers/api-proxy/anthropic-transforms.test.js index 47073056d..740e3fc99 100644 --- a/containers/api-proxy/anthropic-transforms.test.js +++ b/containers/api-proxy/anthropic-transforms.test.js @@ -174,6 +174,17 @@ describe('applyAnsiStrip', () => { expect(result).not.toBe(body); expect(body.messages[0].content[0].content).toBe('\x1B[0m'); // original untouched }); + + it('ignores malformed message/content entries without throwing', () => { + const body = { + messages: [null, { role: 'user', content: [null, { type: 'text', text: 'ok' }] }], + }; + expect(() => applyAnsiStrip(body)).not.toThrow(); + const result = applyAnsiStrip(body); + expect(result.messages[0]).toBeNull(); + expect(result.messages[1].content[0]).toBeNull(); + expect(result.messages[1].content[1]).toEqual({ type: 'text', text: 'ok' }); + }); }); // ── applyToolDrop ───────────────────────────────────────────────────────────── @@ -241,6 +252,18 @@ describe('applyToolDrop', () => { expect(result.system[0].text).not.toContain('NotebookEdit'); expect(result.tools).toBeUndefined(); }); + + it('ignores malformed tool/system entries without throwing', () => { + const body = { + messages: [], + tools: [null, { name: 'NotebookEdit' }], + system: [null, { type: 'text', text: 'Use NotebookEdit.' }], + }; + expect(() => applyToolDrop(body, ['NotebookEdit'])).not.toThrow(); + const result = applyToolDrop(body, ['NotebookEdit']); + expect(result.tools).toEqual([null]); + expect(result.system).toEqual([null, { type: 'text', text: 'Use .' }]); + }); }); // ── buildToolScrubPattern ───────────────────────────────────────────────────── @@ -398,6 +421,11 @@ describe('injectCacheBreakpoints', () => { const body = { messages: [] }; expect(() => injectCacheBreakpoints(body)).not.toThrow(); }); + + it('handles malformed message entries gracefully', () => { + const body = { messages: [null, { role: 'user', content: [{ type: 'text', text: 'ok' }] }] }; + expect(() => injectCacheBreakpoints(body)).not.toThrow(); + }); }); // Helper: count total cache_control breakpoints in a body @@ -497,6 +525,15 @@ describe('upgradeEphemeralTtl', () => { const twice = upgradeEphemeralTtl(once, '5m'); expect(JSON.stringify(twice)).toBe(JSON.stringify(once)); }); + + it('handles malformed entries without throwing', () => { + const body = { + tools: [null, { name: 'T', cache_control: { type: 'ephemeral' } }], + system: [null, { type: 'text', cache_control: { type: 'ephemeral' } }], + messages: [null, { role: 'user', content: [null, makeBlock('tail', { type: 'ephemeral' })] }], + }; + expect(() => upgradeEphemeralTtl(body, '5m')).not.toThrow(); + }); }); // ── makeAnthropicTransform ──────────────────────────────────────────────────── diff --git a/containers/api-proxy/transforms/ansi-strip.js b/containers/api-proxy/transforms/ansi-strip.js index e884122de..fcd98b194 100644 --- a/containers/api-proxy/transforms/ansi-strip.js +++ b/containers/api-proxy/transforms/ansi-strip.js @@ -37,9 +37,11 @@ function applyAnsiStrip(body) { if (!Array.isArray(body.messages)) return body; const messages = body.messages.map(msg => { + if (!msg || typeof msg !== 'object') return msg; if (!Array.isArray(msg.content)) return msg; const content = msg.content.map(block => { + if (!block || typeof block !== 'object') return block; if (block.type !== 'tool_result') return block; // tool_result.content may be a plain string … @@ -50,6 +52,7 @@ function applyAnsiStrip(body) { // … or an array of typed sub-blocks if (Array.isArray(block.content)) { const inner = block.content.map(b => { + if (!b || typeof b !== 'object') return b; if (b.type === 'text' && typeof b.text === 'string') { return { ...b, text: stripAnsi(b.text) }; } diff --git a/containers/api-proxy/transforms/cache-control.js b/containers/api-proxy/transforms/cache-control.js index 14feff94a..2117ebbd0 100644 --- a/containers/api-proxy/transforms/cache-control.js +++ b/containers/api-proxy/transforms/cache-control.js @@ -29,6 +29,7 @@ const EXTENDED_CACHE_BETA = 'extended-cache-ttl-2025-04-11'; * @returns {object} */ function withCacheControl(block, cacheControl) { + if (!block || typeof block !== 'object') return block; return { ...block, cache_control: cacheControl }; } @@ -59,29 +60,37 @@ function injectCacheBreakpoints(body, tailTtl = '5m') { if (slotsUsed < MAX_CACHE_BREAKPOINTS && Array.isArray(result.tools) && result.tools.length > 0) { const tools = [...result.tools]; - tools[tools.length - 1] = withCacheControl(tools[tools.length - 1], { type: 'ephemeral', ttl: '1h' }); - result.tools = tools; - slotsUsed++; + const lastTool = tools[tools.length - 1]; + if (lastTool && typeof lastTool === 'object') { + tools[tools.length - 1] = withCacheControl(lastTool, { type: 'ephemeral', ttl: '1h' }); + result.tools = tools; + slotsUsed++; + } } // Slot 2: last system block if (slotsUsed < MAX_CACHE_BREAKPOINTS && Array.isArray(result.system) && result.system.length > 0) { const system = [...result.system]; - system[system.length - 1] = withCacheControl(system[system.length - 1], { type: 'ephemeral', ttl: '1h' }); - result.system = system; - slotsUsed++; + const lastSystemBlock = system[system.length - 1]; + if (lastSystemBlock && typeof lastSystemBlock === 'object') { + system[system.length - 1] = withCacheControl(lastSystemBlock, { type: 'ephemeral', ttl: '1h' }); + result.system = system; + slotsUsed++; + } } // Slot 3: last block of messages[0] const msgs = result.messages; + const firstMsg = Array.isArray(msgs) && msgs.length > 0 ? msgs[0] : null; if (slotsUsed < MAX_CACHE_BREAKPOINTS && Array.isArray(msgs) && msgs.length > 0 && - Array.isArray(msgs[0].content) && msgs[0].content.length > 0) { - const content = [...msgs[0].content]; + firstMsg && typeof firstMsg === 'object' && + Array.isArray(firstMsg.content) && firstMsg.content.length > 0) { + const content = [...firstMsg.content]; content[content.length - 1] = withCacheControl(content[content.length - 1], { type: 'ephemeral', ttl: '1h' }); const messages = [...msgs]; - messages[0] = { ...msgs[0], content }; + messages[0] = { ...firstMsg, content }; result.messages = messages; slotsUsed++; } @@ -92,7 +101,8 @@ function injectCacheBreakpoints(body, tailTtl = '5m') { Array.isArray(result.messages) && result.messages.length > 1) { const messages = result.messages; const lastMsg = messages[messages.length - 1]; - if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) { + if (lastMsg && typeof lastMsg === 'object' && + Array.isArray(lastMsg.content) && lastMsg.content.length > 0) { const content = [...lastMsg.content]; content[content.length - 1] = withCacheControl( content[content.length - 1], @@ -130,6 +140,7 @@ function upgradeEphemeralTtl(body, tailTtl = '5m') { if (Array.isArray(body.messages)) { outer: for (let i = body.messages.length - 1; i >= 0; i--) { const msg = body.messages[i]; + if (!msg || typeof msg !== 'object') continue; if (!Array.isArray(msg.content)) continue; for (let j = msg.content.length - 1; j >= 0; j--) { const b = msg.content[j]; @@ -147,6 +158,7 @@ function upgradeEphemeralTtl(body, tailTtl = '5m') { // Upgrade tools — these are always static, so always use 1h if (Array.isArray(result.tools)) { const tools = result.tools.map(tool => { + if (!tool || typeof tool !== 'object') return tool; if (!tool.cache_control || tool.cache_control.type !== 'ephemeral' || tool.cache_control.ttl) return tool; @@ -158,6 +170,7 @@ function upgradeEphemeralTtl(body, tailTtl = '5m') { // Upgrade system blocks — also static, always use 1h if (Array.isArray(result.system)) { const system = result.system.map(block => { + if (!block || typeof block !== 'object') return block; if (!block.cache_control || block.cache_control.type !== 'ephemeral' || block.cache_control.ttl) return block; @@ -169,6 +182,7 @@ function upgradeEphemeralTtl(body, tailTtl = '5m') { // Upgrade messages — tail keeps tailTtl; everything else gets 1h if (Array.isArray(result.messages)) { const messages = result.messages.map((msg, mi) => { + if (!msg || typeof msg !== 'object') return msg; if (!Array.isArray(msg.content)) return msg; const content = msg.content.map((block, bi) => { if (!block || diff --git a/containers/api-proxy/transforms/tool-drop.js b/containers/api-proxy/transforms/tool-drop.js index bb92d4012..94e427a7f 100644 --- a/containers/api-proxy/transforms/tool-drop.js +++ b/containers/api-proxy/transforms/tool-drop.js @@ -44,7 +44,10 @@ function applyToolDrop(body, toolNames, scrubPattern = null) { // Remove matching entries from the tools array if (Array.isArray(result.tools)) { - const filtered = result.tools.filter(tool => !dropSet.has(tool.name)); + const filtered = result.tools.filter(tool => { + if (!tool || typeof tool !== 'object') return true; + return !dropSet.has(tool.name); + }); if (filtered.length < result.tools.length) { if (filtered.length === 0) { result = { ...result }; @@ -61,6 +64,7 @@ function applyToolDrop(body, toolNames, scrubPattern = null) { if (Array.isArray(result.system)) { const pattern = scrubPattern || buildToolScrubPattern([...dropSet]); result.system = result.system.map(block => { + if (!block || typeof block !== 'object') return block; if (block.type !== 'text' || typeof block.text !== 'string') return block; const scrubbed = block.text.replace(pattern, ''); return scrubbed === block.text ? block : { ...block, text: scrubbed };