observationRegistryProvider) {
this.anthropicBuilder = anthropicBuilder;
this.oauthService = oauthService;
this.apiHeaders = apiHeaders;
this.restClientBuilderProvider = restClientBuilderProvider;
this.webClientBuilderProvider = webClientBuilderProvider;
this.observationRegistryProvider = observationRegistryProvider;
- this.objectMapper = objectMapper;
}
@Override
@@ -127,40 +122,16 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
String xApp = apiHeaders.xApp();
String betas = apiHeaders.allBetas();
- // Real Claude Code is an Electron + Node app that uses the official
- // Anthropic JS SDK. The SDK auto-sets `accept: application/json` and
- // `anthropic-dangerous-direct-browser-access: true` on every request.
- // Spring AI's Java client doesn't, so Anthropic's edge fingerprint
- // sees the missing headers and treats the traffic as suspicious —
- // rate-limited harder than spec'd. Reference: openclaw
- // anthropic-transport-stream.ts:567-574.
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::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)
- // 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
- // large, retry-after small) from "burst limit hit" without
- // staring at SDK internals.
- .requestInterceptor(new RateLimitDiagnosticInterceptor());
+ .defaultHeader("x-app", xApp);
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)
- // Rewrite system string → array (streaming path counterpart).
- .filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper))
- .filter(new RateLimitDiagnosticExchangeFilter());
+ .defaultHeader("x-app", xApp);
// 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/ClaudeCodeIdentityChatModelDecorator.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java
index 96cecbd2..5c90f095 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
@@ -1,67 +1,50 @@
package vip.mate.agent.chatmodel;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.ai.anthropic.AnthropicChatOptions;
-import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
-import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
-import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
-import org.springframework.ai.chat.model.ToolContext;
-import org.springframework.ai.tool.ToolCallback;
-import org.springframework.ai.tool.definition.DefaultToolDefinition;
-import org.springframework.ai.tool.definition.ToolDefinition;
-import org.springframework.ai.tool.metadata.ToolMetadata;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
-import java.util.LinkedHashSet;
import java.util.List;
-import java.util.Set;
/**
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
* request when the underlying auth is a Claude Code OAuth token.
*
* Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
- * (and intermittently 5xxs) requests claiming Claude Code identity but
- * shaped differently from real Claude Code traffic. Symptoms:
+ * (and intermittently 5xxs) requests whose system prompt does NOT claim
+ * Claude Code identity. Symptoms include:
*
*
* - HTTP 429 with {@code rate_limit_error} on quiet accounts that haven't
- * come close to their token budget — give-away is a body of just
+ * come close to their token budget — the give-away is a body of just
* {@code {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}
- * (genuine quota exhaustion carries a descriptive message).
+ * (genuine quota exhaustion includes a descriptive message).
* - Sporadic 500s on the first call after a long idle period.
*
*
* Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
- * lines 1571-1607 — same transforms applied unconditionally on
+ * lines 1571-1607 — applies the same transforms unconditionally on
* {@code is_oauth=True} requests.
*
*
Transforms applied per call
*
- * - System prompt prefix: prepend
- * {@code "You are Claude Code, Anthropic's official CLI for Claude."}.
- * Insert a new SystemMessage if none exists.
- * - Brand scrub: replace {@code "MateClaw"}/{@code "mateclaw"}
- * in system text with their Claude Code equivalents — Anthropic's
- * content filter flags identity contradictions.
- * - Tool {@code mcp_} prefix (outgoing): every tool definition
- * sent to Anthropic is renamed {@code mcp_} — Claude Code
- * runs all tools through MCP servers, so real Claude Code traffic
- * always has the prefix. Mismatch trips anti-abuse.
- * - History tool_use prefix: previously-issued tool calls in
- * AssistantMessage history get the prefix re-applied (we strip on
- * response, so they're stored unprefixed).
- * - Tool {@code mcp_} prefix (incoming): ChatResponse tool_use
- * names are stripped of the {@code mcp_} prefix so MateClaw's tool
- * registry can resolve them.
+ * - Prepend {@code "You are Claude Code, Anthropic's official CLI for Claude."}
+ * to the system prompt. If no system message is present we insert one.
+ * - Scrub MateClaw branding from system text — replace
+ * {@code "MateClaw"} → {@code "Claude Code"} and a few common variants —
+ * so server-side content filters don't fire on the spoofed identity.
*
+ *
+ * Tool-name {@code mcp_} prefix (hermes lines 1593-1607) is a
+ * separate concern: it requires bidirectional rewriting (out + back) and
+ * touches Spring AI's tool-callback layer. Deferred to a follow-up — current
+ * symptom is rate-limit on memory analysis (which uses no tools).
*/
@Slf4j
public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
@@ -70,9 +53,6 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
static final String CLAUDE_CODE_SYSTEM_PREFIX =
"You are Claude Code, Anthropic's official CLI for Claude.";
- /** Tool-name prefix Claude Code uses for all MCP-routed tools. */
- static final String MCP_TOOL_PREFIX = "mcp_";
-
private final ChatModel delegate;
public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) {
@@ -81,12 +61,12 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
@Override
public ChatResponse call(Prompt prompt) {
- return stripToolPrefixes(delegate.call(transform(prompt)));
+ return delegate.call(transform(prompt));
}
@Override
public Flux stream(Prompt prompt) {
- return delegate.stream(transform(prompt)).map(this::stripToolPrefixes);
+ return delegate.stream(transform(prompt));
}
@Override
@@ -94,10 +74,6 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
return delegate.getDefaultOptions();
}
- /* ====================================================================== */
- /* Outbound transform: Prompt → Prompt with identity + tool prefix */
- /* ====================================================================== */
-
/**
* Build a new {@link Prompt} with the OAuth identity transforms applied.
* Package-private for unit tests.
@@ -112,153 +88,40 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
boolean systemSeen = false;
for (Message msg : source) {
if (msg instanceof SystemMessage sm && !systemSeen) {
- // 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));
- }
+ // Only mutate the FIRST system message — multiple system messages
+ // are rare in practice but preserve the second-onward verbatim.
+ rewritten.add(new SystemMessage(prependIdentity(sanitizeBranding(sm.getText()))));
systemSeen = true;
- } else if (msg instanceof AssistantMessage am && am.hasToolCalls()) {
- // Re-prefix tool_use names in history. We strip on response, so
- // by the time MateClaw stores the AssistantMessage the names
- // are unprefixed — must put the prefix back when echoing the
- // history to Anthropic for it to match its own prior turn.
- rewritten.add(rebuildAssistantMessage(am, true));
} else {
rewritten.add(msg);
}
}
if (!systemSeen) {
+ // No system message at all → insert one with just the identity prefix.
rewritten.add(0, new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
}
-
- ChatOptions transformedOptions = transformOptions(original.getOptions());
- return new Prompt(rewritten, transformedOptions);
+ return new Prompt(rewritten, original.getOptions());
}
- /**
- * Wrap each tool callback in the options so its {@code getToolDefinition().name()}
- * returns {@code mcp_}. Spring AI sends those names verbatim to Anthropic.
- * Other tool fields (description, schema) untouched. Returns {@code null} for
- * non-Anthropic options so we don't accidentally drop them on a custom subclass.
- */
- private ChatOptions transformOptions(ChatOptions options) {
- if (!(options instanceof AnthropicChatOptions anthropicOpts)) {
- return options;
- }
- List originalCallbacks = anthropicOpts.getToolCallbacks();
- Set originalToolNames = anthropicOpts.getToolNames();
-
- boolean hasCallbacks = originalCallbacks != null && !originalCallbacks.isEmpty();
- boolean hasToolNames = originalToolNames != null && !originalToolNames.isEmpty();
- if (!hasCallbacks && !hasToolNames) {
- return options;
- }
-
- AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(anthropicOpts);
- if (hasCallbacks) {
- List wrapped = new ArrayList<>(originalCallbacks.size());
- for (ToolCallback cb : originalCallbacks) {
- wrapped.add(cb instanceof PrefixedToolCallback ? cb : new PrefixedToolCallback(cb));
- }
- copy.setToolCallbacks(wrapped);
- }
- if (hasToolNames) {
- // toolNames is a set used by Spring AI's tool resolver to filter from
- // a wider registry. If MateClaw populates it (most paths use callbacks
- // directly so this is rare), prefix the names so they line up with
- // the wrapped callbacks above.
- Set prefixed = new LinkedHashSet<>(originalToolNames.size());
- for (String n : originalToolNames) {
- prefixed.add(n.startsWith(MCP_TOOL_PREFIX) ? n : MCP_TOOL_PREFIX + n);
- }
- copy.setToolNames(prefixed);
- }
- return copy;
- }
-
- /* ====================================================================== */
- /* Inbound transform: ChatResponse → strip tool prefix */
- /* ====================================================================== */
-
- ChatResponse stripToolPrefixes(ChatResponse response) {
- if (response == null) {
- return null;
- }
- List origGens = response.getResults();
- if (origGens == null || origGens.isEmpty()) {
- return response;
- }
- List rewritten = null;
- for (int i = 0; i < origGens.size(); i++) {
- Generation g = origGens.get(i);
- AssistantMessage am = g.getOutput();
- if (am == null || !am.hasToolCalls()) continue;
- boolean changed = false;
- for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
- if (tc.name() != null && tc.name().startsWith(MCP_TOOL_PREFIX)) {
- changed = true;
- break;
- }
- }
- if (!changed) continue;
-
- if (rewritten == null) {
- rewritten = new ArrayList<>(origGens);
- }
- AssistantMessage stripped = rebuildAssistantMessage(am, false);
- ChatGenerationMetadata meta = g.getMetadata();
- rewritten.set(i, new Generation(stripped, meta));
- }
- if (rewritten == null) {
- return response; // no tool_use blocks needed rewriting
- }
- return new ChatResponse(rewritten, response.getMetadata());
- }
-
- /**
- * Rebuild an AssistantMessage with tool_call names prefixed (when
- * {@code prefix=true}) or stripped (when {@code prefix=false}).
- */
- private AssistantMessage rebuildAssistantMessage(AssistantMessage original, boolean prefix) {
- List rebuilt = new ArrayList<>(original.getToolCalls().size());
- for (AssistantMessage.ToolCall tc : original.getToolCalls()) {
- String name = tc.name();
- String newName;
- if (prefix) {
- newName = (name == null || name.startsWith(MCP_TOOL_PREFIX)) ? name : MCP_TOOL_PREFIX + name;
- } else {
- newName = (name != null && name.startsWith(MCP_TOOL_PREFIX))
- ? name.substring(MCP_TOOL_PREFIX.length()) : name;
- }
- rebuilt.add(new AssistantMessage.ToolCall(tc.id(), tc.type(), newName, tc.arguments()));
- }
- return AssistantMessage.builder()
- .content(original.getText())
- .properties(original.getMetadata())
- .toolCalls(rebuilt)
- .media(original.getMedia())
- .build();
- }
-
- /* ====================================================================== */
- /* String helpers (system prompt + branding) */
- /* ====================================================================== */
-
private static String prependIdentity(String existingSystem) {
if (existingSystem == null || existingSystem.isBlank()) {
return CLAUDE_CODE_SYSTEM_PREFIX;
}
if (existingSystem.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
+ // Already prefixed (defensive — protects against double-wrapping
+ // if a caller invokes the decorator twice).
return existingSystem;
}
return CLAUDE_CODE_SYSTEM_PREFIX + "\n\n" + existingSystem;
}
+ /**
+ * Replace MateClaw / agent-specific branding tokens with their Claude Code
+ * equivalents. Same idea as hermes-agent's
+ * {@code text.replace("Hermes Agent", "Claude Code")} chain — Anthropic's
+ * server-side filter flags requests where the spoofed identity contradicts
+ * itself ("You are Claude Code … built by MateClaw").
+ */
static String sanitizeBranding(String text) {
if (text == null || text.isEmpty()) {
return text;
@@ -268,57 +131,4 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
.replace("mateclaw", "claude-code")
.replace("Mate Claw", "Claude Code");
}
-
- /* ====================================================================== */
- /* PrefixedToolCallback — wraps a ToolCallback to expose the mcp_ name */
- /* ====================================================================== */
-
- /**
- * Wraps a {@link ToolCallback} so its {@code getToolDefinition().name()}
- * returns {@code mcp_}, while {@code call(...)} forwards verbatim
- * to the underlying tool. Anthropic sees the prefixed name on the wire;
- * MateClaw's tool implementation never sees the prefix.
- */
- static final class PrefixedToolCallback implements ToolCallback {
-
- private final ToolCallback delegate;
- private final ToolDefinition prefixedDefinition;
-
- PrefixedToolCallback(ToolCallback delegate) {
- this.delegate = delegate;
- ToolDefinition orig = delegate.getToolDefinition();
- String origName = orig.name();
- String prefixed = (origName != null && origName.startsWith(MCP_TOOL_PREFIX))
- ? origName : MCP_TOOL_PREFIX + origName;
- this.prefixedDefinition = DefaultToolDefinition.builder()
- .name(prefixed)
- .description(orig.description())
- .inputSchema(orig.inputSchema())
- .build();
- }
-
- @Override
- public ToolDefinition getToolDefinition() {
- return prefixedDefinition;
- }
-
- @Override
- public ToolMetadata getToolMetadata() {
- return delegate.getToolMetadata();
- }
-
- @Override
- public String call(String input) {
- return delegate.call(input);
- }
-
- @Override
- public String call(String input, ToolContext context) {
- return delegate.call(input, context);
- }
-
- ToolCallback unwrap() {
- return delegate;
- }
- }
}
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
deleted file mode 100644
index 36130b35..00000000
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package vip.mate.agent.chatmodel;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-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 via {@code DataBufferUtils.join}, delegates
- * the rewrite to {@link ClaudeCodeSystemArrayInterceptor#rewriteSystemField}, and
- * emits the modified bytes as a single new {@link DataBuffer}.
- */
-@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 extends DataBuffer> body) {
- return DataBufferUtils.join(Flux.from(body))
- .flatMap(joined -> {
- byte[] original = new byte[joined.readableByteCount()];
- joined.read(original);
- DataBufferUtils.release(joined);
-
- byte[] rewritten = ClaudeCodeSystemArrayInterceptor
- .rewriteSystemField(original, objectMapper);
-
- long declared = getHeaders().getContentLength();
- if (declared > 0 && declared != rewritten.length) {
- getHeaders().setContentLength(rewritten.length);
- }
-
- return super.writeWith(Mono.just(
- outputMessage.bufferFactory().wrap(rewritten)));
- });
- }
- }, context))
- .build();
-
- return next.exchange(intercepted);
- }
-}
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
deleted file mode 100644
index 8470a0eb..00000000
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java
+++ /dev/null
@@ -1,94 +0,0 @@
-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. Appending any additional text
- * triggers a 429; two separate array elements always pass (verified 2026-04-25).
- *
- *
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.
- *
- *
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, objectMapper));
- }
-
- /**
- * 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.
- * Package-private static so {@link ClaudeCodeSystemArrayExchangeFilter} can reuse.
- */
- static byte[] rewriteSystemField(byte[] body, ObjectMapper mapper) {
- if (body == null || body.length == 0) return body;
- try {
- JsonNode root = mapper.readTree(body);
- if (!root.isObject()) return body;
- JsonNode systemNode = root.get("system");
- 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) {
- 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);
-
- 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
deleted file mode 100644
index f3c0a2da..00000000
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package vip.mate.agent.chatmodel;
-
-import lombok.extern.slf4j.Slf4j;
-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.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.concurrent.atomic.AtomicReference;
-
-/**
- * WebClient (streaming) counterpart of {@link RateLimitDiagnosticInterceptor}.
- *
- * 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 {
-
- @Override
- public Mono filter(ClientRequest request, ExchangeFunction next) {
- AtomicReference capturedBody = new AtomicReference<>();
-
- ClientRequest intercepted = ClientRequest.from(request)
- .body((outputMessage, context) -> request.body().insert(
- new ClientHttpRequestDecorator(outputMessage) {
- @Override
- public Mono 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) {
- 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());
- }
- });
- }
-}
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
deleted file mode 100644
index b0d7402e..00000000
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java
+++ /dev/null
@@ -1,115 +0,0 @@
-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.nio.charset.StandardCharsets;
-import java.util.List;
-
-/**
- * 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 response headers
- * | Failure mode | tokens-remaining | retry-after |
- * | 5h Pro/Max quota exhausted | 0 | thousands of seconds |
- * | Anti-abuse fingerprint gate | (absent) | (absent) |
- * | Per-minute burst limit | large | single-digit seconds |
- *
- *
- * Sync (RestClient) variant; the WebFlux equivalent is
- * {@link RateLimitDiagnosticExchangeFilter}.
- */
-@Slf4j
-class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor {
-
- 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",
- "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");
-
- 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) {
- logRequestHeaders(request.getHeaders());
- logRequestBody(body);
- logResponseHeaders(response.getHeaders());
- }
- return response;
- }
-
- static void logRequestHeaders(HttpHeaders headers) {
- StringBuilder sb = new StringBuilder("[Anthropic 429] outgoing request headers (sanitized): ");
- 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());
- }
-
- 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);
- if (value != null) {
- if (any) sb.append(", ");
- sb.append(name).append('=').append(value);
- any = true;
- }
- }
- if (!any) {
- 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 1d846e54..f3306cf8 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
@@ -20,7 +20,7 @@ import java.util.List;
* Header set sent on OAuth requests
* | Header | Value | Why |
* | {@code Authorization} | {@code Bearer } | OAuth path uses Bearer; non-OAuth uses {@code x-api-key} |
- * | {@code User-Agent} | {@code claude-cli/} | bare form — suffix triggers anti-abuse fingerprint, see {@link #userAgent()} |
+ * | {@code User-Agent} | {@code claude-cli/ (external, cli)} | Anthropic routes OAuth by UA; spoof identity |
* | {@code x-app} | {@code cli} | Claude Code identity flag |
* | {@code anthropic-beta} | (comma-joined list — see {@link #allBetas()}) | OAuth-only + common feature betas |
*
@@ -48,30 +48,22 @@ public class ClaudeCodeApiHeaders {
/**
* Comma-joined beta header list to send in {@code anthropic-beta}.
- * Order matches hermes-agent {@code anthropic_adapter} line 427:
- * {@code common_betas + _OAUTH_ONLY_BETAS} — common betas first, OAuth betas appended.
+ *
Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS +
+ * _COMMON_BETAS} (OAuth-specific betas first).
*/
public String allBetas() {
return String.join(",",
- concat(COMMON_BETAS, OAUTH_ONLY_BETAS));
+ concat(OAUTH_ONLY_BETAS, COMMON_BETAS));
}
/**
* User-Agent string Anthropic OAuth infrastructure expects.
- * Format: {@code claude-cli/} — bare, no suffix.
- *
- * History note: we previously appended {@code (external, cli)}
- * after hermes-agent's pattern. That turned out to be wrong: Anthropic's
- * anti-abuse gate uses the suffix to fingerprint third-party clients
- * (hermes / OpenCode / Cline) and rate-limits them harder. Real Claude
- * Code (Electron + Node + official Anthropic JS SDK) emits the bare
- * {@code claude-cli/} form, which is what openclaw
- * ({@code anthropic-transport-stream.ts:30,572}) also uses. Verified by
- * reproducing 429 with the suffix and {@code anthropic-ratelimit-*}
- * headers absent — the diagnostic signature of the anti-abuse path.
+ * Format: {@code claude-cli/ (external, cli)}.
+ * The {@code (external, cli)} suffix is the canonical hermes / OpenCode /
+ * Cline identity — drop it and Anthropic returns 400.
*/
public String userAgent() {
- return "claude-cli/" + versionDetector.get();
+ return "claude-cli/" + versionDetector.get() + " (external, cli)";
}
/** {@code x-app} header value. Constant. */
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java
index 0ce3cc04..504a3a9b 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java
@@ -80,11 +80,8 @@ public class ClaudeCodeTokenRefresher {
.uri(endpoint)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
- // Bare UA — see ClaudeCodeApiHeaders.userAgent() javadoc
- // for why the (external, cli) suffix would trip Anthropic's
- // anti-abuse fingerprint.
.header(HttpHeaders.USER_AGENT,
- "claude-cli/" + versionDetector.get())
+ "claude-cli/" + versionDetector.get() + " (external, cli)")
.body(body)
.retrieve()
.body(String.class);