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 d3f83027..43d054c5 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 @@ -1,11 +1,14 @@ package vip.mate.agent.chatmodel; +import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; 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; @@ -63,6 +66,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { private final ObjectProvider restClientBuilderProvider; private final ObjectProvider webClientBuilderProvider; private final ObjectProvider observationRegistryProvider; + private final ObjectMapper objectMapper; public AgentClaudeCodeChatModelBuilder( AgentAnthropicChatModelBuilder anthropicBuilder, @@ -70,13 +74,15 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { ClaudeCodeApiHeaders apiHeaders, ObjectProvider restClientBuilderProvider, ObjectProvider webClientBuilderProvider, - ObjectProvider observationRegistryProvider) { + ObjectProvider observationRegistryProvider, + ObjectMapper objectMapper) { this.anthropicBuilder = anthropicBuilder; this.oauthService = oauthService; this.apiHeaders = apiHeaders; this.restClientBuilderProvider = restClientBuilderProvider; this.webClientBuilderProvider = webClientBuilderProvider; this.observationRegistryProvider = observationRegistryProvider; + this.objectMapper = objectMapper; } @Override @@ -98,6 +104,17 @@ 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) @@ -109,7 +126,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); + return new ClaudeCodeIdentityChatModelDecorator(raw, oauthCacheOptions); } /** @@ -137,6 +154,10 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { .defaultHeader(HttpHeaders.ACCEPT, "application/json") .defaultHeader("anthropic-dangerous-direct-browser-access", "true") .defaultHeader("x-app", xApp) + // Rewrite system string → array before the request hits the wire. + // Anthropic's OAuth anti-abuse gate requires system to be an array; + // see ClaudeCodeSystemArrayInterceptor for the full explanation. + .requestInterceptor(new ClaudeCodeSystemArrayInterceptor(objectMapper)) // Diagnostic: log Anthropic's rate-limit headers on 429 so we // can tell apart "5h Pro quota exhausted" (tokens-remaining=0, // retry-after huge) from "anti-abuse gate" (tokens-remaining @@ -150,6 +171,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { .defaultHeader(HttpHeaders.ACCEPT, "application/json") .defaultHeader("anthropic-dangerous-direct-browser-access", "true") .defaultHeader("x-app", xApp) + // Rewrite system string → array (streaming path counterpart). + .filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper)) .filter(new RateLimitDiagnosticExchangeFilter()); // NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing 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 9d64d422..a1d668d6 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,6 +2,7 @@ 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; @@ -75,8 +76,23 @@ 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 @@ -112,7 +128,15 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel { boolean systemSeen = false; for (Message msg : source) { if (msg instanceof SystemMessage sm && !systemSeen) { - rewritten.add(new SystemMessage(prependIdentity(sanitizeBranding(sm.getText())))); + // Emit identity as its own block so Spring AI serialises system as an + // array. Anthropic's OAuth anti-abuse gate 429s when the identity prefix + // and additional content are merged into a single string, but accepts + // them as separate array elements (verified 2026-04-25). + rewritten.add(new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX)); + String sanitized = sanitizeBranding(sm.getText()); + if (sanitized != null && !sanitized.isBlank()) { + rewritten.add(new SystemMessage(sanitized)); + } systemSeen = true; } else if (msg instanceof AssistantMessage am && am.hasToolCalls()) { // Re-prefix tool_use names in history. We strip on response, so @@ -152,6 +176,13 @@ 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 new file mode 100644 index 00000000..a7352a7a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java @@ -0,0 +1,91 @@ +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; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.client.reactive.ClientHttpRequestDecorator; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +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. + */ +@Slf4j +@RequiredArgsConstructor +class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction { + + private final ObjectMapper objectMapper; + + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + ClientRequest intercepted = ClientRequest.from(request) + .body((outputMessage, context) -> request.body().insert( + 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); + + // 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)); + }); + } + }, context)) + .build(); + + 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 new file mode 100644 index 00000000..5e88aef2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java @@ -0,0 +1,97 @@ +package vip.mate.agent.chatmodel; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** + * RestClient interceptor that rewrites the Anthropic {@code system} field from + * a plain string to a two-element content-block array before the request hits + * 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). + * + *

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. + * + *

Sync (RestClient) variant; the WebFlux equivalent is + * {@link ClaudeCodeSystemArrayExchangeFilter}. + */ +@Slf4j +@RequiredArgsConstructor +class ClaudeCodeSystemArrayInterceptor implements ClientHttpRequestInterceptor { + + private final ObjectMapper objectMapper; + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + return execution.execute(request, rewriteSystemField(body)); + } + + /** + * If {@code body} is a JSON object whose {@code system} field is a string, + * replace it with a two-element content-block array: + *

+     * [ {"type":"text","text":"You are Claude Code..."}, {"type":"text","text":""} ]
+     * 
+ * Returns {@code body} unchanged on any error or if rewrite is not needed. + */ + 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; // absent or already array + byte[] rewritten = objectMapper.writeValueAsBytes( + 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; + } + } + + static ObjectNode buildRewritten(ObjectNode root, String systemText) { + String identity = ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX; + ArrayNode arr = root.arrayNode(); + + ObjectNode identityBlock = arr.objectNode(); + identityBlock.put("type", "text"); + 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()) { + ObjectNode contentBlock = arr.objectNode(); + contentBlock.put("type", "text"); + contentBlock.put("text", rest); + arr.add(contentBlock); + } + } + + ObjectNode copy = root.deepCopy(); + copy.set("system", arr); + return copy; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java index 1f0c969c..f3c0a2da 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java @@ -1,91 +1,66 @@ package vip.mate.agent.chatmodel; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.client.reactive.ClientHttpRequestDecorator; import org.springframework.web.reactive.function.client.ClientRequest; import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.util.List; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; /** - * RFC-062 diagnostic helper for the streaming (WebClient) path. Mirrors - * {@link RateLimitDiagnosticInterceptor} for non-streaming RestClient calls — - * see that class's javadoc for how to interpret the logged headers. + * WebClient (streaming) counterpart of {@link RateLimitDiagnosticInterceptor}. * - *

Stream calls are where most chat traffic flows, so without this filter - * we wouldn't see rate-limit metadata for the most common 429 path. + *

On 429, logs outgoing request headers (sanitized), a body preview captured + * non-destructively via {@link DataBuffer#toByteBuffer(int, int)}, and the + * {@code anthropic-ratelimit-*} response headers. Delegates constant and + * formatting logic to the shared statics on {@link RateLimitDiagnosticInterceptor}. */ @Slf4j class RateLimitDiagnosticExchangeFilter implements ExchangeFilterFunction { - /** Anthropic's documented rate-limit response headers. */ - private static final List RATE_LIMIT_HEADERS = List.of( - "anthropic-ratelimit-requests-limit", - "anthropic-ratelimit-requests-remaining", - "anthropic-ratelimit-requests-reset", - "anthropic-ratelimit-tokens-limit", - "anthropic-ratelimit-tokens-remaining", - "anthropic-ratelimit-tokens-reset", - "anthropic-ratelimit-input-tokens-limit", - "anthropic-ratelimit-input-tokens-remaining", - "anthropic-ratelimit-input-tokens-reset", - "anthropic-ratelimit-output-tokens-limit", - "anthropic-ratelimit-output-tokens-remaining", - "anthropic-ratelimit-output-tokens-reset", - "retry-after"); - @Override public Mono filter(ClientRequest request, ExchangeFunction next) { - return next.exchange(request).doOnNext(response -> { + AtomicReference capturedBody = new AtomicReference<>(); + + ClientRequest intercepted = ClientRequest.from(request) + .body((outputMessage, context) -> request.body().insert( + new ClientHttpRequestDecorator(outputMessage) { + @Override + public Mono writeWith(Publisher body) { + return super.writeWith( + Flux.from(body).doOnNext(buf -> { + if (capturedBody.get() == null) { + int len = Math.min(buf.readableByteCount(), + RateLimitDiagnosticInterceptor.BODY_LOG_LIMIT); + ByteBuffer view = buf.toByteBuffer(buf.readPosition(), len); + byte[] bytes = new byte[len]; + view.get(bytes); + capturedBody.compareAndSet(null, + new String(bytes, StandardCharsets.UTF_8)); + } + }) + ); + } + }, context)) + .build(); + + return next.exchange(intercepted).doOnNext(response -> { if (response.statusCode().value() == 429) { - // Log REQUEST headers too — Spring AI's AnthropicApi.Builder - // calls clone() + defaultHeaders(consumer) on the rest/web - // client builder we hand in, and we want to verify our OAuth - // fingerprint headers actually survived that flow. If they - // didn't, no amount of correct fingerprinting fixes it. - logRequestHeaders(request.headers()); - logHeaders(response.headers().asHttpHeaders()); + RateLimitDiagnosticInterceptor.logRequestHeaders(request.headers()); + String preview = capturedBody.get(); + log.warn("[Anthropic 429] request body preview: {}", + preview != null ? preview : "(not captured)"); + RateLimitDiagnosticInterceptor.logResponseHeaders( + response.headers().asHttpHeaders()); } }); } - - private static void logRequestHeaders(HttpHeaders requestHeaders) { - StringBuilder sb = new StringBuilder("[Anthropic 429] outgoing request headers (sanitized): "); - boolean any = false; - for (var entry : requestHeaders.entrySet()) { - String name = entry.getKey().toLowerCase(); - // Skip Authorization — never log Bearer tokens. Just show "Bearer ". - String displayValue; - if (name.equals("authorization")) { - displayValue = "Bearer "; - } else { - displayValue = String.join(",", entry.getValue()); - } - if (any) sb.append(", "); - sb.append(name).append('=').append(displayValue); - any = true; - } - log.warn(sb.toString()); - } - - private static void logHeaders(HttpHeaders headers) { - StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit headers: "); - boolean any = false; - for (String name : RATE_LIMIT_HEADERS) { - String value = headers.getFirst(name); - if (value != null) { - if (any) sb.append(", "); - sb.append(name).append('=').append(value); - any = true; - } - } - if (!any) { - log.warn("[Anthropic 429] no rate-limit headers present — likely anti-abuse gate, not real quota"); - } else { - log.warn(sb.toString()); - } - } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java index a2b31caf..b0d7402e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java @@ -8,31 +8,31 @@ import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; /** - * RFC-062 diagnostic helper: when Anthropic returns 429 we want to see the - * {@code anthropic-ratelimit-*} headers in the log so we can tell apart the - * three distinct "rate limit" failure modes that all share the same - * {@code {"type":"rate_limit_error","message":"Error"}} body: + * On 429, logs the outgoing request headers (sanitized), the request body + * (first {@link #BODY_LOG_LIMIT} bytes), and the {@code anthropic-ratelimit-*} + * response headers to distinguish three failure modes: * * - * + * * * - * + * * *
How to read the headersHow to read the response headers
Failure modetokens-remainingretry-after
5h Pro/Max quota exhausted0thousands of seconds
Anti-abuse fingerprint gatelargetens of seconds
Anti-abuse fingerprint gate(absent)(absent)
Per-minute burst limitlargesingle-digit seconds
* - *

Spring AI logs the body but not the headers, so we'd be debugging blind - * without this. Sync (RestClient) variant; the WebFlux equivalent lives in + *

Sync (RestClient) variant; the WebFlux equivalent is * {@link RateLimitDiagnosticExchangeFilter}. */ @Slf4j class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor { - /** Anthropic's documented rate-limit response headers. */ - private static final List RATE_LIMIT_HEADERS = List.of( + static final int BODY_LOG_LIMIT = 16384; + + static final List RATE_LIMIT_HEADERS = List.of( "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset", @@ -47,36 +47,56 @@ class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor { "anthropic-ratelimit-output-tokens-reset", "retry-after"); + static final List REQUEST_HEADERS_TO_LOG = List.of( + "authorization", + "user-agent", + "accept", + "x-app", + "anthropic-beta", + "anthropic-version", + "anthropic-dangerous-direct-browser-access"); + @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { ClientHttpResponse response = execution.execute(request, body); if (response.getStatusCode().value() == 429) { - // Log REQUEST headers too so we can verify Spring AI didn't strip - // our OAuth fingerprint when it cloned the rest client builder. logRequestHeaders(request.getHeaders()); - logHeaders(response.getHeaders()); + logRequestBody(body); + logResponseHeaders(response.getHeaders()); } return response; } - private static void logRequestHeaders(HttpHeaders requestHeaders) { + static void logRequestHeaders(HttpHeaders headers) { StringBuilder sb = new StringBuilder("[Anthropic 429] outgoing request headers (sanitized): "); - boolean any = false; - for (var entry : requestHeaders.entrySet()) { - String name = entry.getKey().toLowerCase(); - String displayValue = name.equals("authorization") - ? "Bearer " - : String.join(",", entry.getValue()); - if (any) sb.append(", "); - sb.append(name).append('=').append(displayValue); - any = true; + boolean first = true; + for (String name : REQUEST_HEADERS_TO_LOG) { + String value = headers.getFirst(name); + if (value == null) continue; + if (!first) sb.append(", "); + first = false; + if ("authorization".equalsIgnoreCase(name) && value.startsWith("Bearer ")) { + sb.append(name).append("=Bearer "); + } else { + sb.append(name).append('=').append(value); + } } log.warn(sb.toString()); } - private static void logHeaders(HttpHeaders headers) { - StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit headers: "); + static void logRequestBody(byte[] body) { + if (body == null || body.length == 0) { + log.warn("[Anthropic 429] request body: (empty)"); + return; + } + int len = Math.min(body.length, BODY_LOG_LIMIT); + log.warn("[Anthropic 429] request body (first {} of {} bytes): {}", + len, body.length, new String(body, 0, len, StandardCharsets.UTF_8)); + } + + static void logResponseHeaders(HttpHeaders headers) { + StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit response headers: "); boolean any = false; for (String name : RATE_LIMIT_HEADERS) { String value = headers.getFirst(name); @@ -87,10 +107,7 @@ class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor { } } if (!any) { - // Anthropic still returned 429 but didn't include any rate-limit - // headers — strong signal of the anti-abuse path (it generally - // doesn't bother filling them in). - log.warn("[Anthropic 429] no rate-limit headers present — likely anti-abuse gate, not real quota"); + log.warn("[Anthropic 429] no rate-limit response headers — likely anti-abuse gate, not real quota"); } else { log.warn(sb.toString()); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java index 60864e00..1d846e54 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java @@ -48,12 +48,12 @@ public class ClaudeCodeApiHeaders { /** * Comma-joined beta header list to send in {@code anthropic-beta}. - *

Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS + - * _COMMON_BETAS} (OAuth-specific betas first). + *

Order matches hermes-agent {@code anthropic_adapter} line 427: + * {@code common_betas + _OAUTH_ONLY_BETAS} — common betas first, OAuth betas appended. */ public String allBetas() { return String.join(",", - concat(OAUTH_ONLY_BETAS, COMMON_BETAS)); + concat(COMMON_BETAS, OAUTH_ONLY_BETAS)); } /**