fix(model-catalog): claude-sonnet-4-7 doesn't exist — Sonnet stays at 4.6

This commit is contained in:
matevip 2026-04-26 08:34:12 +08:00
parent b9c4f40028
commit dfb9fc2cac
8 changed files with 46 additions and 611 deletions

View File

@ -1,6 +1,5 @@
package vip.mate.agent.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatModel;
@ -39,10 +38,9 @@ import vip.mate.llm.model.ModelProviderEntity;
* {@code oauth-2025-04-20} or Anthropic's edge intermittently 500s.
* We push these via {@link AnthropicApi.Builder#anthropicBetaFeatures}
* so Spring AI's existing header-merging logic still applies.</li>
* <li>{@code User-Agent: claude-cli/<ver>} (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()}.</li>
* <li>{@code User-Agent: claude-cli/<ver> (external, cli)} and
* {@code x-app: cli} masquerade as the Claude Code CLI Anthropic
* rejects unrecognised UAs on Bearer-auth requests with HTTP 400.</li>
* </ol>
*
* <h2>Token lifecycle</h2>
@ -64,7 +62,6 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
private final ObjectMapper objectMapper;
public AgentClaudeCodeChatModelBuilder(
AgentAnthropicChatModelBuilder anthropicBuilder,
@ -72,15 +69,13 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
ClaudeCodeApiHeaders apiHeaders,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
ObjectProvider<ObservationRegistry> observationRegistryProvider,
ObjectMapper objectMapper) {
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.anthropicBuilder = anthropicBuilder;
this.oauthService = oauthService;
this.apiHeaders = apiHeaders;
this.restClientBuilderProvider = restClientBuilderProvider;
this.webClientBuilderProvider = webClientBuilderProvider;
this.observationRegistryProvider = observationRegistryProvider;
this.objectMapper = objectMapper;
}
@Override
@ -127,40 +122,16 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
String xApp = apiHeaders.xApp();
String betas = apiHeaders.allBetas();
// Real Claude Code is an Electron + Node app that uses the official
// Anthropic JS SDK. The SDK auto-sets `accept: application/json` and
// `anthropic-dangerous-direct-browser-access: true` on every request.
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
// sees the missing headers and treats the traffic as suspicious
// rate-limited harder than spec'd. Reference: openclaw
// anthropic-transport-stream.ts:567-574.
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder))
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
.defaultHeader("x-app", xApp)
// Rewrite system string array before the request hits the wire.
// Anthropic's OAuth anti-abuse gate requires system to be an array;
// see ClaudeCodeSystemArrayInterceptor for the full explanation.
.requestInterceptor(new ClaudeCodeSystemArrayInterceptor(objectMapper))
// Diagnostic: log Anthropic's rate-limit headers on 429 so we
// can tell apart "5h Pro quota exhausted" (tokens-remaining=0,
// retry-after huge) from "anti-abuse gate" (tokens-remaining
// large, retry-after small) from "burst limit hit" without
// staring at SDK internals.
.requestInterceptor(new RateLimitDiagnosticInterceptor());
.defaultHeader("x-app", xApp);
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
.defaultHeader("anthropic-dangerous-direct-browser-access", "true")
.defaultHeader("x-app", xApp)
// Rewrite system string array (streaming path counterpart).
.filter(new ClaudeCodeSystemArrayExchangeFilter(objectMapper))
.filter(new RateLimitDiagnosticExchangeFilter());
.defaultHeader("x-app", xApp);
// NoopApiKey.getValue() returns "" Spring AI's addDefaultHeadersIfMissing
// skips x-api-key. The Builder.build() Assert.notNull on apiKey still

View File

