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 3f041907..d8a5eca4 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
@@ -135,14 +135,21 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
- .defaultHeader("x-app", xApp);
+ .defaultHeader("x-app", xApp)
+ // 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
+ // large, retry-after small) from "burst limit hit" without
+ // staring at SDK internals.
+ .requestInterceptor(new RateLimitDiagnosticInterceptor());
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
- .defaultHeader("x-app", xApp);
+ .defaultHeader("x-app", xApp)
+ .filter(new RateLimitDiagnosticExchangeFilter());
// NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still
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
new file mode 100644
index 00000000..1dc7d8b9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java
@@ -0,0 +1,66 @@
+package vip.mate.agent.chatmodel;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+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 reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * 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.
+ *
+ *
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.
+ */
+@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 -> {
+ if (response.statusCode().value() == 429) {
+ logHeaders(response.headers().asHttpHeaders());
+ }
+ });
+ }
+
+ 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
new file mode 100644
index 00000000..8fd3b54c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java
@@ -0,0 +1,80 @@
+package vip.mate.agent.chatmodel;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+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;
+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:
+ *
+ *
+ * How to read the headers
+ * | Failure mode | tokens-remaining | retry-after |
+ * | 5h Pro/Max quota exhausted | 0 | thousands of seconds |
+ * | Anti-abuse fingerprint gate | large | tens of seconds |
+ * | Per-minute burst limit | large | single-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
+ * {@link RateLimitDiagnosticExchangeFilter}.
+ */
+@Slf4j
+class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor {
+
+ /** 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 ClientHttpResponse intercept(HttpRequest request, byte[] body,
+ ClientHttpRequestExecution execution) throws IOException {
+ ClientHttpResponse response = execution.execute(request, body);
+ if (response.getStatusCode().value() == 429) {
+ logHeaders(response.getHeaders());
+ }
+ return response;
+ }
+
+ 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) {
+ // 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");
+ } else {
+ log.warn(sb.toString());
+ }
+ }
+}