feat(anthropic): wire Claude Code OAuth into chat model

This commit is contained in:
matevip 2026-04-26 08:34:08 +08:00
parent 8539fb9407
commit a7938b0e68
5 changed files with 163 additions and 4 deletions

View File

@ -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.
*
* <p>Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
* (RFC-062) can apply the same timeouts to its OAuth RestClient without
* duplicating the snippet.</p>
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();

View File

@ -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}.
*
* <p>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.
*
* <h2>How OAuth changes the wire format</h2>
* <ol>
* <li>{@code Authorization: Bearer <oauth-token>} 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.</li>
* <li>{@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.</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>
* <p>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<RestClient.Builder> restClientBuilderProvider;
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentClaudeCodeChatModelBuilder(
AgentAnthropicChatModelBuilder anthropicBuilder,
ClaudeCodeOAuthService oauthService,
ClaudeCodeApiHeaders apiHeaders,
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
ObjectProvider<ObservationRegistry> 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();
}
}

View File

@ -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;
};
}

View File

@ -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");

View File

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