@ -1,67 +1,50 @@
package vip.mate.agent.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
* request when the underlying auth is a Claude Code OAuth token.
*
* <p>Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
* (and intermittently 5xxs) requests claiming Claude Code identity but
* shaped differently from real Claude Code traffic. Symptoms:
* (and intermittently 5xxs) requests whose system prompt does NOT claim
* Claude Code identity. Symptoms include:
*
* <ul>
* <li>HTTP 429 with {@code rate_limit_error} on quiet accounts that haven't
* come close to their token budget give-away is a body of just
* come close to their token budget the give-away is a body of just
* {@code {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}
* (genuine quota exhaustion carries a descriptive message).</li>
* (genuine quota exhaustion includes a descriptive message).</li>
* <li>Sporadic 500s on the first call after a long idle period.</li>
* </ul>
*
* <p>Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
* lines 1571-1607 same transforms applied unconditionally on
* lines 1571-1607 applies the same transforms unconditionally on
* {@code is_oauth=True} requests.
*
* <h2>Transforms applied per call</h2>
* <ol>
* <li><b>System prompt prefix</b>: prepend
* {@code "You are Claude Code, Anthropic's official CLI for Claude."}.
* Insert a new SystemMessage if none exists.</li>
* <li><b>Brand scrub</b>: replace {@code "MateClaw"}/{@code "mateclaw"}
* in system text with their Claude Code equivalents Anthropic's
* content filter flags identity contradictions.</li>
* <li><b>Tool {@code mcp_} prefix (outgoing)</b>: every tool definition
* sent to Anthropic is renamed {@code mcp_<orig>} Claude Code
* runs all tools through MCP servers, so real Claude Code traffic
* always has the prefix. Mismatch trips anti-abuse.</li>
* <li><b>History tool_use prefix</b>: previously-issued tool calls in
* AssistantMessage history get the prefix re-applied (we strip on
* response, so they're stored unprefixed).</li>
* <li><b>Tool {@code mcp_} prefix (incoming)</b>: ChatResponse tool_use
* names are stripped of the {@code mcp_} prefix so MateClaw's tool
* registry can resolve them.</li>
* <li>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.</li>
* <li>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.</li>
* </ol>
*
* <p><b>Tool-name {@code mcp_} prefix</b> (hermes lines 1593-1607) is a
* separate concern: it requires bidirectional rewriting (out + back) and
* touches Spring AI's tool-callback layer. Deferred to a follow-up current
* symptom is rate-limit on memory analysis (which uses no tools).
*/
@Slf4j
public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
@ -70,9 +53,6 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
static final String CLAUDE_CODE_SYSTEM_PREFIX =
"You are Claude Code, Anthropic's official CLI for Claude.";
/** Tool-name prefix Claude Code uses for all MCP-routed tools. */
static final String MCP_TOOL_PREFIX = "mcp_";
private final ChatModel delegate;
public ClaudeCodeIdentityChatModelDecorator(ChatModel delegate) {
@ -81,12 +61,12 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
@Override
public ChatResponse call(Prompt prompt) {
return stripToolPrefixes(delegate.call(transform(prompt)));
return delegate.call(transform(prompt));
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return delegate.stream(transform(prompt)).map(this::stripToolPrefixes);
return delegate.stream(transform(prompt));
}
@Override
@ -94,10 +74,6 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
return delegate.getDefaultOptions();
}
/* ====================================================================== */
/* Outbound transform: Prompt → Prompt with identity + tool prefix */
/* ====================================================================== */
/**
* Build a new {@link Prompt} with the OAuth identity transforms applied.
* Package-private for unit tests.
@ -112,153 +88,40 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
boolean systemSeen = false;
for (Message msg : source) {
if (msg instanceof SystemMessage sm && !systemSeen) {
// Emit identity as its own block so Spring AI serialises system as an
// array. Anthropic's OAuth anti-abuse gate 429s when the identity prefix
// and additional content are merged into a single string, but accepts
// them as separate array elements (verified 2026-04-25).
rewritten.add(new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
String sanitized = sanitizeBranding(sm.getText());
if (sanitized != null && !sanitized.isBlank()) {
rewritten.add(new SystemMessage(sanitized));
}
// Only mutate the FIRST system message multiple system messages
// are rare in practice but preserve the second-onward verbatim.
rewritten.add(new SystemMessage(prependIdentity(sanitizeBranding(sm.getText()))));
systemSeen = true;
} else if (msg instanceof AssistantMessage am && am.hasToolCalls()) {
// Re-prefix tool_use names in history. We strip on response, so
// by the time MateClaw stores the AssistantMessage the names
// are unprefixed must put the prefix back when echoing the
// history to Anthropic for it to match its own prior turn.
rewritten.add(rebuildAssistantMessage(am, true));
} else {
rewritten.add(msg);
}
}
if (!systemSeen) {
// No system message at all insert one with just the identity prefix.
rewritten.add(0, new SystemMessage(CLAUDE_CODE_SYSTEM_PREFIX));
}
ChatOptions transformedOptions = transformOptions(original.getOptions());
return new Prompt(rewritten, transformedOptions);
return new Prompt(rewritten, original.getOptions());
}
/**
* Wrap each tool callback in the options so its {@code getToolDefinition().name()}
* returns {@code mcp_<orig>}. 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<ToolCallback> originalCallbacks = anthropicOpts.getToolCallbacks();
Set<String> 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<ToolCallback> 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<String> 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<Generation> origGens = response.getResults();
if (origGens == null || origGens.isEmpty()) {
return response;
}
List<Generation> 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<AssistantMessage.ToolCall> rebuilt = new ArrayList<>(original.getToolCalls().size());
for (AssistantMessage.ToolCall tc : original.getToolCalls()) {
String name = tc.name();
String newName;
if (prefix) {
newName = (name == null || name.startsWith(MCP_TOOL_PREFIX)) ? name : MCP_TOOL_PREFIX + name;
} else {
newName = (name != null && name.startsWith(MCP_TOOL_PREFIX))
? name.substring(MCP_TOOL_PREFIX.length()) : name;
}
rebuilt.add(new AssistantMessage.ToolCall(tc.id(), tc.type(), newName, tc.arguments()));
}
return AssistantMessage.builder()
.content(original.getText())
.properties(original.getMetadata())
.toolCalls(rebuilt)
.media(original.getMedia())
.build();
}
/* ====================================================================== */
/* String helpers (system prompt + branding) */
/* ====================================================================== */
private static String prependIdentity(String existingSystem) {
if (existingSystem == null || existingSystem.isBlank()) {
return CLAUDE_CODE_SYSTEM_PREFIX;
}
if (existingSystem.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
// Already prefixed (defensive protects against double-wrapping
// if a caller invokes the decorator twice).
return existingSystem;
}
return CLAUDE_CODE_SYSTEM_PREFIX + "\n\n" + existingSystem;
}
/**
* Replace MateClaw / agent-specific branding tokens with their Claude Code
* equivalents. Same idea as hermes-agent's
* {@code text.replace("Hermes Agent", "Claude Code")} chain Anthropic's
* server-side filter flags requests where the spoofed identity contradicts
* itself ("You are Claude Code … built by MateClaw").
*/
static String sanitizeBranding(String text) {
if (text == null || text.isEmpty()) {
return text;
@ -268,57 +131,4 @@ public class ClaudeCodeIdentityChatModelDecorator implements ChatModel {
.replace("mateclaw", "claude-code")
.replace("Mate Claw", "Claude Code");
}
/* ====================================================================== */
/* PrefixedToolCallback — wraps a ToolCallback to expose the mcp_ name */
/* ====================================================================== */
/**
* Wraps a {@link ToolCallback} so its {@code getToolDefinition().name()}
* returns {@code mcp_<orig>}, 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;
}
}
}

View File

@ -1,60 +0,0 @@
package vip.mate.agent.chatmodel;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* WebClient (streaming) counterpart of {@link ClaudeCodeSystemArrayInterceptor}.
*
* <p>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<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
ClientRequest intercepted = ClientRequest.from(request)
.body((outputMessage, context) -> request.body().insert(
new ClientHttpRequestDecorator(outputMessage) {
@Override
public Mono<Void> 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);
}
}

View File

@ -1,94 +0,0 @@
package vip.mate.agent.chatmodel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestClient interceptor that rewrites the Anthropic {@code system} field from
* a plain string to a two-element content-block array before the request hits
* the wire.
*
* <p>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).
*
* <p>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.
*
* <p>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:
* <pre>
* [ {"type":"text","text":"You are Claude Code..."}, {"type":"text","text":"<rest>"} ]
* </pre>
* 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;
}
}

View File

@ -1,66 +0,0 @@
package vip.mate.agent.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
/**
* WebClient (streaming) counterpart of {@link RateLimitDiagnosticInterceptor}.
*
* <p>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<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
AtomicReference<String> capturedBody = new AtomicReference<>();
ClientRequest intercepted = ClientRequest.from(request)
.body((outputMessage, context) -> request.body().insert(
new ClientHttpRequestDecorator(outputMessage) {
@Override
public Mono<Void> 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());
}
});
}
}

View File

@ -1,115 +0,0 @@
package vip.mate.agent.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* On 429, logs the outgoing request headers (sanitized), the request body
* (first {@link #BODY_LOG_LIMIT} bytes), and the {@code anthropic-ratelimit-*}
* response headers to distinguish three failure modes:
*
* <table>
* <caption>How to read the response headers</caption>
* <tr><th>Failure mode</th><th>tokens-remaining</th><th>retry-after</th></tr>
* <tr><td>5h Pro/Max quota exhausted</td><td>0</td><td>thousands of seconds</td></tr>
* <tr><td>Anti-abuse fingerprint gate</td><td>(absent)</td><td>(absent)</td></tr>
* <tr><td>Per-minute burst limit</td><td>large</td><td>single-digit seconds</td></tr>
* </table>
*
* <p>Sync (RestClient) variant; the WebFlux equivalent is
* {@link RateLimitDiagnosticExchangeFilter}.
*/
@Slf4j
class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor {
static final int BODY_LOG_LIMIT = 16384;
static final List<String> 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<String> 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 <redacted>");
} 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());
}
}
}

