feat(llm): add OpenAI ChatGPT OAuth login for Plus/Pro member access

This commit is contained in:
matevip 2026-04-07 15:32:55 +08:00
parent 8e9a83fd50
commit afe8466fa5
23 changed files with 979 additions and 8 deletions

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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/responsesResponses 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 reasoningOpenClaw 的标准参数
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 + ")");
}
}

View File

@ -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());
}
}

View File

@ -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");

View File

@ -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;

View File

@ -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;
}

View File

@ -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) {}
}

View File

@ -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 不支持模型测试");
};
}

View File

@ -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());

View File

@ -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)

View File

@ -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

View File

@ -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);
-- 默认系统设置

View File

@ -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)

View File

@ -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');

View File

@ -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'),

View File

@ -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',

View File

@ -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 名称',

View File

@ -525,6 +525,9 @@ export interface ProviderInfo {
apiKey?: string
baseUrl?: string
generateKwargs?: Record<string, unknown>
authType?: string
oauthConnected?: boolean
oauthExpiresAt?: number
}
export interface ActiveModelsInfo {

View File

@ -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>

View File

@ -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))

View File

@ -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; }
}

View File

@ -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,
}
}