mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
158 lines
8.0 KiB
Java
158 lines
8.0 KiB
Java
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);
|
|
|
|
AnthropicChatModel raw = AnthropicChatModel.builder()
|
|
.anthropicApi(api)
|
|
.defaultOptions(options)
|
|
.retryTemplate(retry)
|
|
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
|
.build();
|
|
|
|
// 4) Wrap with the OAuth identity decorator. Anthropic's edge rate-limits /
|
|
// 5xxs requests that don't claim Claude Code identity in the system
|
|
// prompt — symptom: 429 rate_limit_error with body "Error" on quiet
|
|
// accounts. See ClaudeCodeIdentityChatModelDecorator javadoc.
|
|
return new ClaudeCodeIdentityChatModelDecorator(raw);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 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);
|
|
|
|
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);
|
|
|
|
// 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();
|
|
}
|
|
}
|