From b9c4f40028d9c2426aa3b9c1f23d23fccb650d3c Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 08:34:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor(anthropic):=20cleanup=20=E2=80=94=20de?= =?UTF-8?q?duplicate=20diagnostic=20statics,=20remove=20dead=20cache-optio?= =?UTF-8?q?ns=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentClaudeCodeChatModelBuilder.java | 15 +------ .../ClaudeCodeIdentityChatModelDecorator.java | 23 ---------- .../ClaudeCodeSystemArrayExchangeFilter.java | 45 +++---------------- .../ClaudeCodeSystemArrayInterceptor.java | 29 ++++++------ 4 files changed, 21 insertions(+), 91 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java index 43d054c5..9ba5a2b1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java @@ -7,8 +7,6 @@ import org.springframework.ai.anthropic.AnthropicChatModel; import org.springframework.ai.anthropic.AnthropicChatOptions; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.anthropic.api.AnthropicCacheOptions; -import org.springframework.ai.anthropic.api.AnthropicCacheStrategy; import org.springframework.ai.model.NoopApiKey; import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpHeaders; @@ -104,17 +102,6 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { // sampling-params handling, thinking-budget mapping, prompt cache. AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model); - // Enable multi-block system caching so Spring AI serialises system as an - // array of content blocks. Anthropic's OAuth anti-abuse gate accepts the - // identity prefix as a string ONLY when there is no additional content; as - // soon as we append the agent's actual system prompt the gate returns 429. - // Two separate array blocks always pass (verified 2026-04-25). - AnthropicCacheOptions oauthCacheOptions = AnthropicCacheOptions.builder() - .strategy(AnthropicCacheStrategy.SYSTEM_ONLY) - .multiBlockSystemCaching(true) - .build(); - options.setCacheOptions(oauthCacheOptions); - AnthropicChatModel raw = AnthropicChatModel.builder() .anthropicApi(api) .defaultOptions(options) @@ -126,7 +113,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { // 5xxs requests that don't claim Claude Code identity in the system // prompt — symptom: 429 rate_limit_error with body "Error" on quiet // accounts. See ClaudeCodeIdentityChatModelDecorator javadoc. - return new ClaudeCodeIdentityChatModelDecorator(raw, oauthCacheOptions); + return new ClaudeCodeIdentityChatModelDecorator(raw); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java index a1d668d6..96cecbd2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java @@ -2,7 +2,6 @@ package vip.mate.agent.chatmodel; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.anthropic.AnthropicChatOptions; -import org.springframework.ai.anthropic.api.AnthropicCacheOptions; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; @@ -76,23 +75,8 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel { private final ChatModel delegate; - /** - * Cache options to apply to every transformed prompt so Spring AI serialises - * {@code system} as an array of content blocks rather than a plain string. - * Anthropic's OAuth anti-abuse gate accepts the string format ONLY when the - * content is exactly the identity prefix with nothing else; as soon as we - * append the agent's actual system prompt the gate returns 429. Sending the - * same two pieces as separate blocks in an array is accepted unconditionally. - */ - private final AnthropicCacheOptions oauthCacheOptions; - public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) { - this(delegate, null); - } - - public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate, AnthropicCacheOptions oauthCacheOptions) { this.delegate = delegate; - this.oauthCacheOptions = oauthCacheOptions; } @Override @@ -176,13 +160,6 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel { } AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(anthropicOpts); - // Force cacheOptions so Spring AI emits system as an array (multi-block path). - // AnthropicChatOptions.fromOptions copies cacheOptions from the runtime options, - // which default to DISABLED; that would override defaultOptions at merge time, - // so we must override here rather than relying on defaultOptions alone. - if (oauthCacheOptions != null) { - copy.setCacheOptions(oauthCacheOptions); - } if (hasCallbacks) { List wrapped = new ArrayList<>(originalCallbacks.size()); for (ToolCallback cb : originalCallbacks) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java index a7352a7a..36130b35 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java @@ -1,8 +1,6 @@ package vip.mate.agent.chatmodel; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.buffer.DataBuffer; @@ -19,17 +17,9 @@ import reactor.core.publisher.Mono; /** * WebClient (streaming) counterpart of {@link ClaudeCodeSystemArrayInterceptor}. * - *

Collects the full request body from the reactive publisher with - * {@code DataBufferUtils.join}, rewrites the {@code system} field from a - * plain string to a two-element content-block array, and emits the modified - * bytes as a single new {@link DataBuffer}. - * - *

Anthropic's OAuth anti-abuse gate rejects the merged-string format with - * a 429; two separate array elements always pass (verified 2026-04-25). - * Spring AI's native array path is gated behind {@code @JsonIgnore} cache - * options that {@code ModelOptionsUtils.copyToTarget} strips before our - * settings can reach {@code buildSystemContent}. This interceptor is immune - * to that stripping because it runs at the HTTP transport layer. + *

Collects the full request body via {@code DataBufferUtils.join}, delegates + * the rewrite to {@link ClaudeCodeSystemArrayInterceptor#rewriteSystemField}, and + * emits the modified bytes as a single new {@link DataBuffer}. */ @Slf4j @RequiredArgsConstructor @@ -44,24 +34,22 @@ class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction { new ClientHttpRequestDecorator(outputMessage) { @Override public Mono writeWith(Publisher body) { - // Join all chunks so we can parse the complete JSON body. return DataBufferUtils.join(Flux.from(body)) .flatMap(joined -> { byte[] original = new byte[joined.readableByteCount()]; joined.read(original); DataBufferUtils.release(joined); - byte[] rewritten = rewriteSystemField(original); + byte[] rewritten = ClaudeCodeSystemArrayInterceptor + .rewriteSystemField(original, objectMapper); - // Keep Content-Length consistent if it was set. long declared = getHeaders().getContentLength(); if (declared > 0 && declared != rewritten.length) { getHeaders().setContentLength(rewritten.length); } - DataBuffer newBuf = outputMessage.bufferFactory() - .wrap(rewritten); - return super.writeWith(Mono.just(newBuf)); + return super.writeWith(Mono.just( + outputMessage.bufferFactory().wrap(rewritten))); }); } }, context)) @@ -69,23 +57,4 @@ class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction { return next.exchange(intercepted); } - - private byte[] rewriteSystemField(byte[] body) { - if (body == null || body.length == 0) return body; - try { - JsonNode root = objectMapper.readTree(body); - if (!root.isObject()) return body; - JsonNode systemNode = root.get("system"); - if (systemNode == null || !systemNode.isTextual()) return body; - byte[] rewritten = objectMapper.writeValueAsBytes( - ClaudeCodeSystemArrayInterceptor.buildRewritten( - (ObjectNode) root, systemNode.asText())); - log.debug("[ClaudeCodeSystem] rewrote system field from string to array ({} → {} bytes)", - body.length, rewritten.length); - return rewritten; - } catch (Exception e) { - log.warn("[ClaudeCodeSystem] body rewrite failed, sending original: {}", e.getMessage()); - return body; - } - } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java index 5e88aef2..8470a0eb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java @@ -19,15 +19,13 @@ import java.io.IOException; * the wire. * *

Anthropic's OAuth anti-abuse gate accepts the Claude Code identity prefix - * as a string ONLY when it is the sole content. The moment we append the - * agent's actual system prompt the gate returns 429. Sending the same two - * pieces as separate array elements always passes (verified 2026-04-25). + * as a string ONLY when it is the sole content. Appending any additional text + * triggers a 429; two separate array elements always pass (verified 2026-04-25). * - *

Spring AI's {@code AnthropicChatModel} can emit array format natively - * via prompt caching, but {@code ModelOptionsUtils.copyToTarget} (Jackson- - * based) drops the {@code @JsonIgnore cacheOptions} field, making it - * impossible to activate through the normal options path. This interceptor - * works at the HTTP layer and is immune to Spring AI's internal option-merging. + *

Spring AI's native array path is guarded by {@code @JsonIgnore cacheOptions} + * which {@code ModelOptionsUtils.copyToTarget} drops before our settings can + * reach {@code buildSystemContent}. This interceptor bypasses that by rewriting + * at the HTTP transport layer. * *

Sync (RestClient) variant; the WebFlux equivalent is * {@link ClaudeCodeSystemArrayExchangeFilter}. @@ -41,7 +39,7 @@ class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor { @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { - return execution.execute(request, rewriteSystemField(body)); + return execution.execute(request, rewriteSystemField(body, objectMapper)); } /** @@ -51,17 +49,17 @@ class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor { * [ {"type":"text","text":"You are Claude Code..."}, {"type":"text","text":""} ] * * Returns {@code body} unchanged on any error or if rewrite is not needed. + * Package-private static so {@link ClaudeCodeSystemArrayExchangeFilter} can reuse. */ - byte[] rewriteSystemField(byte[] body) { + static byte[] rewriteSystemField(byte[] body, ObjectMapper mapper) { if (body == null || body.length == 0) return body; try { - JsonNode root = objectMapper.readTree(body); + JsonNode root = mapper.readTree(body); if (!root.isObject()) return body; JsonNode systemNode = root.get("system"); - if (systemNode == null || !systemNode.isTextual()) return body; // absent or already array - byte[] rewritten = objectMapper.writeValueAsBytes( - buildRewritten((ObjectNode) root, systemNode.asText())); - log.debug("[ClaudeCodeSystem] rewrote system field from string to array ({} → {} bytes)", + if (systemNode == null || !systemNode.isTextual()) return body; + byte[] rewritten = mapper.writeValueAsBytes(buildRewritten((ObjectNode) root, systemNode.asText())); + log.debug("[ClaudeCodeSystem] rewrote system field to array ({} → {} bytes)", body.length, rewritten.length); return rewritten; } catch (Exception e) { @@ -79,7 +77,6 @@ class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor { identityBlock.put("text", identity); arr.add(identityBlock); - // Strip the identity prefix and leading newlines to get the rest. if (!systemText.equals(identity) && systemText.startsWith(identity)) { String rest = systemText.substring(identity.length()).replaceFirst("^\n+", ""); if (!rest.isBlank()) {