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 e9e184a9..9ba5a2b1 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,5 +1,6 @@ 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; @@ -38,9 +39,10 @@ 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/ (external, cli)} and - * {@code x-app: cli} masquerade as the Claude Code CLI — Anthropic - * rejects unrecognised UAs on Bearer-auth requests with HTTP 400.
  • + *
  • {@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()}.
  • * * *

    Token lifecycle

    @@ -62,6 +64,7 @@ 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, @@ -69,13 +72,15 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { ClaudeCodeApiHeaders apiHeaders, ObjectProvider restClientBuilderProvider, ObjectProvider webClientBuilderProvider, - ObjectProvider observationRegistryProvider) { + ObjectProvider 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

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

    + *
      + *
    1. Read {@link ThinkingLevelHolder} for the current request's thinking + * level (set by AgentService before the call).
    2. + *
    3. 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.
    4. + *
    5. 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.
    6. + *
    7. Delegate to the wrapped {@link ChatModel}.
    8. + *
    + * + *

    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 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 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 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 * HeaderValueWhy * {@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);