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 0acc89b8..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 @@ -97,12 +97,18 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { // sampling-params handling, thinking-budget mapping, prompt cache. AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model); - return AnthropicChatModel.builder() + AnthropicChatModel raw = AnthropicChatModel.builder() .anthropicApi(api) .defaultOptions(options) .retryTemplate(retry) .observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP)) .build(); + + // 4) Wrap with the OAuth identity decorator. Anthropic's edge rate-limits / + // 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); } /** 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 new file mode 100644 index 00000000..5c90f095 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecorator.java @@ -0,0 +1,134 @@ +package vip.mate.agent.chatmodel; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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 whose system prompt does NOT claim + * Claude Code identity. Symptoms include: + * + *

+ * + *

Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request} + * lines 1571-1607 — applies the same transforms unconditionally on + * {@code is_oauth=True} requests. + * + *

Transforms applied per call

+ *
    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
+ * + *

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 { + + /** Magic identity prefix Anthropic's OAuth edge requires in the system prompt. */ + static final String CLAUDE_CODE_SYSTEM_PREFIX = + "You are Claude Code, Anthropic's official CLI for Claude."; + + private final ChatModel delegate; + + public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) { + this.delegate = delegate; + } + + @Override + public ChatResponse call(Prompt prompt) { + return delegate.call(transform(prompt)); + } + + @Override + public Flux stream(Prompt prompt) { + return delegate.stream(transform(prompt)); + } + + @Override + public ChatOptions getDefaultOptions() { + return delegate.getDefaultOptions(); + } + + /** + * Build a new {@link Prompt} with the OAuth identity transforms applied. + * Package-private for unit tests. + */ + Prompt transform(Prompt original) { + if (original == null) { + return null; + } + List source = original.getInstructions(); + List rewritten = new ArrayList<>(source.size() + 1); + + boolean systemSeen = false; + for (Message msg : source) { + if (msg instanceof SystemMessage sm && !systemSeen) { + // 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 { + 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)); + } + return new Prompt(rewritten, original.getOptions()); + } + + 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; + } + return text + .replace("MateClaw", "Claude Code") + .replace("mateclaw", "claude-code") + .replace("Mate Claw", "Claude Code"); + } +}