feat(anthropic): surface Claude Code OAuth in admin UI

This commit is contained in:
matevip 2026-04-26 08:34:09 +08:00
parent bf4e81c554
commit fb4c013ad8
13 changed files with 268 additions and 26 deletions

View File

@ -0,0 +1,74 @@
package vip.mate.llm.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService.OAuthStatus;
/**
* RFC-062 PR-3: management-UI surface for the Claude Code OAuth provider.
*
* <p>Unlike the OpenAI ChatGPT OAuth flow, Claude Code OAuth piggybacks on
* the user's locally-installed Claude Code client credentials live in the
* macOS Keychain or {@code ~/.claude/.credentials.json}, not in
* {@code mate_model_provider}. So this controller is read-only:
*
* <ul>
* <li>{@code GET /status} re-reads from disk and reports
* connected / expired / expiry timestamp / source.</li>
* <li>{@code POST /reload} alias of status, semantically signals
* "I just logged in via Claude Code, please re-detect" so the UI can
* refresh state without polling. Also triggers an auto-refresh of the
* access token when it's near expiry, so the user can verify end-to-end
* token validity from the management page.</li>
* </ul>
*
* <p>The PKCE-based in-app login flow (without requiring local Claude Code)
* lands in PR-4 and will add {@code /authorize} + {@code /callback-paste}
* endpoints here.
*/
@Slf4j
@Tag(name = "Anthropic Claude Code OAuth")
@RestController
@RequestMapping("/api/v1/oauth/anthropic")
@RequiredArgsConstructor
public class ClaudeCodeOAuthController {
private final ClaudeCodeOAuthService oauthService;
@Operation(summary = "Read current Claude Code OAuth credential status from local disk")
@GetMapping("/status")
public R<OAuthStatus> status() {
return R.ok(oauthService.getStatus());
}
/**
* Re-detect credentials and force a refresh-if-near-expiry. Returns the
* post-action status so the UI can update without a follow-up GET.
*
* <p>If no credentials exist or refresh fails, the underlying exception is
* caught here and surfaced as {@code connected=false} status the UI
* shouldn't show a red banner just because the user hasn't logged into
* Claude Code yet.
*/
@Operation(summary = "Force re-detect credentials and refresh if near expiry")
@PostMapping("/reload")
public R<OAuthStatus> reload() {
try {
oauthService.getValidToken();
} catch (Exception e) {
// Expected when not logged in or refresh upstream is down.
// Status response carries enough detail (connected/expired/source)
// for the UI to decide what to render.
log.debug("[ClaudeCodeOAuth] reload encountered: {}", e.getMessage());
}
return R.ok(oauthService.getStatus());
}
}

View File

@ -4,10 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
@ -22,9 +24,14 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class ModelProviderService {
/** Provider id whose OAuth token lives on local disk (Keychain / ~/.claude/.credentials.json) instead of the database. */
private static final String CLAUDE_CODE_PROVIDER_ID = "anthropic-claude-code";
private final ModelProviderMapper modelProviderMapper;
private final ModelConfigService modelConfigService;
private final ApplicationEventPublisher eventPublisher;
/** Lazy provider — avoids forcing the bean to exist in test contexts that don't load the anthropic package. */
private final ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthServiceProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
@ -242,8 +249,24 @@ public class ModelProviderService {
dto.setBaseUrl(provider.getBaseUrl());
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
dto.setAuthType(provider.getAuthType() != null ? provider.getAuthType() : "api_key");
dto.setOauthConnected(StringUtils.hasText(provider.getOauthAccessToken()));
dto.setOauthExpiresAt(provider.getOauthExpiresAt());
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
// Claude Code OAuth credentials live on disk (RFC-062), not in the
// mate_model_provider row. Bypass the column lookup and ask the
// service directly. Falls back to false if the bean isn't present
// (e.g. minimal test contexts).
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
if (svc != null) {
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
dto.setOauthConnected(status.connected() && !status.expired());
dto.setOauthExpiresAt(status.expiresAtMs() > 0L ? status.expiresAtMs() : null);
} else {
dto.setOauthConnected(false);
dto.setOauthExpiresAt(null);
}
} else {
dto.setOauthConnected(StringUtils.hasText(provider.getOauthAccessToken()));
dto.setOauthExpiresAt(provider.getOauthExpiresAt());
}
dto.setFallbackPriority(provider.getFallbackPriority() != null ? provider.getFallbackPriority() : 0);
List<ModelInfoDTO> builtinModels = new ArrayList<>();
List<ModelInfoDTO> extraModels = new ArrayList<>();
@ -278,6 +301,11 @@ public class ModelProviderService {
// OAuth 认证的 provider检查 OAuth token 是否存在
if ("oauth".equals(provider.getAuthType())) {
// Claude Code OAuth (RFC-062) token lives on disk, not in DB.
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
return svc != null && svc.isLoggedIn();
}
return StringUtils.hasText(provider.getOauthAccessToken());
}

View File

@ -118,6 +118,14 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a
KEY (provider_id)
VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW());
-- RFC-062: Anthropic Claude Code OAuth provider. Credentials live on local
-- disk (Keychain / ~/.claude/.credentials.json), not in this row — leave
-- api_key + oauth_access_token blank. Bearer-auth requests bypass model
-- discovery + connection check, hence both FALSE.
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
KEY (provider_id)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW());
-- ==================== Local model pre-configs (Ollama, disabled by default) ====================
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
KEY (id) VALUES
@ -258,7 +266,10 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000271, 'Claude Sonnet 4.7', 'anthropic', 'claude-sonnet-4-7', 'Anthropic Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'Claude Opus 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'Claude Sonnet 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'Claude Sonnet 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
-- RFC-062: Claude 4.7 via Claude Code OAuth subscription (Pro/Max plan).
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', 'Claude Sonnet 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);
-- Default system settings
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)

