mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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 vip.mate.config.ToolTimeoutProperties toolTimeoutProperties;
|
||||||
private final WorkspaceFileService workspaceFileService;
|
private final WorkspaceFileService workspaceFileService;
|
||||||
private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager;
|
private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager;
|
||||||
|
private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 AgentEntity 构建完整的 Agent 实例
|
* 根据 AgentEntity 构建完整的 Agent 实例
|
||||||
@ -427,7 +428,8 @@ public class AgentGraphBuilder {
|
|||||||
private boolean supportsStateGraph(ModelProtocol protocol) {
|
private boolean supportsStateGraph(ModelProtocol protocol) {
|
||||||
return protocol == ModelProtocol.DASHSCOPE_NATIVE
|
return protocol == ModelProtocol.DASHSCOPE_NATIVE
|
||||||
|| protocol == ModelProtocol.OPENAI_COMPATIBLE
|
|| protocol == ModelProtocol.OPENAI_COMPATIBLE
|
||||||
|| protocol == ModelProtocol.ANTHROPIC_MESSAGES;
|
|| protocol == ModelProtocol.ANTHROPIC_MESSAGES
|
||||||
|
|| protocol == ModelProtocol.OPENAI_CHATGPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 模型构建 ====================
|
// ==================== 模型构建 ====================
|
||||||
@ -449,6 +451,12 @@ public class AgentGraphBuilder {
|
|||||||
.build();
|
.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) {
|
if (protocol == ModelProtocol.OPENAI_COMPATIBLE) {
|
||||||
OpenAiApi api = buildOpenAiApi(provider);
|
OpenAiApi api = buildOpenAiApi(provider);
|
||||||
OpenAiChatOptions options = buildOpenAiOptions(runtimeModel, 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 {
|
public enum ModelProtocol {
|
||||||
|
|
||||||
OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"),
|
OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"),
|
||||||
|
OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"),
|
||||||
ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"),
|
ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"),
|
||||||
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),
|
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),
|
||||||
DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel");
|
DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel");
|
||||||
|
|||||||
@ -39,6 +39,16 @@ public class ModelProviderEntity {
|
|||||||
|
|
||||||
private Boolean requireApiKey;
|
private Boolean requireApiKey;
|
||||||
|
|
||||||
|
private String authType;
|
||||||
|
|
||||||
|
private String oauthAccessToken;
|
||||||
|
|
||||||
|
private String oauthRefreshToken;
|
||||||
|
|
||||||
|
private Long oauthExpiresAt;
|
||||||
|
|
||||||
|
private String oauthAccountId;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -26,4 +26,7 @@ public class ProviderInfoDTO {
|
|||||||
private String apiKey;
|
private String apiKey;
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
private Map<String, Object> generateKwargs;
|
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 DASHSCOPE_NATIVE -> fetchDashScopeModels(provider);
|
||||||
case GEMINI_NATIVE -> fetchGeminiModels(provider);
|
case GEMINI_NATIVE -> fetchGeminiModels(provider);
|
||||||
case ANTHROPIC_MESSAGES -> fetchAnthropicModels(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 DASHSCOPE_NATIVE -> sendDashScopeTestPrompt(provider, modelId);
|
||||||
case GEMINI_NATIVE -> sendGeminiTestPrompt(provider, modelId);
|
case GEMINI_NATIVE -> sendGeminiTestPrompt(provider, modelId);
|
||||||
case ANTHROPIC_MESSAGES -> sendAnthropicTestPrompt(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.setApiKey(maskApiKey(provider.getApiKey()));
|
||||||
dto.setBaseUrl(provider.getBaseUrl());
|
dto.setBaseUrl(provider.getBaseUrl());
|
||||||
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
|
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> builtinModels = new ArrayList<>();
|
||||||
List<ModelInfoDTO> extraModels = new ArrayList<>();
|
List<ModelInfoDTO> extraModels = new ArrayList<>();
|
||||||
if (models != null) {
|
if (models != null) {
|
||||||
@ -213,6 +216,11 @@ public class ModelProviderService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 认证的 provider:检查 OAuth token 是否存在
|
||||||
|
if ("oauth".equals(provider.getAuthType())) {
|
||||||
|
return StringUtils.hasText(provider.getOauthAccessToken());
|
||||||
|
}
|
||||||
|
|
||||||
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
|
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
|
||||||
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
|
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)
|
KEY (provider_id)
|
||||||
VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW());
|
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) ====================
|
-- ==================== 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)
|
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
|
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),
|
(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),
|
(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),
|
(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
|
-- Default system settings
|
||||||
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
|
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())
|
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);
|
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) ====================
|
-- ==================== 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)
|
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)
|
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),
|
(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),
|
(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),
|
(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);
|
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
|
-- 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())
|
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);
|
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,默认禁用) ====================
|
-- ==================== 本地模型预配置(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)
|
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)
|
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),
|
(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),
|
(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),
|
(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);
|
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)
|
KEY (provider_id)
|
||||||
VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW());
|
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,默认禁用,用户拉取后启用) ====================
|
-- ==================== 本地模型预配置(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)
|
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)
|
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),
|
(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),
|
(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),
|
(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)
|
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_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);
|
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`),
|
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 ====================
|
// ==================== Settings ====================
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => http.get('/settings'),
|
get: () => http.get('/settings'),
|
||||||
|
|||||||
@ -254,6 +254,14 @@ export default {
|
|||||||
advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.',
|
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).',
|
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
|
||||||
searchStrategyDefault: 'Default',
|
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: {
|
fields: {
|
||||||
providerId: 'Provider ID',
|
providerId: 'Provider ID',
|
||||||
providerName: 'Provider Name',
|
providerName: 'Provider Name',
|
||||||
|
|||||||
@ -254,6 +254,14 @@ export default {
|
|||||||
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
|
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
|
||||||
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
||||||
searchStrategyDefault: '默认',
|
searchStrategyDefault: '默认',
|
||||||
|
oauthTitle: 'OpenAI OAuth 登录',
|
||||||
|
oauthLogin: 'OAuth 登录',
|
||||||
|
oauthConnected: '已连接',
|
||||||
|
oauthDisconnected: '未连接',
|
||||||
|
oauthDisconnect: '断开连接',
|
||||||
|
oauthHint: '通过 OAuth 登录 OpenAI 账号,使用 ChatGPT Plus/Pro 会员额度(非 API 额度)。',
|
||||||
|
oauthLoginSuccess: 'OpenAI OAuth 登录成功',
|
||||||
|
oauthRevokeSuccess: '已断开 OpenAI OAuth 连接',
|
||||||
fields: {
|
fields: {
|
||||||
providerId: 'Provider ID',
|
providerId: 'Provider ID',
|
||||||
providerName: 'Provider 名称',
|
providerName: 'Provider 名称',
|
||||||
|
|||||||
@ -525,6 +525,9 @@ export interface ProviderInfo {
|
|||||||
apiKey?: string
|
apiKey?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
generateKwargs?: Record<string, unknown>
|
generateKwargs?: Record<string, unknown>
|
||||||
|
authType?: string
|
||||||
|
oauthConnected?: boolean
|
||||||
|
oauthExpiresAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveModelsInfo {
|
export interface ActiveModelsInfo {
|
||||||
|
|||||||
@ -31,7 +31,14 @@
|
|||||||
{{ provider.baseUrl || t('settings.model.notSet') }}
|
{{ provider.baseUrl || t('settings.model.notSet') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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-label">{{ t('settings.model.apiKey') }}</span>
|
||||||
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
|
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -130,4 +137,7 @@ const { t } = useI18n()
|
|||||||
.connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; }
|
.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.success { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||||
.connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
.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>
|
</style>
|
||||||
|
|||||||
@ -79,6 +79,8 @@
|
|||||||
@close="closeProviderModal"
|
@close="closeProviderModal"
|
||||||
@save="onSaveProvider"
|
@save="onSaveProvider"
|
||||||
@toggle-advanced="advancedOpen = !advancedOpen"
|
@toggle-advanced="advancedOpen = !advancedOpen"
|
||||||
|
@oauth-login="handleOAuthLogin"
|
||||||
|
@oauth-revoke="handleOAuthRevoke"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Manage Models Modal -->
|
<!-- Manage Models Modal -->
|
||||||
@ -168,6 +170,8 @@ const {
|
|||||||
providerStatus,
|
providerStatus,
|
||||||
getProviderIcon,
|
getProviderIcon,
|
||||||
onIconError,
|
onIconError,
|
||||||
|
handleOAuthLogin,
|
||||||
|
handleOAuthRevoke,
|
||||||
} = useProviders()
|
} = useProviders()
|
||||||
|
|
||||||
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||||||
|
|||||||
@ -24,7 +24,37 @@
|
|||||||
/>
|
/>
|
||||||
<div class="field-hint">{{ baseUrlHint }}</div>
|
<div class="field-hint">{{ baseUrlHint }}</div>
|
||||||
</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>
|
<label class="form-label">{{ t('settings.model.apiKey') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="form.apiKey"
|
v-model="form.apiKey"
|
||||||
@ -137,6 +167,8 @@ defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
save: []
|
save: []
|
||||||
toggleAdvanced: []
|
toggleAdvanced: []
|
||||||
|
oauthLogin: []
|
||||||
|
oauthRevoke: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -176,6 +208,16 @@ defineEmits<{
|
|||||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { modelApi } from '@/api'
|
import { modelApi, oauthApi } from '@/api'
|
||||||
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
||||||
|
|
||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
@ -385,12 +385,59 @@ export function useProviders() {
|
|||||||
'zhipu-cn': '/icons/providers/zhipu.svg',
|
'zhipu-cn': '/icons/providers/zhipu.svg',
|
||||||
'zhipu-intl': '/icons/providers/zhipu.svg',
|
'zhipu-intl': '/icons/providers/zhipu.svg',
|
||||||
'volcengine': '/icons/providers/volcengine.svg',
|
'volcengine': '/icons/providers/volcengine.svg',
|
||||||
|
'openai-chatgpt': '/icons/providers/openai.svg',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderIcon(providerId: string): string {
|
function getProviderIcon(providerId: string): string {
|
||||||
return providerIconMap[providerId] || '/icons/providers/default.svg'
|
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) {
|
function onIconError(e: Event) {
|
||||||
const img = e.target as HTMLImageElement
|
const img = e.target as HTMLImageElement
|
||||||
img.style.display = 'none'
|
img.style.display = 'none'
|
||||||
@ -445,6 +492,8 @@ export function useProviders() {
|
|||||||
providerStatus,
|
providerStatus,
|
||||||
getProviderIcon,
|
getProviderIcon,
|
||||||
onIconError,
|
onIconError,
|
||||||
|
handleOAuthLogin,
|
||||||
|
handleOAuthRevoke,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user