feat: 新增统一模型网关与模型管理工作区
- 新增 OpenAI 兼容统一模型调用链路、模型发布配置与批量发布能力 - 重构模型管理页面入口与统一网关工作区,更新服务商 logo 资源与模型 ID 文案 - 收口全新库初始化脚本,仅保留服务商种子并整理统一网关 migration
This commit is contained in:
@@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.entity.table.ModelTableDef;
|
||||
@@ -69,6 +70,12 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
return Result.ok(modelService.getList(entity));
|
||||
}
|
||||
|
||||
@GetMapping("invokeList")
|
||||
@SaCheckPermission("/api/v1/model/query")
|
||||
public Result<List<Model>> invokeList() {
|
||||
return Result.ok(modelService.listInvokeModels());
|
||||
}
|
||||
|
||||
@PostMapping("/addAiLlm")
|
||||
@SaCheckPermission("/api/v1/model/save")
|
||||
public Result<Boolean> addAiLlm(Model entity) {
|
||||
@@ -92,6 +99,31 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/updateInvokeConfig")
|
||||
@SaCheckPermission("/api/v1/model/save")
|
||||
public Result<Model> updateInvokeConfig(@RequestBody ModelInvokeConfigDtos.UpdateRequest request) {
|
||||
return Result.ok(modelService.updateInvokeConfig(
|
||||
request.getId(),
|
||||
request.getInvokeCode(),
|
||||
request.getPublishEnabled()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/batchUpdateInvokePublishStatus")
|
||||
@SaCheckPermission("/api/v1/model/save")
|
||||
public Result<List<Model>> batchUpdateInvokePublishStatus(@RequestBody ModelInvokeConfigDtos.BatchPublishRequest request) {
|
||||
return Result.ok(modelService.batchUpdateInvokePublishStatus(
|
||||
request.getIds(),
|
||||
request.getPublishEnabled()
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result<?> onSaveOrUpdateBefore(Model entity, boolean isSave) {
|
||||
modelService.validateForSaveOrUpdate(entity, isSave);
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
|
||||
@GetMapping("/selectLlmByProviderCategory")
|
||||
@SaCheckPermission("/api/v1/model/query")
|
||||
public Result<Map<String, List<Model>>> selectLlmByProviderCategory(Model entity, String sortKey, String sortType) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.ai.invoke.exception.ModelInvokeException;
|
||||
import tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper;
|
||||
import tech.easyflow.ai.invoke.model.UnifiedChatChunk;
|
||||
import tech.easyflow.ai.invoke.model.UnifiedChatRequest;
|
||||
import tech.easyflow.ai.invoke.model.UnifiedChatResponse;
|
||||
import tech.easyflow.ai.invoke.protocol.openai.OpenAiChatCompletionRequest;
|
||||
import tech.easyflow.ai.invoke.protocol.openai.OpenAiErrorResponse;
|
||||
import tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver;
|
||||
import tech.easyflow.ai.invoke.service.UnifiedModelInvokeService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
@SaIgnore
|
||||
@RestController
|
||||
@RequestMapping("/v1")
|
||||
public class PublicModelChatController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PublicModelChatController.class);
|
||||
private static final long SSE_TIMEOUT = Duration.ofMinutes(10).toMillis();
|
||||
|
||||
@Resource
|
||||
private SysApiKeyService sysApiKeyService;
|
||||
@Resource
|
||||
private UnifiedModelInvokeService unifiedModelInvokeService;
|
||||
@Resource
|
||||
private OpenAiProtocolMapper openAiProtocolMapper;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 统一模型调用(OpenAI Chat Completions)
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/chat/completions",
|
||||
produces = {
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
MediaType.TEXT_EVENT_STREAM_VALUE
|
||||
}
|
||||
)
|
||||
public Object chatCompletions(@RequestBody String rawBody, HttpServletRequest request) {
|
||||
try {
|
||||
String apiKey = resolveApiKey(request.getHeader(HttpHeaders.AUTHORIZATION));
|
||||
sysApiKeyService.checkApikeyPermission(apiKey, request.getRequestURI());
|
||||
|
||||
OpenAiChatCompletionRequest openAiRequest = openAiProtocolMapper.readRequest(rawBody);
|
||||
UnifiedChatRequest unifiedRequest = openAiProtocolMapper.toUnifiedRequest(openAiRequest);
|
||||
if (Boolean.TRUE.equals(unifiedRequest.getStream())) {
|
||||
return createStreamEmitter(unifiedRequest);
|
||||
}
|
||||
UnifiedChatResponse response = unifiedModelInvokeService.chat(unifiedRequest);
|
||||
return buildJsonResponse(openAiProtocolMapper.toOpenAiResponse(response));
|
||||
} catch (ModelInvokeException e) {
|
||||
return buildErrorResponse(e);
|
||||
} catch (BusinessException e) {
|
||||
return buildErrorResponse(mapBusinessException(e));
|
||||
} catch (Exception e) {
|
||||
log.error("chatCompletions unexpected error: {}", e.getMessage(), e);
|
||||
return buildErrorResponse(new ModelInvokeException(
|
||||
500,
|
||||
"统一模型调用失败: " + e.getMessage(),
|
||||
"api_error",
|
||||
null,
|
||||
"internal_error"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private SseEmitter createStreamEmitter(UnifiedChatRequest unifiedRequest) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
|
||||
unifiedModelInvokeService.chatStream(unifiedRequest, new UnifiedChatChunkObserver() {
|
||||
@Override
|
||||
public void onChunk(UnifiedChatChunk chunk) {
|
||||
try {
|
||||
String payload = objectMapper.writeValueAsString(openAiProtocolMapper.toOpenAiChunk(chunk));
|
||||
emitter.send(SseEmitter.event().data(payload));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data("[DONE]"));
|
||||
emitter.complete();
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable throwable) {
|
||||
log.error("chatCompletions stream error: {}", throwable.getMessage(), throwable);
|
||||
emitter.completeWithError(throwable);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private ResponseEntity<String> buildJsonResponse(Object body) {
|
||||
try {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(objectMapper.writeValueAsString(body));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("序列化响应失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<String> buildErrorResponse(ModelInvokeException e) {
|
||||
OpenAiErrorResponse response = new OpenAiErrorResponse();
|
||||
OpenAiErrorResponse.Error error = new OpenAiErrorResponse.Error();
|
||||
error.setMessage(e.getMessage());
|
||||
error.setType(e.getType());
|
||||
error.setParam(e.getParam());
|
||||
error.setCode(e.getCode());
|
||||
response.setError(error);
|
||||
return ResponseEntity.status(e.getStatus())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(writeJson(response));
|
||||
}
|
||||
|
||||
private String writeJson(Object body) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(body);
|
||||
} catch (Exception ex) {
|
||||
log.error("writeJson error: {}", ex.getMessage(), ex);
|
||||
return "{\"error\":{\"message\":\"响应序列化失败\",\"type\":\"api_error\",\"code\":\"serialization_error\"}}";
|
||||
}
|
||||
}
|
||||
|
||||
private ModelInvokeException mapBusinessException(BusinessException e) {
|
||||
String message = StrUtil.blankToDefault(e.getMessage(), "访问令牌校验失败");
|
||||
if (StrUtil.containsAnyIgnoreCase(message, "apikey 不存在", "apikey 已过期", "已禁用")) {
|
||||
return ModelInvokeException.unauthorized(message);
|
||||
}
|
||||
if (StrUtil.containsAnyIgnoreCase(message, "无权限", "接口不存在")) {
|
||||
return ModelInvokeException.forbidden(message);
|
||||
}
|
||||
return ModelInvokeException.badRequest(message);
|
||||
}
|
||||
|
||||
private String resolveApiKey(String authorizationHeader) {
|
||||
if (StrUtil.isBlank(authorizationHeader)) {
|
||||
throw ModelInvokeException.unauthorized("Authorization 不能为空");
|
||||
}
|
||||
String trimmed = authorizationHeader.trim();
|
||||
if (StrUtil.startWithIgnoreCase(trimmed, "Bearer ")) {
|
||||
trimmed = trimmed.substring(7).trim();
|
||||
}
|
||||
if (StrUtil.isBlank(trimmed)) {
|
||||
throw ModelInvokeException.unauthorized("Authorization 无效");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user