View File

@ -20,7 +20,7 @@ import java.util.List;
* <caption>Header set sent on OAuth requests</caption>
* <tr><th>Header</th><th>Value</th><th>Why</th></tr>
* <tr><td>{@code Authorization}</td><td>{@code Bearer <accessToken>}</td><td>OAuth path uses Bearer; non-OAuth uses {@code x-api-key}</td></tr>
* <tr><td>{@code User-Agent}</td><td>{@code claude-cli/<version>}</td><td>bare form suffix triggers anti-abuse fingerprint, see {@link #userAgent()}</td></tr>
* <tr><td>{@code User-Agent}</td><td>{@code claude-cli/<version> (external, cli)}</td><td>Anthropic routes OAuth by UA; spoof identity</td></tr>
* <tr><td>{@code x-app}</td><td>{@code cli}</td><td>Claude Code identity flag</td></tr>
* <tr><td>{@code anthropic-beta}</td><td>(comma-joined list see {@link #allBetas()})</td><td>OAuth-only + common feature betas</td></tr>
* </table>
@ -48,30 +48,22 @@ public class ClaudeCodeApiHeaders {
/**
* Comma-joined beta header list to send in {@code anthropic-beta}.
* <p>Order matches hermes-agent {@code anthropic_adapter} line 427:
* {@code common_betas + _OAUTH_ONLY_BETAS} common betas first, OAuth betas appended.
* <p>Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS +
* _COMMON_BETAS} (OAuth-specific betas first).
*/
public String allBetas() {
return String.join(",",
concat(COMMON_BETAS, OAUTH_ONLY_BETAS));
concat(OAUTH_ONLY_BETAS, COMMON_BETAS));
}
/**
* User-Agent string Anthropic OAuth infrastructure expects.
* Format: {@code claude-cli/<version>} bare, no suffix.
*
* <p><b>History note:</b> 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/<v>} form, which is what openclaw
* ({@code anthropic-transport-stream.ts:30,572}) also uses. Verified by
* reproducing 429 with the suffix and {@code anthropic-ratelimit-*}
* headers absent the diagnostic signature of the anti-abuse path.
* Format: {@code claude-cli/<version> (external, cli)}.
* The {@code (external, cli)} suffix is the canonical hermes / OpenCode /
* Cline identity drop it and Anthropic returns 400.
*/
public String userAgent() {
return "claude-cli/" + versionDetector.get();
return "claude-cli/" + versionDetector.get() + " (external, cli)";
}
/** {@code x-app} header value. Constant. */

View File

@ -80,11 +80,8 @@ public class ClaudeCodeTokenRefresher {
.uri(endpoint)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
// Bare UA see ClaudeCodeApiHeaders.userAgent() javadoc
// for why the (external, cli) suffix would trip Anthropic's
// anti-abuse fingerprint.
.header(HttpHeaders.USER_AGENT,
"claude-cli/" + versionDetector.get())
"claude-cli/" + versionDetector.get() + " (external, cli)")
.body(body)
.retrieve()
.body(String.class);