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
@@ -122,16 +127,40 @@ 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("x-app", xApp);
+ .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());
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
- .defaultHeader("x-app", xApp);
+ .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
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
index a2e0593e..86b83b96 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
@@ -43,11 +44,20 @@ public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider);
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
- return OpenAiChatModel.builder()
+ ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
+
+ // DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
+ // strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
+ // those directly — wrap with a per-request payload patcher. See
+ // DeepSeekV4ThinkingDecorator javadoc.
+ if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
+ return new DeepSeekV4ThinkingDecorator(raw);
+ }
+ return 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
index 5c90f095..96cecbd2 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,50 +1,67 @@
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 whose system prompt does NOT claim
- * Claude Code identity. Symptoms include:
+ * (and intermittently 5xxs) requests claiming Claude Code identity but
+ * shaped differently from real Claude Code traffic. Symptoms:
*
*
* - HTTP 429 with {@code rate_limit_error} on quiet accounts that haven't
- * come close to their token budget — the give-away is a body of just
+ * come close to their token budget — give-away is a body of just
* {@code {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}
- * (genuine quota exhaustion includes a descriptive message).
+ * (genuine quota exhaustion carries 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 — applies the same transforms unconditionally on
+ * lines 1571-1607 — same transforms applied unconditionally on
* {@code is_oauth=True} requests.
*
*
Transforms applied per call
*
- * - 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.
+ * - 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.
*
- *
- * 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 {
@@ -53,6 +70,9 @@ 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) {
@@ -61,12 +81,12 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
@Override
public ChatResponse call(Prompt prompt) {
- return delegate.call(transform(prompt));
+ return stripToolPrefixes(delegate.call(transform(prompt)));
}
@Override
public Flux stream(Prompt prompt) {
- return delegate.stream(transform(prompt));
+ return delegate.stream(transform(prompt)).map(this::stripToolPrefixes);
}
@Override
@@ -74,6 +94,10 @@ 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.
@@ -88,40 +112,153 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
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()))));
+ // 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
+ // 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));
}
- return new Prompt(rewritten, original.getOptions());
+
+ ChatOptions transformedOptions = transformOptions(original.getOptions());
+ return new Prompt(rewritten, transformedOptions);
}
+ /**
+ * 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;
@@ -131,4 +268,57 @@ 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
new file mode 100644
index 00000000..36130b35
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayExchangeFilter.java
@@ -0,0 +1,60 @@
+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
new file mode 100644
index 00000000..8470a0eb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/ClaudeCodeSystemArrayInterceptor.java
@@ -0,0 +1,94 @@
+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/DeepSeekV4ThinkingDecorator.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecorator.java
new file mode 100644
index 00000000..aebad848
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecorator.java
@@ -0,0 +1,213 @@
+package vip.mate.agent.chatmodel;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.MessageType;
+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 org.springframework.ai.openai.OpenAiChatOptions;
+import reactor.core.publisher.Flux;
+import vip.mate.agent.ThinkingLevelHolder;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RFC: DeepSeek V4 thinking-mode payload patcher applied to every
+ * {@code deepseek-v4-flash} / {@code deepseek-v4-pro} request.
+ *
+ * DeepSeek V4 extends OpenAI's chat-completions wire format with two
+ * non-standard request fields that the base Spring AI {@link OpenAiChatOptions}
+ * has no first-class support for:
+ *
+ *
+ * - {@code thinking: {"type": "enabled" | "disabled"}} — toggles V4's
+ * step-by-step reasoning channel.
+ * - {@code reasoning_effort: "low" | "medium" | "high"} — only meaningful
+ * when {@code thinking.type == "enabled"}.
+ *
+ *
+ * It also has a strict replay contract: when thinking is enabled and the
+ * conversation contains prior assistant tool-calls, every such tool-call
+ * message must carry a {@code reasoning_content} string (empty allowed) or
+ * the API rejects with an obscure 400. When thinking is disabled, any prior
+ * {@code reasoning_content} must be stripped or DeepSeek echoes the old
+ * thinking back into the response.
+ *
+ *
Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts}
+ * lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}).
+ *
+ *
Pipeline (per request)
+ *
+ * - Read {@link ThinkingLevelHolder} for the current request's thinking
+ * level (set by AgentService before the call).
+ * - Clone {@link OpenAiChatOptions} and patch its {@code extraBody} +
+ * {@code reasoningEffort} fields. Spring AI sends {@code extraBody}
+ * verbatim in the JSON body, so the {@code thinking} key lands where
+ * DeepSeek expects it.
+ * - Walk message history: when disabled, strip {@code reasoning_content}
+ * from {@link AssistantMessage} metadata; when enabled, ensure each
+ * tool-call message has a (possibly empty) {@code reasoning_content}
+ * entry to satisfy V4's replay contract.
+ * - Delegate to the wrapped {@link ChatModel}.
+ *
+ *
+ * Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public
+ * {@code extraBody: Map} (verified via {@code javap}). No
+ * byte-level body patching needed — the simple path works.
+ */
+@Slf4j
+public class DeepSeekV4ThinkingDecorator implements ChatModel {
+
+ /** Metadata key under which we stash {@code reasoning_content} on AssistantMessage. */
+ static final String REASONING_CONTENT_KEY = "reasoning_content";
+
+ /** Request-body field DeepSeek V4 reads to toggle thinking mode. */
+ static final String THINKING_FIELD = "thinking";
+
+ private final ChatModel delegate;
+
+ public DeepSeekV4ThinkingDecorator(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();
+ }
+
+ /* ---------------------------------------------------------------- */
+ /* Outbound transform */
+ /* ---------------------------------------------------------------- */
+
+ /** Build a new Prompt with thinking + reasoning_content patched. Package-private for tests. */
+ Prompt transform(Prompt original) {
+ if (original == null) {
+ return null;
+ }
+ boolean thinkingEnabled = isThinkingEnabled();
+ ChatOptions patchedOptions = patchOptions(original.getOptions(), thinkingEnabled);
+ List patchedMessages = patchMessages(original.getInstructions(), thinkingEnabled);
+ return new Prompt(patchedMessages, patchedOptions);
+ }
+
+ private static boolean isThinkingEnabled() {
+ String level = ThinkingLevelHolder.get();
+ // null/empty → fall back to enabled (V4's default behavior is reasoning-on);
+ // explicit "off" → disabled.
+ return level == null || level.isBlank() || !"off".equalsIgnoreCase(level);
+ }
+
+ /**
+ * Map MateClaw's thinking levels (off/low/medium/high/max) to DeepSeek's
+ * accepted reasoning_effort values. Aligns with openclaw
+ * {@code resolveDeepSeekV4ReasoningEffort}: max collapses into high since
+ * DeepSeek doesn't expose a "max" tier on V4.
+ */
+ static String mapEffort(String level) {
+ if (level == null || level.isBlank()) return "medium";
+ return switch (level.toLowerCase()) {
+ case "low" -> "low";
+ case "medium" -> "medium";
+ case "high", "max" -> "high";
+ default -> "medium";
+ };
+ }
+
+ /**
+ * Clone {@link OpenAiChatOptions} and inject extraBody.thinking + reasoning_effort.
+ * Returns the input unchanged for non-OpenAI options (defensive — should
+ * never happen for V4, but skips ahead-of-binding work in tests that pass
+ * vanilla {@link ChatOptions}).
+ */
+ private static ChatOptions patchOptions(ChatOptions original, boolean enabled) {
+ if (!(original instanceof OpenAiChatOptions oai)) {
+ return original;
+ }
+ OpenAiChatOptions copy = OpenAiChatOptions.fromOptions(oai);
+ Map extra = copy.getExtraBody();
+ Map patched = (extra == null) ? new LinkedHashMap<>() : new LinkedHashMap<>(extra);
+
+ if (enabled) {
+ patched.put(THINKING_FIELD, Map.of("type", "enabled"));
+ // reasoning_effort is a first-class OpenAiChatOptions field → set via setter.
+ String level = ThinkingLevelHolder.get();
+ copy.setReasoningEffort(mapEffort(level));
+ } else {
+ patched.put(THINKING_FIELD, Map.of("type", "disabled"));
+ // Drop reasoning_effort — DeepSeek 400s if both are present with thinking disabled.
+ copy.setReasoningEffort(null);
+ }
+ copy.setExtraBody(patched);
+ return copy;
+ }
+
+ /**
+ * Walk message history and patch reasoning_content per V4's contract:
+ *
+ * - enabled: every assistant tool-call message must carry a
+ * (possibly empty) {@code reasoning_content} entry in its metadata.
+ * - disabled: strip any {@code reasoning_content} from prior
+ * messages so DeepSeek doesn't echo stale reasoning back.
+ *
+ */
+ static List patchMessages(List source, boolean enabled) {
+ if (source == null || source.isEmpty()) {
+ return source;
+ }
+ List out = new ArrayList<>(source.size());
+ for (Message msg : source) {
+ if (msg.getMessageType() == MessageType.ASSISTANT && msg instanceof AssistantMessage am) {
+ out.add(rewriteAssistant(am, enabled));
+ } else {
+ out.add(msg);
+ }
+ }
+ return out;
+ }
+
+ private static AssistantMessage rewriteAssistant(AssistantMessage am, boolean enabled) {
+ Map meta = am.getMetadata();
+ boolean hasTools = am.hasToolCalls();
+ boolean hasReasoning = meta != null && meta.containsKey(REASONING_CONTENT_KEY);
+
+ // Fast path: no rewrite needed.
+ if (enabled && (!hasTools || hasReasoning)) {
+ return am;
+ }
+ if (!enabled && !hasReasoning) {
+ return am;
+ }
+
+ Map newMeta = (meta == null) ? new HashMap<>() : new HashMap<>(meta);
+ if (enabled) {
+ // Tool-call messages need reasoning_content present (empty OK) for replay.
+ newMeta.putIfAbsent(REASONING_CONTENT_KEY, "");
+ } else {
+ // Drop reasoning_content entirely — DeepSeek mirrors back stale thinking otherwise.
+ newMeta.remove(REASONING_CONTENT_KEY);
+ }
+ return AssistantMessage.builder()
+ .content(am.getText())
+ .properties(newMeta)
+ .toolCalls(am.getToolCalls())
+ .media(am.getMedia())
+ .build();
+ }
+}
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..f3c0a2da
--- /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.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
new file mode 100644
index 00000000..b0d7402e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java
@@ -0,0 +1,115 @@
+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 f3306cf8..1d846e54 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/ (external, cli)} | Anthropic routes OAuth by UA; spoof identity |
+ * | {@code User-Agent} | {@code claude-cli/} | bare form — suffix triggers anti-abuse fingerprint, see {@link #userAgent()} |
* | {@code x-app} | {@code cli} | Claude Code identity flag |
* | {@code anthropic-beta} | (comma-joined list — see {@link #allBetas()}) | OAuth-only + common feature betas |
*
@@ -48,22 +48,30 @@ public class ClaudeCodeApiHeaders {
/**
* Comma-joined beta header list to send in {@code anthropic-beta}.
- * Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS +
- * _COMMON_BETAS} (OAuth-specific betas first).
+ *
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));
}
/**
* User-Agent string Anthropic OAuth infrastructure expects.
- * Format: {@code claude-cli/ (external, cli)}.
- * The {@code (external, cli)} suffix is the canonical hermes / OpenCode /
- * Cline identity — drop it and Anthropic returns 400.
+ * 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.
*/
public String userAgent() {
- return "claude-cli/" + versionDetector.get() + " (external, cli)";
+ return "claude-cli/" + versionDetector.get();
}
/** {@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 504a3a9b..0ce3cc04 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,8 +80,11 @@ 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() + " (external, cli)")
+ "claude-cli/" + versionDetector.get())
.body(body)
.retrieve()
.body(String.class);
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java
index e6913ff4..0ab8ceea 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java
@@ -46,6 +46,18 @@ public enum ModelFamily {
*/
DEEPSEEK_REASONER(false, false, false, true, true, true),
+ /**
+ * DeepSeek V4 reasoning 模型:deepseek-v4-flash / deepseek-v4-pro。
+ *
+ * 与 {@link #DEEPSEEK_REASONER}(v3.2)的关键差异:V4 接受 {@code reasoning_effort} 字段,
+ * 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记
+ * {@code supportsReasoningEffort: true}。
+ * 约束:保留 max_tokens;支持 reasoning_effort;temperature/topP 用配置值。
+ * thinking=true 让 {@link vip.mate.agent.chatmodel.DeepSeekV4ThinkingDecorator}
+ * 在请求体注入 OpenAI 协议外的 {@code thinking: {type: enabled|disabled}} 字段。
+ */
+ DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
+
/**
* 通用 thinking 模型(名称含 "thinking" 或 "reasoner" 但不匹配上述族):
* 如 qwen3-235b-a22b-thinking-2507
@@ -137,6 +149,12 @@ public enum ModelFamily {
return KIMI_THINKING;
}
+ // DeepSeek V4 reasoning 族:deepseek-v4-flash / deepseek-v4-pro
+ // 优先匹配(在 DEEPSEEK_REASONER 之前),因为两者都含 "deepseek" 前缀但 V4 接受 reasoning_effort。
+ if (normalized.equals("deepseek-v4-flash") || normalized.equals("deepseek-v4-pro")) {
+ return DEEPSEEK_V4_REASONING;
+ }
+
// DeepSeek reasoning 族:仅 deepseek-reasoner
if (normalized.equals("deepseek-reasoner")) {
return DEEPSEEK_REASONER;
diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql
index 7a672fa5..ab14a78f 100644
--- a/mateclaw-server/src/main/resources/db/data-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-en.sql
@@ -221,6 +221,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+-- DeepSeek V4 (1M context, native thinking via DeepSeekV4ThinkingDecorator)
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
index 8260ac4f..32ebf72d 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
@@ -243,6 +243,9 @@ VALUES
(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+-- DeepSeek V4 (1M context, native thinking via DeepSeekV4ThinkingDecorator)
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
index 686fdaa5..4c50152f 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
@@ -243,6 +243,9 @@ VALUES
(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+-- DeepSeek V4(1M 上下文,原生 thinking 模式由 DeepSeekV4ThinkingDecorator 注入)
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql
index 3ffda686..9c75e089 100644
--- a/mateclaw-server/src/main/resources/db/data-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-zh.sql
@@ -225,6 +225,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+-- DeepSeek V4(1M 上下文,原生 thinking 模式由 DeepSeekV4ThinkingDecorator 注入)
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql
new file mode 100644
index 00000000..b38908a6
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql
@@ -0,0 +1,15 @@
+-- Add DeepSeek V4 (flash + pro) model entries to mate_model_config for
+-- existing deployments. New installs pick these up via DatabaseBootstrapRunner
+-- from data-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators
+-- already on V44.
+--
+-- Reference: openclaw extensions/deepseek/models.ts:28-81 — V4 supports
+-- reasoning_effort + thinking control. NULL temperature/top_p marks the model
+-- as thinking-managed (DeepSeekV4ThinkingDecorator handles the per-request
+-- thinking field injection).
+
+MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
+KEY (id)
+VALUES
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V45__deepseek_v4_models.sql
new file mode 100644
index 00000000..4be5b4f2
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V45__deepseek_v4_models.sql
@@ -0,0 +1,20 @@
+-- Add DeepSeek V4 (flash + pro) model entries for MySQL deployments.
+-- Cross-dialect parity with h2/V45 — see that file's header for context.
+
+INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
+VALUES
+(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
+(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE
+ name = VALUES(name),
+ provider = VALUES(provider),
+ model_name = VALUES(model_name),
+ description = VALUES(description),
+ temperature = VALUES(temperature),
+ max_tokens = VALUES(max_tokens),
+ top_p = VALUES(top_p),
+ builtin = VALUES(builtin),
+ enabled = VALUES(enabled),
+ is_default = VALUES(is_default),
+ update_time = VALUES(update_time),
+ deleted = VALUES(deleted);