fix(anthropic): rewrite system field to array to pass OAuth anti-abuse gate

This commit is contained in:
matevip 2026-04-26 08:34:12 +08:00
parent 5c2482c307
commit dbdb585eed
7 changed files with 337 additions and 103 deletions

View File

@ -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<RestClient.Builder> restClientBuilderProvider;
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
private final ObjectMapper objectMapper;
public AgentClaudeCodeChatModelBuilder(
AgentAnthropicChatModelBuilder anthropicBuilder,
@ -70,13 +74,15 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
ClaudeCodeApiHeaders apiHeaders,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
ObjectProvider<ObservationRegistry> 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

View File

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

View File

@ -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}.
*
* <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.
*/
@Slf4j
@RequiredArgsConstructor
class ClaudeCodeSystemArrayExchangeFilter implements ExchangeFilterFunction {
private final ObjectMapper objectMapper;
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
ClientRequest intercepted = ClientRequest.from(request)
.body((outputMessage, context) -> request.body().insert(
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);
// 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;
}
}
}

View File

@ -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.
*
* <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).
*
* <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>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:
* <pre>
* [ {"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.
*/
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;
}
}

View File

@ -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}.
*
* <p>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.
* <p>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<String> 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<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
return next.exchange(request).doOnNext(response -> {
AtomicReference<String> capturedBody = new AtomicReference<>();
ClientRequest intercepted = ClientRequest.from(request)
.body((outputMessage, context) -> request.body().insert(
new ClientHttpRequestDecorator(outputMessage) {
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> 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 <redacted>".
String displayValue;
if (name.equals("authorization")) {
displayValue = "Bearer <redacted>";
} 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());
}
}
}

View File

@ -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:
*
* <table>
* <caption>How to read the headers</caption>
* <caption>How to read the response headers</caption>
* <tr><th>Failure mode</th><th>tokens-remaining</th><th>retry-after</th></tr>
* <tr><td>5h Pro/Max quota exhausted</td><td>0</td><td>thousands of seconds</td></tr>
* <tr><td>Anti-abuse fingerprint gate</td><td>large</td><td>tens of seconds</td></tr>
* <tr><td>Anti-abuse fingerprint gate</td><td>(absent)</td><td>(absent)</td></tr>
* <tr><td>Per-minute burst limit</td><td>large</td><td>single-digit seconds</td></tr>
* </table>
*
* <p>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
* <p>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<String> RATE_LIMIT_HEADERS = List.of(
static final int BODY_LOG_LIMIT = 16384;
static final List<String> 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<String> 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 <redacted>"
: 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 <redacted>");
} 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());
}

View File

@ -48,12 +48,12 @@ public class ClaudeCodeApiHeaders {
/**
* Comma-joined beta header list to send in {@code anthropic-beta}.
* <p>Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS +
* _COMMON_BETAS} (OAuth-specific betas first).
* <p>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));
}
/**