View File

@ -134,6 +134,12 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(update_time);
-- RFC-062: Anthropic Claude Code OAuth provider. Credentials live on local
-- disk (Keychain / ~/.claude/.credentials.json), not in this row.
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(update_time);
-- ==================== Local model pre-configs (Ollama, disabled by default) ====================
INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
VALUES (1000000300, 'Gemma 3', 'ollama', 'gemma3:latest', 'Google Gemma 3, lightweight and efficient for local inference', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0)
@ -298,7 +304,10 @@ VALUES
(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000271, 'Claude Sonnet 4.7', 'anthropic', 'claude-sonnet-4-7', 'Anthropic Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'Claude Opus 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'Claude Sonnet 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'Claude Sonnet 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
-- RFC-062: Claude 4.7 via Claude Code OAuth subscription (Pro/Max plan).
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', 'Claude Sonnet 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), provider=VALUES(provider), model_name=VALUES(model_name), description=VALUES(description), temperature=VALUES(temperature), max_tokens=VALUES(max_tokens), top_p=VALUES(top_p), builtin=VALUES(builtin), enabled=VALUES(enabled), is_default=VALUES(is_default), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Default system settings

View File

@ -134,6 +134,12 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(update_time);
-- RFC-062Anthropic Claude Code OAuth 订阅 provider。凭据存储在本地磁盘
-- macOS Keychain 或 ~/.claude/.credentials.json不写入该行。
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth 订阅)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(update_time);
-- ==================== 本地模型预配置Ollama默认禁用 ====================
INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
VALUES (1000000300, 'Gemma 3', 'ollama', 'gemma3:latest', 'Google Gemma 3轻量高效适合本地推理', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0)
@ -298,7 +304,10 @@ VALUES
(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7xhigh 自适应思考)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000271, 'Claude Sonnet 4.7', 'anthropic', 'claude-sonnet-4-7', 'Anthropic Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'OpenRouter 代理 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'OpenRouter 代理 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'OpenRouter 代理 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
-- RFC-062通过 Claude Code Pro/Max 订阅调用 Claude 4.7
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', '通过 Claude Code Pro/Max 订阅调用 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', '通过 Claude Code Pro/Max 订阅调用 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), provider=VALUES(provider), model_name=VALUES(model_name), description=VALUES(description), temperature=VALUES(temperature), max_tokens=VALUES(max_tokens), top_p=VALUES(top_p), builtin=VALUES(builtin), enabled=VALUES(enabled), is_default=VALUES(is_default), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- 默认系统设置

View File

@ -118,6 +118,12 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a
KEY (provider_id)
VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW());
-- RFC-062Anthropic Claude Code OAuth 订阅 provider。凭据存储在本地磁盘
-- macOS Keychain 或 ~/.claude/.credentials.json不写入该行。
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
KEY (provider_id)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth 订阅)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW());
-- ==================== 本地模型预配置Ollama默认禁用用户拉取后启用 ====================
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
KEY (id)
@ -264,7 +270,10 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7xhigh 自适应思考)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000271, 'Claude Sonnet 4.7', 'anthropic', 'claude-sonnet-4-7', 'Anthropic Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'OpenRouter 代理 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'OpenRouter 代理 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);
(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'OpenRouter 代理 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
-- RFC-062通过 Claude Code Pro/Max 订阅调用 Claude 4.7
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', '通过 Claude Code Pro/Max 订阅调用 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', '通过 Claude Code Pro/Max 订阅调用 Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);
-- 默认系统设置
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)

