mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(anthropic): rewrite system field to array to pass OAuth anti-abuse gate
This commit is contained in:
parent
5c2482c307
commit
dbdb585eed
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user