From afe8466fa5b6819463b09a3125c6a04f40729d29 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 7 Apr 2026 15:32:55 +0800 Subject: [PATCH] feat(llm): add OpenAI ChatGPT OAuth login for Plus/Pro member access --- .../vip/mate/agent/AgentGraphBuilder.java | 10 +- .../mate/llm/chatgpt/ChatGPTChatModel.java | 86 ++++ .../llm/chatgpt/ChatGPTResponsesClient.java | 209 +++++++++ .../mate/llm/controller/OAuthController.java | 45 ++ .../vip/mate/llm/model/ModelProtocol.java | 1 + .../mate/llm/model/ModelProviderEntity.java | 10 + .../vip/mate/llm/model/ProviderInfoDTO.java | 3 + .../mate/llm/oauth/OpenAIOAuthService.java | 433 ++++++++++++++++++ .../llm/service/ModelDiscoveryService.java | 2 + .../llm/service/ModelProviderService.java | 8 + .../src/main/resources/db/data-en.sql | 8 +- .../src/main/resources/db/data-mysql-en.sql | 8 +- .../src/main/resources/db/data-mysql-zh.sql | 8 +- .../src/main/resources/db/data-zh.sql | 8 +- .../src/main/resources/db/schema.sql | 10 + mateclaw-ui/src/api/index.ts | 8 + mateclaw-ui/src/i18n/locales/en-US.ts | 8 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 8 + mateclaw-ui/src/types/index.ts | 3 + .../views/Settings/Models/ProviderCard.vue | 12 +- .../src/views/Settings/Models/index.vue | 4 + .../Models/modals/ProviderConfigModal.vue | 44 +- .../src/views/Settings/Models/useProviders.ts | 51 ++- 23 files changed, 979 insertions(+), 8 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 7ed04175..6d6d7622 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -113,6 +113,7 @@ public class AgentGraphBuilder { private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties; private final WorkspaceFileService workspaceFileService; private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager; + private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient; /** * 根据 AgentEntity 构建完整的 Agent 实例 @@ -427,7 +428,8 @@ public class AgentGraphBuilder { private boolean supportsStateGraph(ModelProtocol protocol) { return protocol == ModelProtocol.DASHSCOPE_NATIVE || protocol == ModelProtocol.OPENAI_COMPATIBLE - || protocol == ModelProtocol.ANTHROPIC_MESSAGES; + || protocol == ModelProtocol.ANTHROPIC_MESSAGES + || protocol == ModelProtocol.OPENAI_CHATGPT; } // ==================== 模型构建 ==================== @@ -449,6 +451,12 @@ public class AgentGraphBuilder { .build(); } + if (protocol == ModelProtocol.OPENAI_CHATGPT) { + Double temp = runtimeModel.getTemperature() != null ? runtimeModel.getTemperature() : 0.7; + return new vip.mate.llm.chatgpt.ChatGPTChatModel( + chatGPTResponsesClient, runtimeModel.getModelName(), temp); + } + if (protocol == ModelProtocol.OPENAI_COMPATIBLE) { OpenAiApi api = buildOpenAiApi(provider); OpenAiChatOptions options = buildOpenAiOptions(runtimeModel, provider); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java new file mode 100644 index 00000000..b0a1609d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java @@ -0,0 +1,86 @@ +package vip.mate.llm.chatgpt; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.List; + +/** + * ChatGPT 会员模型 — 实现 Spring AI ChatModel 接口, + * 内部通过 ChatGPTResponsesClient 调用 chatgpt.com/backend-api。 + */ +@Slf4j +public class ChatGPTChatModel implements ChatModel { + + private final ChatGPTResponsesClient client; + private final String modelName; + private final Double temperature; + + public ChatGPTChatModel(ChatGPTResponsesClient client, String modelName, Double temperature) { + this.client = client; + this.modelName = modelName; + this.temperature = temperature; + } + + @Override + public ChatResponse call(Prompt prompt) { + List messages = prompt.getInstructions(); + String model = resolveModel(prompt); + Double temp = resolveTemperature(prompt); + + log.debug("ChatGPT call: model={}, messages={}", model, messages.size()); + String content = client.call(model, messages, temp); + + Generation generation = new Generation(new AssistantMessage(content), + ChatGenerationMetadata.builder().finishReason("stop").build()); + return new ChatResponse(List.of(generation), + ChatResponseMetadata.builder().model(model).build()); + } + + @Override + public Flux stream(Prompt prompt) { + List messages = prompt.getInstructions(); + String model = resolveModel(prompt); + Double temp = resolveTemperature(prompt); + + log.debug("ChatGPT stream: model={}, messages={}", model, messages.size()); + return client.stream(model, messages, temp) + .map(delta -> { + Generation generation = new Generation(new AssistantMessage(delta), + ChatGenerationMetadata.builder().finishReason(null).build()); + return new ChatResponse(List.of(generation), + ChatResponseMetadata.builder().model(model).build()); + }); + } + + @Override + public ChatOptions getDefaultOptions() { + return ChatOptions.builder() + .model(modelName) + .temperature(temperature) + .build(); + } + + private String resolveModel(Prompt prompt) { + if (prompt.getOptions() != null && prompt.getOptions().getModel() != null) { + return prompt.getOptions().getModel(); + } + return modelName; + } + + private Double resolveTemperature(Prompt prompt) { + if (prompt.getOptions() != null && prompt.getOptions().getTemperature() != null) { + return prompt.getOptions().getTemperature(); + } + return temperature; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java new file mode 100644 index 00000000..9bebc8ce --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java @@ -0,0 +1,209 @@ +package vip.mate.llm.chatgpt; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.*; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import vip.mate.exception.MateClawException; +import vip.mate.llm.oauth.OpenAIOAuthService; + +import java.util.List; + +/** + * ChatGPT Backend API 客户端 — 调用 chatgpt.com/backend-api/codex/responses(Responses API 格式) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatGPTResponsesClient { + + private static final String BASE_URL = "https://chatgpt.com/backend-api"; + private static final String RESPONSES_PATH = "/codex/responses"; + + private final OpenAIOAuthService oauthService; + private final ObjectMapper objectMapper; + private final WebClient webClient = WebClient.create(); + + /** + * 同步调用 — ChatGPT Backend API 强制要求 stream=true, + * 所以实际仍走 SSE,只是收集完整响应后再返回。 + */ + public String call(String model, List messages, Double temperature) { + return stream(model, messages, temperature) + .collectList() + .map(chunks -> String.join("", chunks)) + .block(); + } + + /** + * 流式调用 Responses API (SSE) + */ + public Flux stream(String model, List messages, Double temperature) { + String accessToken = oauthService.ensureValidAccessToken(); + String accountId = oauthService.getAccountId(); + ObjectNode requestBody = buildRequestBody(model, messages, temperature); + String bodyJson = requestBody.toString(); + log.info("ChatGPT request body: {}", bodyJson); + + return webClient.post() + .uri(BASE_URL + RESPONSES_PATH) + .headers(h -> setHeaders(h, accessToken, accountId)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.TEXT_EVENT_STREAM) + .bodyValue(bodyJson) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + response -> response.bodyToMono(String.class) + .map(errorBody -> { + log.error("ChatGPT API error {}: {}", response.statusCode(), errorBody); + return new MateClawException("ChatGPT API " + response.statusCode() + ": " + errorBody); + })) + .bodyToFlux(String.class) + .doOnNext(raw -> log.debug("ChatGPT SSE raw: {}", raw.length() > 200 ? raw.substring(0, 200) + "..." : raw)) + .filter(line -> !line.isBlank() && !line.equals("[DONE]")) + .map(line -> { + // SSE 格式:每行以 "data: " 开头,需要去掉前缀 + if (line.startsWith("data: ")) return line.substring(6); + if (line.startsWith("data:")) return line.substring(5); + return line; + }) + .filter(line -> !line.isBlank() && !line.equals("[DONE]")) + .mapNotNull(this::extractDeltaContent) + .onErrorMap(e -> e instanceof MateClawException ? e + : new MateClawException("ChatGPT 流式调用失败: " + e.getMessage())); + } + + // ==================== 请求构建 ==================== + + ObjectNode buildRequestBody(String model, List messages, Double temperature) { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", model); + body.put("stream", true); // ChatGPT Backend API 强制要求 stream=true + body.put("store", false); + + // 从 messages 中提取 system prompt → instructions + String systemPrompt = null; + for (Message msg : messages) { + if (msg.getMessageType() == MessageType.SYSTEM) { + systemPrompt = msg.getText(); + break; + } + } + if (systemPrompt != null) { + body.put("instructions", systemPrompt); + } + + // 非 system 消息 → input 数组(Responses API 格式) + ArrayNode input = objectMapper.createArrayNode(); + int msgIndex = 0; + for (Message msg : messages) { + if (msg.getMessageType() == MessageType.SYSTEM) continue; + + if (msg.getMessageType() == MessageType.USER) { + // User: content 必须是 [{ type: "input_text", text: "..." }] 格式 + ObjectNode item = objectMapper.createObjectNode(); + item.put("role", "user"); + ArrayNode contentArr = objectMapper.createArrayNode(); + ObjectNode textPart = objectMapper.createObjectNode(); + textPart.put("type", "input_text"); + textPart.put("text", msg.getText() != null ? msg.getText() : ""); + contentArr.add(textPart); + item.set("content", contentArr); + input.add(item); + } else if (msg.getMessageType() == MessageType.ASSISTANT) { + // Assistant: 转为 output message item + ObjectNode item = objectMapper.createObjectNode(); + item.put("type", "message"); + item.put("role", "assistant"); + item.put("id", "msg_" + msgIndex); + ArrayNode contentArr = objectMapper.createArrayNode(); + ObjectNode textPart = objectMapper.createObjectNode(); + textPart.put("type", "output_text"); + textPart.put("text", msg.getText() != null ? msg.getText() : ""); + contentArr.add(textPart); + item.set("content", contentArr); + input.add(item); + } + msgIndex++; + } + body.set("input", input); + + // 注意:ChatGPT Backend API 的部分模型(如 gpt-5.4)不支持 temperature, + // 仅对非推理类旧模型(如 gpt-4o)传递此参数 + if (temperature != null && !model.startsWith("gpt-5") && !model.startsWith("o")) { + body.put("temperature", temperature); + } + + // Responses API 特有参数 + ObjectNode text = objectMapper.createObjectNode(); + text.put("verbosity", "medium"); + body.set("text", text); + + // include reasoning(OpenClaw 的标准参数) + ArrayNode include = objectMapper.createArrayNode(); + include.add("reasoning.encrypted_content"); + body.set("include", include); + + return body; + } + + // ==================== 响应解析 ==================== + + /** + * 从 SSE delta 事件中提取增量文本 + */ + private String extractDeltaContent(String eventData) { + try { + JsonNode node = objectMapper.readTree(eventData); + String type = node.path("type").asText(""); + + // response.output_text.delta — 文本增量 + if ("response.output_text.delta".equals(type)) { + return node.path("delta").asText(null); + } + + // response.completed / response.done — 结束信号 + if (type.startsWith("response.completed") || type.startsWith("response.done")) { + return null; + } + + // response.failed — 错误 + if ("response.failed".equals(type)) { + String error = node.path("response").path("error").path("message").asText("Unknown error"); + log.error("ChatGPT Responses API 返回错误: {}", error); + throw new MateClawException("ChatGPT ���回错误: " + error); + } + + return null; + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + // ==================== Headers ==================== + + private void setHeaders(HttpHeaders headers, String accessToken, String accountId) { + if (accountId == null || accountId.isBlank()) { + throw new MateClawException("chatgpt-account-id 缺失,请断开后重新 OAuth 登录"); + } + headers.setBearerAuth(accessToken); + headers.set("chatgpt-account-id", accountId); + headers.set("originator", "pi"); + headers.set("OpenAI-Beta", "responses=experimental"); + headers.set("accept", "text/event-stream"); + String os = System.getProperty("os.name", "unknown").toLowerCase(); + String release = System.getProperty("os.version", ""); + String arch = System.getProperty("os.arch", ""); + headers.set("User-Agent", "pi (" + os + " " + release + "; " + arch + ")"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java new file mode 100644 index 00000000..d2b2ea58 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java @@ -0,0 +1,45 @@ +package vip.mate.llm.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.llm.oauth.OpenAIOAuthService; +import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult; +import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult; + +@Tag(name = "OpenAI OAuth") +@RestController +@RequestMapping("/api/v1/oauth/openai") +@RequiredArgsConstructor +public class OAuthController { + + private final OpenAIOAuthService oauthService; + + @Operation(summary = "获取 OAuth 授权 URL(同时启动本地回调服务器)") + @GetMapping("/authorize") + public R authorize() { + return R.ok(oauthService.buildAuthorizeUrl()); + } + + @Operation(summary = "手动刷新 Token") + @PostMapping("/refresh") + public R refresh() { + oauthService.refreshToken(); + return R.ok(); + } + + @Operation(summary = "清除 OAuth 凭证") + @DeleteMapping("/revoke") + public R revoke() { + oauthService.revokeToken(); + return R.ok(); + } + + @Operation(summary = "获取 OAuth 连接状态") + @GetMapping("/status") + public R status() { + return R.ok(oauthService.getStatus()); + } +} 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 75dcf2c6..42699fe4 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 @@ -5,6 +5,7 @@ import java.util.Arrays; public enum ModelProtocol { OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"), + OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"), ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"), GEMINI_NATIVE("gemini-native", "GeminiChatModel"), DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel"); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java index 872f34a9..5ab1e9a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java @@ -39,6 +39,16 @@ public class ModelProviderEntity { private Boolean requireApiKey; + private String authType; + + private String oauthAccessToken; + + private String oauthRefreshToken; + + private Long oauthExpiresAt; + + private String oauthAccountId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java index 2fd9637a..09c48063 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java @@ -26,4 +26,7 @@ public class ProviderInfoDTO { private String apiKey; private String baseUrl; private Map generateKwargs; + private String authType; + private Boolean oauthConnected; + private Long oauthExpiresAt; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java new file mode 100644 index 00000000..e0900d31 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java @@ -0,0 +1,433 @@ +package vip.mate.llm.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.repository.ModelProviderMapper; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * OpenAI OAuth 服务 — 基于 PKCE 的 OAuth 2.0 流程。 + *

+ * 核心机制:在本地 1455 端口启动临时 HTTP 服务器接收回调, + * redirect_uri 固定为 http://localhost:1455/auth/callback(与 OpenAI 注册的一致)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenAIOAuthService { + + private static final String CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; + private static final String AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; + private static final String TOKEN_URL = "https://auth.openai.com/oauth/token"; + private static final String REDIRECT_URI = "http://localhost:1455/auth/callback"; + private static final String SCOPES = "openid profile email offline_access"; + private static final String PROVIDER_ID = "openai-chatgpt"; + private static final int CALLBACK_PORT = 1455; + + private final ModelProviderMapper modelProviderMapper; + private final ObjectMapper objectMapper; + private final RestClient restClient = RestClient.create(); + + /** state → code_verifier 缓存 */ + private final ConcurrentHashMap pendingStates = new ConcurrentHashMap<>(); + + /** 当前运行中的回调服务器(用于启动新服务器前关闭旧的) */ + private volatile HttpServer activeCallbackServer; + + // ==================== OAuth 流程 ==================== + + /** + * 生成授权 URL 并启动本地回调服务器。 + *

+ * 流程: + * 1. 生成 PKCE code_verifier + code_challenge + * 2. 启动 localhost:1455 临时 HTTP 服务器 + * 3. 返回授权 URL,前端打开浏览器 + * 4. 用户在 OpenAI 登录后,浏览器重定向到 localhost:1455/auth/callback + * 5. 临时服务器收到 code,交换 token,保存凭证 + */ + public OAuthAuthorizeResult buildAuthorizeUrl() { + String codeVerifier = generateCodeVerifier(); + String codeChallenge = generateCodeChallenge(codeVerifier); + String state = generateState(); + + pendingStates.put(state, codeVerifier); + + // 启动本地回调服务器(异步等待回调) + startCallbackServer(state); + + String url = AUTHORIZE_URL + + "?response_type=code" + + "&client_id=" + enc(CLIENT_ID) + + "&redirect_uri=" + enc(REDIRECT_URI) + + "&scope=" + enc(SCOPES) + + "&code_challenge=" + enc(codeChallenge) + + "&code_challenge_method=S256" + + "&state=" + enc(state) + + "&id_token_add_organizations=true" + + "&codex_cli_simplified_flow=true" + + "&originator=pi"; + + return new OAuthAuthorizeResult(url, state); + } + + /** + * 启动临时 HTTP 服务器在 localhost:1455 监听回调 + */ + private void startCallbackServer(String expectedState) { + // 关闭上一次可能残留的回调服务器 + stopActiveCallbackServer(); + + CompletableFuture.runAsync(() -> { + HttpServer server = null; + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", CALLBACK_PORT), 0); + final HttpServer srv = server; + + server.createContext("/auth/callback", exchange -> { + try { + String query = exchange.getRequestURI().getQuery(); + String code = extractParam(query, "code"); + String state = extractParam(query, "state"); + + if (!expectedState.equals(state)) { + String errorHtml = "

State mismatch

OAuth state 不匹配,请重试。

"; + byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(400, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + return; + } + + if (code == null || code.isBlank()) { + String errorHtml = "

Missing code

缺少授权码。

"; + byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(400, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + return; + } + + // 交换 token + try { + exchangeToken(code, state); + String successHtml = "

✓ 登录成功

" + + "

OpenAI OAuth 授权完成,您可以关闭此窗口。

" + + "" + + ""; + byte[] bytes = successHtml.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + } catch (Exception e) { + log.error("OAuth token 交换失败", e); + String errorHtml = "

Token 交换失败

" + e.getMessage() + "

"; + byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(500, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + } + } finally { + // 收到回调后关闭服务器 + srv.stop(1); + activeCallbackServer = null; + log.info("OAuth 回调服务器已关闭"); + } + }); + + server.start(); + activeCallbackServer = server; + log.info("OAuth 回调服务器已启动在 http://127.0.0.1:{}", CALLBACK_PORT); + + // 3 分钟超时自动关闭 + final HttpServer finalServer = server; + CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> { + try { + finalServer.stop(0); + if (activeCallbackServer == finalServer) { + activeCallbackServer = null; + } + pendingStates.remove(expectedState); + log.info("OAuth 回调服务器超时关闭"); + } catch (Exception ignored) {} + }); + + } catch (java.net.BindException e) { + log.warn("端口 {} 已被占用,OAuth 回调服务器启动失败: {}", CALLBACK_PORT, e.getMessage()); + pendingStates.remove(expectedState); + } catch (Exception e) { + log.error("OAuth 回调服务器启动失败", e); + pendingStates.remove(expectedState); + if (server != null) server.stop(0); + } + }); + } + + /** + * 用 authorization code 换取 token(内部调用,由回调服务器触发) + */ + private void exchangeToken(String code, String state) { + String codeVerifier = pendingStates.remove(state); + if (codeVerifier == null) { + throw new MateClawException("无效的 OAuth state,可能已过期或重复使用"); + } + + String body = "grant_type=authorization_code" + + "&client_id=" + enc(CLIENT_ID) + + "&code=" + enc(code) + + "&code_verifier=" + enc(codeVerifier) + + "&redirect_uri=" + enc(REDIRECT_URI); + + JsonNode tokenResponse = postTokenRequest(body); + saveTokens(tokenResponse); + } + + /** + * 刷新 access_token + */ + public void refreshToken() { + ModelProviderEntity provider = getProvider(); + if (!StringUtils.hasText(provider.getOauthRefreshToken())) { + throw new MateClawException("无 refresh_token,请重新登录"); + } + + String body = "grant_type=refresh_token" + + "&refresh_token=" + enc(provider.getOauthRefreshToken()) + + "&client_id=" + enc(CLIENT_ID); + + JsonNode tokenResponse = postTokenRequest(body); + saveTokens(tokenResponse); + } + + /** + * 确保 access_token 有效(过期时自动刷新) + */ + public String ensureValidAccessToken() { + ModelProviderEntity provider = getProvider(); + if (!StringUtils.hasText(provider.getOauthAccessToken())) { + throw new MateClawException("未连接 OpenAI OAuth,请先登录"); + } + + // 提前 5 分钟刷新 + if (provider.getOauthExpiresAt() != null + && System.currentTimeMillis() > provider.getOauthExpiresAt() - 300_000) { + log.info("OpenAI OAuth token 即将过期,自动刷新..."); + refreshToken(); + provider = getProvider(); + } + + return provider.getOauthAccessToken(); + } + + /** + * 获取 account_id(用于请求 header) + */ + public String getAccountId() { + ModelProviderEntity provider = getProvider(); + String accountId = provider.getOauthAccountId(); + // 兼容修复:旧版 JWT 解析字段名错误导致 accountId 为空,从现有 token 重新解析 + if (!StringUtils.hasText(accountId) && StringUtils.hasText(provider.getOauthAccessToken())) { + accountId = extractAccountIdFromJwt(provider.getOauthAccessToken()); + if (StringUtils.hasText(accountId)) { + provider.setOauthAccountId(accountId); + modelProviderMapper.updateById(provider); + log.info("从已有 token 重新解析并保存 accountId={}", accountId); + } + } + return accountId; + } + + /** + * 清除 OAuth 凭证 + */ + public void revokeToken() { + // MyBatis Plus updateById 默认跳过 null 字段,必须用 LambdaUpdateWrapper 显式置空 + modelProviderMapper.update(null, new LambdaUpdateWrapper() + .eq(ModelProviderEntity::getProviderId, PROVIDER_ID) + .set(ModelProviderEntity::getOauthAccessToken, null) + .set(ModelProviderEntity::getOauthRefreshToken, null) + .set(ModelProviderEntity::getOauthExpiresAt, null) + .set(ModelProviderEntity::getOauthAccountId, null)); + log.info("OpenAI OAuth 凭证已清除"); + } + + /** + * 获取 OAuth 连接状态 + */ + public OAuthStatusResult getStatus() { + ModelProviderEntity provider = modelProviderMapper.selectById(PROVIDER_ID); + if (provider == null || !StringUtils.hasText(provider.getOauthAccessToken())) { + return new OAuthStatusResult(false, false, null); + } + boolean expired = provider.getOauthExpiresAt() != null + && System.currentTimeMillis() > provider.getOauthExpiresAt(); + return new OAuthStatusResult(true, expired, provider.getOauthExpiresAt()); + } + + // ==================== 内部工具方法 ==================== + + private JsonNode postTokenRequest(String formBody) { + try { + String response = restClient.post() + .uri(TOKEN_URL) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .body(formBody) + .retrieve() + .body(String.class); + return objectMapper.readTree(response); + } catch (Exception e) { + log.error("OpenAI OAuth token 请求失败", e); + throw new MateClawException("OAuth token 交换失败: " + e.getMessage()); + } + } + + private void saveTokens(JsonNode tokenResponse) { + String accessToken = tokenResponse.path("access_token").asText(null); + String refreshToken = tokenResponse.path("refresh_token").asText(null); + int expiresIn = tokenResponse.path("expires_in").asInt(3600); + + if (!StringUtils.hasText(accessToken)) { + throw new MateClawException("OAuth 响应中缺少 access_token"); + } + + String accountId = extractAccountIdFromJwt(accessToken); + + ModelProviderEntity provider = getProvider(); + provider.setOauthAccessToken(accessToken); + if (StringUtils.hasText(refreshToken)) { + provider.setOauthRefreshToken(refreshToken); + } + provider.setOauthExpiresAt(System.currentTimeMillis() + (long) expiresIn * 1000); + if (StringUtils.hasText(accountId)) { + provider.setOauthAccountId(accountId); + } + modelProviderMapper.updateById(provider); + log.info("OpenAI OAuth token 已保存,expires_in={}s, accountId={}", expiresIn, accountId); + } + + /** + * 从 JWT access_token 中解析 chatgpt_account_id + */ + String extractAccountIdFromJwt(String jwt) { + try { + String[] parts = jwt.split("\\."); + if (parts.length < 2) return null; + String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])), StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(payload); + JsonNode auth = node.path("https://api.openai.com/auth"); + if (!auth.isMissingNode()) { + String accountId = auth.path("chatgpt_account_id").asText(null); + if (accountId == null) { + accountId = auth.path("chatgpt_account_user_id").asText(null); + } + return accountId; + } + return null; + } catch (Exception e) { + log.warn("解析 JWT 提取 account_id 失败", e); + return null; + } + } + + private ModelProviderEntity getProvider() { + ModelProviderEntity provider = modelProviderMapper.selectById(PROVIDER_ID); + if (provider == null) { + throw new MateClawException("OpenAI ChatGPT provider 未配置,请检查数据库初始化"); + } + return provider; + } + + private void stopActiveCallbackServer() { + HttpServer existing = activeCallbackServer; + if (existing != null) { + try { + existing.stop(0); + log.info("已关闭上一个残留的 OAuth 回调服务器"); + } catch (Exception ignored) {} + activeCallbackServer = null; + } + } + + // ==================== PKCE 工具 ==================== + + private String generateCodeVerifier() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return base64UrlEncode(bytes); + } + + private String generateCodeChallenge(String codeVerifier) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return base64UrlEncode(digest); + } catch (Exception e) { + throw new MateClawException("PKCE code_challenge 生成失败: " + e.getMessage()); + } + } + + private String generateState() { + byte[] bytes = new byte[16]; + new SecureRandom().nextBytes(bytes); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static String base64UrlEncode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String padBase64(String base64) { + int mod = base64.length() % 4; + if (mod > 0) { + base64 += "=".repeat(4 - mod); + } + return base64; + } + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static String extractParam(String query, String name) { + if (query == null) return null; + for (String pair : query.split("&")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2 && kv[0].equals(name)) { + return java.net.URLDecoder.decode(kv[1], StandardCharsets.UTF_8); + } + } + return null; + } + + // ==================== 结果类 ==================== + + public record OAuthAuthorizeResult(String authorizeUrl, String state) {} + + public record OAuthStatusResult(boolean connected, boolean expired, Long expiresAt) {} +} 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 fa8962b2..54475b31 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 @@ -122,6 +122,7 @@ public class ModelDiscoveryService { case DASHSCOPE_NATIVE -> fetchDashScopeModels(provider); case GEMINI_NATIVE -> fetchGeminiModels(provider); case ANTHROPIC_MESSAGES -> fetchAnthropicModels(provider); + case OPENAI_CHATGPT -> throw new MateClawException("ChatGPT OAuth provider 不支持模型发现"); }; } @@ -213,6 +214,7 @@ 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("ChatGPT OAuth provider 不支持模型测试"); }; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index c60aab3e..0078c590 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -184,6 +184,9 @@ public class ModelProviderService { dto.setApiKey(maskApiKey(provider.getApiKey())); 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()); List builtinModels = new ArrayList<>(); List extraModels = new ArrayList<>(); if (models != null) { @@ -213,6 +216,11 @@ public class ModelProviderService { return true; } + // OAuth 认证的 provider:检查 OAuth token 是否存在 + if ("oauth".equals(provider.getAuthType())) { + return StringUtils.hasText(provider.getOauthAccessToken()); + } + boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl()); boolean hasApiKey = hasUsableApiKey(provider.getApiKey()); diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index a407a273..fed8af41 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -114,6 +114,10 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a KEY (provider_id) VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()); +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 ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', 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 @@ -240,7 +244,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', 'Doubao multimodal vision model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', 'Doubao deep reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', 'Doubao lite reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0); +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, 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) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index e6af9d0f..4cbff52a 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -114,6 +114,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); +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 ('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); + -- ==================== 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) @@ -241,7 +245,9 @@ VALUES (1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', 'Doubao multimodal vision model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', 'Doubao deep reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', 'Doubao lite reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, 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 diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index a80332f8..1b93ad3b 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -114,6 +114,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); +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 ('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); + -- ==================== 本地模型预配置(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) @@ -241,7 +245,9 @@ VALUES (1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', '豆包多模态视觉模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', '豆包深度推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', '豆包轻量推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型(OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, 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); -- 默认系统设置 diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 0e7a15ee..e5b6da6c 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -114,6 +114,10 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a KEY (provider_id) VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()); +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 ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', 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) @@ -246,7 +250,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', '豆包多模态视觉模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', '豆包深度推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', '豆包轻量推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0); +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型(OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0); -- 默认系统设置 MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 251c88f0..b9bd82e1 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -436,3 +436,13 @@ CREATE INDEX IF NOT EXISTS idx_memory_recall_candidates ON mate_memory_recall(ag -- 补充复合索引(高频查询优化) CREATE INDEX IF NOT EXISTS idx_message_conv_time ON mate_message(conversation_id, create_time); CREATE INDEX IF NOT EXISTS idx_workspace_file_agent_enabled ON mate_workspace_file(agent_id, enabled); + +-- ==================== OAuth 支持 ==================== +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS auth_type VARCHAR(16) NOT NULL DEFAULT 'api_key'; +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_access_token TEXT; +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_refresh_token TEXT; +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_expires_at BIGINT; +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_account_id VARCHAR(128); + +-- 清理 Codex 不支持的 ChatGPT OAuth 模型(gpt-4o, o3, o4-mini 在 Codex 模式下不可用) +DELETE FROM mate_model_config WHERE provider = 'openai-chatgpt' AND model_name IN ('gpt-4o', 'o3', 'o4-mini'); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index fc7e4b2c..40c507d3 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -251,6 +251,14 @@ export const modelApi = { http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`), } +// ==================== OAuth ==================== +export const oauthApi = { + authorize: () => http.get('/oauth/openai/authorize'), + status: () => http.get('/oauth/openai/status'), + refresh: () => http.post('/oauth/openai/refresh'), + revoke: () => http.delete('/oauth/openai/revoke'), +} + // ==================== Settings ==================== export const settingsApi = { get: () => http.get('/settings'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ce892fe9..f0a45749 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -254,6 +254,14 @@ export default { advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.', searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).', searchStrategyDefault: 'Default', + oauthTitle: 'OpenAI OAuth Login', + oauthLogin: 'OAuth Login', + oauthConnected: 'Connected', + oauthDisconnected: 'Not Connected', + oauthDisconnect: 'Disconnect', + 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', fields: { providerId: 'Provider ID', providerName: 'Provider Name', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 7e7a1354..94e75b09 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -254,6 +254,14 @@ export default { advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。', searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。', searchStrategyDefault: '默认', + oauthTitle: 'OpenAI OAuth 登录', + oauthLogin: 'OAuth 登录', + oauthConnected: '已连接', + oauthDisconnected: '未连接', + oauthDisconnect: '断开连接', + oauthHint: '通过 OAuth 登录 OpenAI 账号,使用 ChatGPT Plus/Pro 会员额度(非 API 额度)。', + oauthLoginSuccess: 'OpenAI OAuth 登录成功', + oauthRevokeSuccess: '已断开 OpenAI OAuth 连接', fields: { providerId: 'Provider ID', providerName: 'Provider 名称', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 4a6a0b3c..65001410 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -525,6 +525,9 @@ export interface ProviderInfo { apiKey?: string baseUrl?: string generateKwargs?: Record + authType?: string + oauthConnected?: boolean + oauthExpiresAt?: number } export interface ActiveModelsInfo { diff --git a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue index 6ef99d17..c65a05cc 100644 --- a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue +++ b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue @@ -31,7 +31,14 @@ {{ provider.baseUrl || t('settings.model.notSet') }} -
+
+ OAuth + + {{ t('settings.model.oauthConnected') }} + {{ t('settings.model.oauthDisconnected') }} + +
+
{{ t('settings.model.apiKey') }} {{ provider.apiKey || t('settings.model.notSet') }}
@@ -130,4 +137,7 @@ const { t } = useI18n() .connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; } .connection-result.success { background: var(--mc-primary-bg); color: var(--mc-primary); } .connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); } +.oauth-card-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; } +.oauth-card-badge.connected { background: rgba(34, 197, 94, 0.12); color: #22c55e; } +.oauth-card-badge.disconnected { background: rgba(156, 163, 175, 0.12); color: var(--mc-text-tertiary); } diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index d9247833..275484ac 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -79,6 +79,8 @@ @close="closeProviderModal" @save="onSaveProvider" @toggle-advanced="advancedOpen = !advancedOpen" + @oauth-login="handleOAuthLogin" + @oauth-revoke="handleOAuthRevoke" /> @@ -168,6 +170,8 @@ const { providerStatus, getProviderIcon, onIconError, + handleOAuthLogin, + handleOAuthRevoke, } = useProviders() const localProviders = computed(() => providers.value.filter(p => p.isLocal)) diff --git a/mateclaw-ui/src/views/Settings/Models/modals/ProviderConfigModal.vue b/mateclaw-ui/src/views/Settings/Models/modals/ProviderConfigModal.vue index 572619a4..ff821492 100644 --- a/mateclaw-ui/src/views/Settings/Models/modals/ProviderConfigModal.vue +++ b/mateclaw-ui/src/views/Settings/Models/modals/ProviderConfigModal.vue @@ -24,7 +24,37 @@ />
{{ baseUrlHint }}
-
+ +
+ +
+ + {{ t('settings.model.oauthConnected') }} + + + {{ t('settings.model.oauthDisconnected') }} + + + +
+
{{ t('settings.model.oauthHint') }}
+
+ +
() @@ -176,6 +208,16 @@ defineEmits<{ .toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); } +.oauth-group { margin-top: 4px; } +.oauth-status-row { display: flex; align-items: center; gap: 12px; margin-top: 6px; } +.oauth-badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 8px; font-size: 13px; font-weight: 600; } +.oauth-connected { background: rgba(34, 197, 94, 0.12); color: #22c55e; } +.oauth-disconnected { background: rgba(156, 163, 175, 0.12); color: var(--mc-text-tertiary); } +.btn-oauth { border: none; border-radius: 10px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-primary); color: white; transition: all 0.15s; } +.btn-oauth:hover { background: var(--mc-primary-hover); } +.btn-oauth-revoke { background: rgba(239, 68, 68, 0.1); color: #ef4444; } +.btn-oauth-revoke:hover { background: rgba(239, 68, 68, 0.2); } + @media (max-width: 900px) { .form-grid { grid-template-columns: 1fr; } } diff --git a/mateclaw-ui/src/views/Settings/Models/useProviders.ts b/mateclaw-ui/src/views/Settings/Models/useProviders.ts index 9163355f..605483d5 100644 --- a/mateclaw-ui/src/views/Settings/Models/useProviders.ts +++ b/mateclaw-ui/src/views/Settings/Models/useProviders.ts @@ -1,7 +1,7 @@ import { computed, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' -import { modelApi } from '@/api' +import { modelApi, oauthApi } from '@/api' import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types' export function useProviders() { @@ -385,12 +385,59 @@ export function useProviders() { 'zhipu-cn': '/icons/providers/zhipu.svg', 'zhipu-intl': '/icons/providers/zhipu.svg', 'volcengine': '/icons/providers/volcengine.svg', + 'openai-chatgpt': '/icons/providers/openai.svg', } function getProviderIcon(providerId: string): string { return providerIconMap[providerId] || '/icons/providers/default.svg' } + // ==================== OAuth ==================== + + async function handleOAuthLogin() { + try { + const res: any = await oauthApi.authorize() + const { authorizeUrl } = res.data + // 打开新窗口进行 OAuth 登录 + const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700') + // 轮询检查 OAuth 状态 + const pollInterval = setInterval(async () => { + try { + const statusRes: any = await oauthApi.status() + if (statusRes.data?.connected) { + 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 + } + } + } catch { /* ignore polling errors */ } + }, 2000) + // 30 秒后停止轮询 + setTimeout(() => clearInterval(pollInterval), 30000) + } catch (e: any) { + ElMessage.error(e.msg || 'OAuth login failed') + } + } + + async function handleOAuthRevoke() { + 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 + } + } catch (e: any) { + ElMessage.error(e.msg || 'OAuth revoke failed') + } + } + function onIconError(e: Event) { const img = e.target as HTMLImageElement img.style.display = 'none' @@ -445,6 +492,8 @@ export function useProviders() { providerStatus, getProviderIcon, onIconError, + handleOAuthLogin, + handleOAuthRevoke, } }