View File

@ -0,0 +1,16 @@
-- RFC-062: Seed the Anthropic Claude Code OAuth provider + its Claude 4.7
-- model bindings on existing deployments. New installs already get these
-- rows from data-{en,zh,mysql-en,mysql-zh}.sql via DatabaseBootstrapRunner;
-- this migration is for operators upgrading from <= V42.
--
-- MERGE INTO is the H2 idempotent upsert; running this twice is a no-op.
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
KEY (provider_id)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW());
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
KEY (id)
VALUES
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', 'Claude Sonnet 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0);

View File

@ -0,0 +1,34 @@
-- RFC-062: Seed the Anthropic Claude Code OAuth provider + its Claude 4.7
-- model bindings on existing deployments. New installs already get these
-- rows from data-mysql-{en,zh}.sql via DatabaseBootstrapRunner; this
-- migration is for operators upgrading from <= V42.
--
-- INSERT ... ON DUPLICATE KEY UPDATE is the MySQL idempotent upsert.
-- Same V number is used in h2/ for cross-dialect parity.
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time)
VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW())
ON DUPLICATE KEY UPDATE
name = VALUES(name),
chat_model = VALUES(chat_model),
base_url = VALUES(base_url),
auth_type = VALUES(auth_type),
update_time = VALUES(update_time);
INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
VALUES
(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', 'Claude Sonnet 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
provider = VALUES(provider),
model_name = VALUES(model_name),
description = VALUES(description),
temperature = VALUES(temperature),
max_tokens = VALUES(max_tokens),
top_p = VALUES(top_p),
builtin = VALUES(builtin),
enabled = VALUES(enabled),
is_default = VALUES(is_default),
update_time = VALUES(update_time),
deleted = VALUES(deleted);

View File

@ -330,6 +330,14 @@ export const oauthApi = {
revoke: () => http.delete('/oauth/openai/revoke'),
}
// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code
// install — no in-app authorize/revoke flow yet (PR-4). Until then the UI
// can only check status + force a re-detect from disk.
export const claudeCodeOAuthApi = {
status: () => http.get('/oauth/anthropic/status'),
reload: () => http.post('/oauth/anthropic/reload'),
}
// ==================== Setup ====================
export const setupApi = {
onboardingStatus: () => http.get('/setup/onboarding-status'),

View File

@ -363,6 +363,11 @@ export default {
oauthHint: 'Sign in with your OpenAI account to use ChatGPT Plus/Pro member quota (not API credits).',
oauthLoginSuccess: 'OpenAI OAuth login successful',
oauthRevokeSuccess: 'OpenAI OAuth disconnected',
// RFC-062: Claude Code OAuth (subscription piggyback)
claudeCodeOauthDetect: 'Detect Claude Code Login',
claudeCodeOauthHint: 'Reuses your local Claude Code Pro/Max subscription. Sign in via the Claude Code app first, then click "Detect" to pick up the credentials.',
claudeCodeOauthInstructions: 'No Claude Code credentials found. Install Claude Code, sign in with a Pro/Max account, then click Detect again.',
claudeCodeOauthRevokeHint: 'Sign out from the Claude Code app to revoke. MateClaw does not modify Claude Code\'s on-disk credentials.',
fields: {
providerId: 'Provider ID',
providerName: 'Provider Name',

View File

@ -353,6 +353,11 @@ export default {
oauthHint: '通过 OAuth 登录 OpenAI 账号,使用 ChatGPT Plus/Pro 会员额度(非 API 额度)。',
oauthLoginSuccess: 'OpenAI OAuth 登录成功',
oauthRevokeSuccess: '已断开 OpenAI OAuth 连接',
// RFC-062Claude Code OAuth订阅复用
claudeCodeOauthDetect: '检测 Claude Code 登录态',
claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。',
claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。',
claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。',
fields: {
providerId: 'Provider ID',
providerName: 'Provider 名称',

View File

@ -38,20 +38,26 @@
v-if="!editingProvider?.oauthConnected"
class="btn-oauth"
type="button"
@click="$emit('oauthLogin')"
@click="$emit('oauthLogin', editingProvider?.id)"
>
{{ t('settings.model.oauthLogin') }}
{{ editingProvider?.id === 'anthropic-claude-code'
? t('settings.model.claudeCodeOauthDetect')
: t('settings.model.oauthLogin') }}
</button>
<button
v-else
class="btn-oauth btn-oauth-revoke"
type="button"
@click="$emit('oauthRevoke')"
@click="$emit('oauthRevoke', editingProvider?.id)"
>
{{ t('settings.model.oauthDisconnect') }}
</button>
</div>
<div class="field-hint">{{ t('settings.model.oauthHint') }}</div>
<div class="field-hint">
{{ editingProvider?.id === 'anthropic-claude-code'
? t('settings.model.claudeCodeOauthHint')
: t('settings.model.oauthHint') }}
</div>
</div>
<!-- API Key 输入区域 OAuth 时显示 -->
<div v-else class="form-group">
@ -182,8 +188,10 @@ defineEmits<{
close: []
save: []
toggleAdvanced: []
oauthLogin: []
oauthRevoke: []
// RFC-062: providerId tells the handler which OAuth flow to dispatch
// (anthropic-claude-code reuses local creds, openai-chatgpt opens auth URL).
oauthLogin: [providerId?: string]
oauthRevoke: [providerId?: string]
}>()
</script>

View File

@ -1,7 +1,7 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { modelApi, oauthApi, providerPoolApi } from '@/api'
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
import type { ProviderPoolEntry } from '@/api'
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
@ -445,6 +445,7 @@ export function useProviders() {
'zhipu-intl': '/icons/providers/zhipu.svg',
'volcengine': '/icons/providers/volcengine.svg',
'openai-chatgpt': '/icons/providers/openai.svg',
'anthropic-claude-code': '/icons/providers/anthropic.svg',
}
function getProviderIcon(providerId: string): string {
@ -453,7 +454,34 @@ export function useProviders() {
// ==================== OAuth ====================
async function handleOAuthLogin() {
/** Refresh editingProvider after a load so the modal state stays in sync. */
async function reloadProvidersAndSync() {
await loadProviders()
if (editingProvider.value) {
const updated = providers.value.find(p => p.id === editingProvider.value!.id)
if (updated) editingProvider.value = updated
}
}
async function handleOAuthLogin(providerId?: string) {
// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code
// install. There's no in-app authorize URL — the "Connect" button just
// re-reads credentials from disk so a user who logged in via Claude Code
// sees the connection appear without restarting the server.
if (providerId === 'anthropic-claude-code') {
try {
const res: any = await claudeCodeOAuthApi.reload()
if (res.data?.connected && !res.data?.expired) {
ElMessage.success(t('settings.model.oauthLoginSuccess'))
} else {
ElMessage.warning(t('settings.model.claudeCodeOauthInstructions'))
}
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'Claude Code OAuth detection failed')
}
return
}
try {
const res: any = await oauthApi.authorize()
const { authorizeUrl } = res.data
@ -467,12 +495,7 @@ export function useProviders() {
clearInterval(pollInterval)
if (authWindow && !authWindow.closed) authWindow.close()
ElMessage.success(t('settings.model.oauthLoginSuccess'))
await loadProviders()
// 刷新当前编辑的 provider
if (editingProvider.value) {
const updated = providers.value.find(p => p.id === editingProvider.value!.id)
if (updated) editingProvider.value = updated
}
await reloadProvidersAndSync()
}
} catch { /* ignore polling errors */ }
}, 2000)
@ -483,15 +506,18 @@ export function useProviders() {
}
}
async function handleOAuthRevoke() {
async function handleOAuthRevoke(providerId?: string) {
// Claude Code OAuth credentials live on disk — MateClaw doesn't manage
// them, so we don't expose a revoke that would clobber the user's
// Claude Code login. Direct them to log out from the Claude Code app.
if (providerId === 'anthropic-claude-code') {
ElMessage.info(t('settings.model.claudeCodeOauthRevokeHint'))
return
}
try {
await oauthApi.revoke()
ElMessage.success(t('settings.model.oauthRevokeSuccess'))
await loadProviders()
if (editingProvider.value) {
const updated = providers.value.find(p => p.id === editingProvider.value!.id)
if (updated) editingProvider.value = updated
}
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'OAuth revoke failed')
}