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 9ba5a2b1..e9e184a9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java @@ -1,6 +1,5 @@ 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; @@ -39,10 +38,9 @@ import vip.mate.llm.model.ModelProviderEntity; * {@code oauth-2025-04-20} or Anthropic's edge intermittently 500s. * We push these via {@link AnthropicApi.Builder#anthropicBetaFeatures} * so Spring AI's existing header-merging logic still applies. - *
  • {@code User-Agent: claude-cli/} (bare — no suffix) and - * {@code x-app: cli} masquerade as the Claude Code CLI. Suffix variants - * like {@code (external, cli)} are anti-abuse fingerprints; see - * {@link ClaudeCodeApiHeaders#userAgent()}.
  • + *
  • {@code User-Agent: claude-cli/ (external, cli)} and + * {@code x-app: cli} masquerade as the Claude Code CLI — Anthropic + * rejects unrecognised UAs on Bearer-auth requests with HTTP 400.
  • * * *

    Token lifecycle

    @@ -64,7 +62,6 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { private final ObjectProvider restClientBuilderProvider; private final ObjectProvider webClientBuilderProvider; private final ObjectProvider observationRegistryProvider; - private final ObjectMapper objectMapper; public AgentClaudeCodeChatModelBuilder( AgentAnthropicChatModelBuilder anthropicBuilder, @@ -72,15 +69,13 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { ClaudeCodeApiHeaders apiHeaders, ObjectProvider restClientBuilderProvider, ObjectProvider webClientBuilderProvider, - ObjectProvider observationRegistryProvider, - ObjectMapper objectMapper) { + ObjectProvider 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

    *
      - *
    1. System prompt prefix: prepend - * {@code "You are Claude Code, Anthropic's official CLI for Claude."}. - * Insert a new SystemMessage if none exists.
    2. - *
    3. Brand scrub: replace {@code "MateClaw"}/{@code "mateclaw"} - * in system text with their Claude Code equivalents — Anthropic's - * content filter flags identity contradictions.
    4. - *
    5. 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.
    6. - *
    7. 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).
    8. - *
    9. Tool {@code mcp_} prefix (incoming): ChatResponse tool_use - * names are stripped of the {@code mcp_} prefix so MateClaw's tool - * registry can resolve them.
    10. + *
    11. 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.
    12. + *
    13. 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.
    14. *
    + * + *

    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 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 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 modetokens-remainingretry-after
    5h Pro/Max quota exhausted0thousands of seconds
    Anti-abuse fingerprint gate(absent)(absent)
    Per-minute burst limitlargesingle-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 * HeaderValueWhy * {@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);