diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
index cb693bd6..4aeb7dd1 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
@@ -187,8 +187,12 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* Apply 10s connect / 180s read timeouts. The 180s read covers the case
* where nginx caps the gateway at 60s but a real long thinking response
* needs more — the upper retry layer takes over once we time out.
+ *
+ *
Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
+ * (RFC-062) can apply the same timeouts to its OAuth RestClient without
+ * duplicating the snippet.
*/
- private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
+ static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
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
new file mode 100644
index 00000000..0acc89b8
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java
@@ -0,0 +1,140 @@
+package vip.mate.agent.chatmodel;
+
+import io.micrometer.observation.ObservationRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.anthropic.AnthropicChatModel;
+import org.springframework.ai.anthropic.AnthropicChatOptions;
+import org.springframework.ai.anthropic.api.AnthropicApi;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.model.NoopApiKey;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.http.HttpHeaders;
+import org.springframework.retry.support.RetryTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.reactive.function.client.WebClient;
+import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
+import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
+import vip.mate.llm.chatmodel.ChatModelBuilder;
+import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.model.ModelProtocol;
+import vip.mate.llm.model.ModelProviderEntity;
+
+/**
+ * RFC-062: Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
+ *
+ * Sends Anthropic Messages API requests authenticated with the user's
+ * Claude Code OAuth subscription token instead of an API key — letting users
+ * with a Claude Pro/Max plan run MateClaw against their existing entitlement.
+ *
+ *
How OAuth changes the wire format
+ *
+ * - {@code Authorization: Bearer } replaces {@code x-api-key}.
+ * Spring AI's {@link AnthropicApi} only sets {@code x-api-key} when the
+ * supplied {@code ApiKey.getValue()} returns a non-blank string, so we
+ * pass a {@link NoopApiKey} to satisfy the non-null assertion without
+ * leaking a key header.
+ * - {@code anthropic-beta} must include {@code claude-code-20250219} and
+ * {@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.
+ *
+ *
+ * Token lifecycle
+ * Each {@link #build} call asks {@link ClaudeCodeOAuthService} for a valid
+ * access token. The service auto-refreshes when within 60s of expiry and
+ * persists the fresh credential back to whichever source (Keychain / JSON
+ * file) it originally read from. The constructed {@link AnthropicApi} pins
+ * the token at build time — for a multi-hour session this is fine because
+ * tokens last hours and Spring AI's call-site retry covers the rare case
+ * where a token rolls mid-call (next request rebuilds with a fresh token).
+ */
+@Slf4j
+@Component
+public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
+
+ private final AgentAnthropicChatModelBuilder anthropicBuilder;
+ private final ClaudeCodeOAuthService oauthService;
+ private final ClaudeCodeApiHeaders apiHeaders;
+ private final ObjectProvider restClientBuilderProvider;
+ private final ObjectProvider webClientBuilderProvider;
+ private final ObjectProvider observationRegistryProvider;
+
+ public AgentClaudeCodeChatModelBuilder(
+ AgentAnthropicChatModelBuilder anthropicBuilder,
+ ClaudeCodeOAuthService oauthService,
+ ClaudeCodeApiHeaders apiHeaders,
+ ObjectProvider restClientBuilderProvider,
+ ObjectProvider webClientBuilderProvider,
+ ObjectProvider observationRegistryProvider) {
+ this.anthropicBuilder = anthropicBuilder;
+ this.oauthService = oauthService;
+ this.apiHeaders = apiHeaders;
+ this.restClientBuilderProvider = restClientBuilderProvider;
+ this.webClientBuilderProvider = webClientBuilderProvider;
+ this.observationRegistryProvider = observationRegistryProvider;
+ }
+
+ @Override
+ public ModelProtocol supportedProtocol() {
+ return ModelProtocol.ANTHROPIC_CLAUDE_CODE;
+ }
+
+ @Override
+ public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
+ // 1) Pull a fresh access token (auto-refreshes when near expiry; throws
+ // err.anthropic.no_claude_code or err.anthropic.token_expired_no_refresh
+ // so the UI / global handler can present an actionable message).
+ String accessToken = oauthService.getValidToken();
+
+ // 2) Build the Anthropic API client wired with OAuth headers.
+ AnthropicApi api = buildOauthAnthropicApi(accessToken);
+
+ // 3) Reuse the canonical Anthropic options builder — same Claude 4.7
+ // sampling-params handling, thinking-budget mapping, prompt cache.
+ AnthropicChatOptions options = anthropicBuilder.buildAnthropicOptions(model);
+
+ return AnthropicChatModel.builder()
+ .anthropicApi(api)
+ .defaultOptions(options)
+ .retryTemplate(retry)
+ .observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
+ .build();
+ }
+
+ /**
+ * Construct an {@link AnthropicApi} whose underlying RestClient + WebClient
+ * are pre-stamped with OAuth-mode headers. Package-private so unit tests
+ * can verify header composition without spinning up a chat model.
+ */
+ AnthropicApi buildOauthAnthropicApi(String accessToken) {
+ String authHeader = apiHeaders.bearerAuth(accessToken);
+ String userAgent = apiHeaders.userAgent();
+ String xApp = apiHeaders.xApp();
+ String betas = apiHeaders.allBetas();
+
+ RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
+ restClientBuilderProvider.getIfAvailable(RestClient::builder))
+ .defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
+ .defaultHeader(HttpHeaders.USER_AGENT, userAgent)
+ .defaultHeader("x-app", xApp);
+
+ WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
+ .defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
+ .defaultHeader(HttpHeaders.USER_AGENT, userAgent)
+ .defaultHeader("x-app", xApp);
+
+ // NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing
+ // skips x-api-key. The Builder.build() Assert.notNull on apiKey still
+ // passes because the object is non-null.
+ return AnthropicApi.builder()
+ .apiKey(new NoopApiKey())
+ .anthropicBetaFeatures(betas)
+ .restClientBuilder(restClientBuilder)
+ .webClientBuilder(webClientBuilder)
+ .build();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java
index 3bf30d0c..4ce56d5e 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java
@@ -58,7 +58,9 @@ public record CachePlanContext(
/** 协议是否原生支持 cache_control 标记。DashScope/Ollama/Gemini 自有缓存机制,无需接入。 */
public boolean protocolSupportsCacheControl() {
return switch (protocol) {
- case ANTHROPIC_MESSAGES, OPENAI_CHATGPT -> true;
+ // Claude Code OAuth (RFC-062) tunnels through the same Messages API,
+ // so it inherits Anthropic-native cache_control support.
+ case ANTHROPIC_MESSAGES, ANTHROPIC_CLAUDE_CODE, OPENAI_CHATGPT -> true;
case OPENAI_COMPATIBLE, DASHSCOPE_NATIVE, GEMINI_NATIVE -> false;
};
}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java
index 42699fe4..6b6c2e44 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java
@@ -7,6 +7,12 @@ public enum ModelProtocol {
OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"),
OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"),
ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"),
+ /**
+ * RFC-062: same Anthropic Messages API but authenticated with the user's
+ * Claude Code OAuth token (Pro/Max subscription) instead of an API key.
+ * Routed by {@code AgentClaudeCodeChatModelBuilder}.
+ */
+ ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"),
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),
DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel");
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
index 4ed7826f..e43ffe20 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java
@@ -364,7 +364,12 @@ public class ModelDiscoveryService {
case DASHSCOPE_NATIVE -> fetchDashScopeModels(provider);
case GEMINI_NATIVE -> fetchGeminiModels(provider);
case ANTHROPIC_MESSAGES -> fetchAnthropicModels(provider);
- case OPENAI_CHATGPT -> throw new MateClawException("err.llm.chatgpt_no_discovery", "ChatGPT OAuth provider 不支持模型发现");
+ // Claude Code OAuth provider has a fixed model catalog (Anthropic
+ // doesn't expose model discovery on Bearer-auth requests). Models
+ // are seeded via Flyway, not discovered.
+ case OPENAI_CHATGPT, ANTHROPIC_CLAUDE_CODE ->
+ throw new MateClawException("err.llm.oauth_no_discovery",
+ "OAuth provider 不支持模型发现");
};
}
@@ -456,7 +461,9 @@ public class ModelDiscoveryService {
case DASHSCOPE_NATIVE -> sendDashScopeTestPrompt(provider, modelId);
case GEMINI_NATIVE -> sendGeminiTestPrompt(provider, modelId);
case ANTHROPIC_MESSAGES -> sendAnthropicTestPrompt(provider, modelId);
- case OPENAI_CHATGPT -> throw new MateClawException("err.llm.chatgpt_no_test", "ChatGPT OAuth provider 不支持模型测试");
+ case OPENAI_CHATGPT, ANTHROPIC_CLAUDE_CODE ->
+ throw new MateClawException("err.llm.oauth_no_test",
+ "OAuth provider 不支持模型测试");
};
}