refactor(anthropic): cleanup — deduplicate diagnostic statics, remove dead cache-options code

This commit is contained in:
matevip 2026-04-26 08:34:12 +08:00
parent dbdb585eed
commit b9c4f40028
4 changed files with 21 additions and 91 deletions

View File

@ -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);
}
/**

View File

@ -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<ToolCallback> wrapped = new ArrayList<>(originalCallbacks.size());
for (ToolCallback cb : originalCallbacks) {

View File

@ -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}.
*
* <p>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}.
*
* <p>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.
* <p>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<Void> writeWith(Publisher<? extends DataBuffer> 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;
}
}
}

View File

@ -19,15 +19,13 @@ import java.io.IOException;
* the wire.
*
* <p>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).
*
* <p>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.
* <p>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.
*
* <p>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":"<rest>"} ]
* </pre>
* 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()) {