mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): add OpenAI ChatGPT OAuth login for Plus/Pro member access
This commit is contained in:
parent
8e9a83fd50
commit
afe8466fa5
@ -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);
|
||||
|
||||
@ -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<Message> 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<ChatResponse> stream(Prompt prompt) {
|
||||
List<Message> 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;
|
||||
}
|
||||
}
|
||||
@ -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<Message> messages, Double temperature) {
|
||||
return stream(model, messages, temperature)
|
||||
.collectList()
|
||||
.map(chunks -> String.join("", chunks))
|
||||
.block();
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式调用 Responses API (SSE)
|
||||
*/
|
||||
public Flux<String> stream(String model, List<Message> 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<Message> 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 <20><><EFBFBD>回错误: " + 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 + ")");
|
||||
}
|
||||
}
|
||||
@ -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<OAuthAuthorizeResult> authorize() {
|
||||
return R.ok(oauthService.buildAuthorizeUrl());
|
||||
}
|
||||
|
||||
@Operation(summary = "手动刷新 Token")
|
||||
@PostMapping("/refresh")
|
||||
public R<Void> refresh() {
|
||||
oauthService.refreshToken();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "清除 OAuth 凭证")
|
||||
@DeleteMapping("/revoke")
|
||||
public R<Void> revoke() {
|
||||
oauthService.revokeToken();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "获取 OAuth 连接状态")
|
||||
@GetMapping("/status")
|
||||
public R<OAuthStatusResult> status() {
|
||||
return R.ok(oauthService.getStatus());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -26,4 +26,7 @@ public class ProviderInfoDTO {
|
||||
private String apiKey;
|
||||
private String baseUrl;
|
||||
private Map<String, Object> generateKwargs;
|
||||
private String authType;
|
||||
private Boolean oauthConnected;
|
||||
private Long oauthExpiresAt;
|
||||
}
|
||||
|
||||
@ -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 流程。
|
||||
* <p>
|
||||
* 核心机制:在本地 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<String, String> pendingStates = new ConcurrentHashMap<>();
|
||||
|
||||
/** 当前运行中的回调服务器(用于启动新服务器前关闭旧的) */
|
||||
private volatile HttpServer activeCallbackServer;
|
||||
|
||||
// ==================== OAuth 流程 ====================
|
||||
|
||||
/**
|
||||
* 生成授权 URL 并启动本地回调服务器。
|
||||
* <p>
|
||||
* 流程:
|
||||
* 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 = "<html><body><h1>State mismatch</h1><p>OAuth state 不匹配,请重试。</p></body></html>";
|
||||
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 = "<html><body><h1>Missing code</h1><p>缺少授权码。</p></body></html>";
|
||||
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 = "<html><body><h1>✓ 登录成功</h1>"
|
||||
+ "<p>OpenAI OAuth 授权完成,您可以关闭此窗口。</p>"
|
||||
+ "<script>setTimeout(function(){window.close()},2000)</script>"
|
||||
+ "</body></html>";
|
||||
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 = "<html><body><h1>Token 交换失败</h1><p>" + e.getMessage() + "</p></body></html>";
|
||||
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<ModelProviderEntity>()
|
||||
.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) {}
|
||||
}
|
||||
@ -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 不支持模型测试");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<ModelInfoDTO> builtinModels = new ArrayList<>();
|
||||
List<ModelInfoDTO> 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());
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
-- 默认系统设置
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 名称',
|
||||
|
||||
@ -525,6 +525,9 @@ export interface ProviderInfo {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
generateKwargs?: Record<string, unknown>
|
||||
authType?: string
|
||||
oauthConnected?: boolean
|
||||
oauthExpiresAt?: number
|
||||
}
|
||||
|
||||
export interface ActiveModelsInfo {
|
||||
|
||||
@ -31,7 +31,14 @@
|
||||
{{ provider.baseUrl || t('settings.model.notSet') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div v-if="provider.authType === 'oauth'" class="info-row">
|
||||
<span class="info-label">OAuth</span>
|
||||
<span class="info-value">
|
||||
<span v-if="provider.oauthConnected" class="oauth-card-badge connected">{{ t('settings.model.oauthConnected') }}</span>
|
||||
<span v-else class="oauth-card-badge disconnected">{{ t('settings.model.oauthDisconnected') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="info-row">
|
||||
<span class="info-label">{{ t('settings.model.apiKey') }}</span>
|
||||
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
|
||||
</div>
|
||||
@ -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); }
|
||||
</style>
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
@close="closeProviderModal"
|
||||
@save="onSaveProvider"
|
||||
@toggle-advanced="advancedOpen = !advancedOpen"
|
||||
@oauth-login="handleOAuthLogin"
|
||||
@oauth-revoke="handleOAuthRevoke"
|
||||
/>
|
||||
|
||||
<!-- Manage Models Modal -->
|
||||
@ -168,6 +170,8 @@ const {
|
||||
providerStatus,
|
||||
getProviderIcon,
|
||||
onIconError,
|
||||
handleOAuthLogin,
|
||||
handleOAuthRevoke,
|
||||
} = useProviders()
|
||||
|
||||
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||||
|
||||
@ -24,7 +24,37 @@
|
||||
/>
|
||||
<div class="field-hint">{{ baseUrlHint }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!-- OAuth 登录区域(auth_type === 'oauth' 时显示) -->
|
||||
<div v-if="editingProvider?.authType === 'oauth'" class="form-group full-width oauth-group">
|
||||
<label class="form-label">{{ t('settings.model.oauthTitle') }}</label>
|
||||
<div class="oauth-status-row">
|
||||
<span v-if="editingProvider?.oauthConnected" class="oauth-badge oauth-connected">
|
||||
{{ t('settings.model.oauthConnected') }}
|
||||
</span>
|
||||
<span v-else class="oauth-badge oauth-disconnected">
|
||||
{{ t('settings.model.oauthDisconnected') }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!editingProvider?.oauthConnected"
|
||||
class="btn-oauth"
|
||||
type="button"
|
||||
@click="$emit('oauthLogin')"
|
||||
>
|
||||
{{ t('settings.model.oauthLogin') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-oauth btn-oauth-revoke"
|
||||
type="button"
|
||||
@click="$emit('oauthRevoke')"
|
||||
>
|
||||
{{ t('settings.model.oauthDisconnect') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-hint">{{ t('settings.model.oauthHint') }}</div>
|
||||
</div>
|
||||
<!-- API Key 输入区域(非 OAuth 时显示) -->
|
||||
<div v-else class="form-group">
|
||||
<label class="form-label">{{ t('settings.model.apiKey') }}</label>
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
@ -137,6 +167,8 @@ defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
toggleAdvanced: []
|
||||
oauthLogin: []
|
||||
oauthRevoke: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user