From a7938b0e68090b90f9af3f761bcc74cfe585273e Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 08:34:08 +0800 Subject: [PATCH] feat(anthropic): wire Claude Code OAuth into chat model --- .../AgentAnthropicChatModelBuilder.java | 6 +- .../AgentClaudeCodeChatModelBuilder.java | 140 ++++++++++++++++++ .../vip/mate/llm/cache/CachePlanContext.java | 4 +- .../vip/mate/llm/model/ModelProtocol.java | 6 + .../llm/service/ModelDiscoveryService.java | 11 +- 5 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java 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

+ *
    + *
  1. {@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.
  2. + *
  3. {@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.
  4. + *
  5. {@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.
  6. + *
+ * + *

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 不支持模型测试"); }; }