mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(anthropic): wire Claude Code OAuth into chat model
This commit is contained in:
parent
8539fb9407
commit
a7938b0e68
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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 不支持模型测试");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user