Files
ManuAgent/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java
2026-08-29 14:02:28 +08:00

453 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tech.easyflow.manuagent.model;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/
@Service
@Order(2)
public class ModelService implements ApplicationRunner {
private final JdbcClient jdbc;
private final UserService userService;
private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 创建模型服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
public ModelService(
JdbcClient jdbc,
UserService userService,
KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.keyCipher = keyCipher;
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
/**
* 从项目根目录 Key 文件初始化默认模型。
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
return;
}
try {
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
if (key.isBlank()) {
return;
}
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
.query(UUID.class)
.single();
UUID modelId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, is_default, created_by)
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
""")
.param("id", modelId)
.param("baseUrl", properties.modelBaseUrl())
.param("modelId", properties.modelId())
.param("ciphertext", keyCipher.encrypt(key))
.param("hint", hint(key))
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
.param("capabilities", json(Map.of(
"toolCalling", true,
"reasoning", true,
"contextWindow", properties.modelContextWindow())))
.param("userId", adminId)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
.param("role", role)
.param("id", modelId)
.param("userId", adminId)
.update();
}
} catch (IOException exception) {
throw new IllegalStateException("无法读取默认模型 Key", exception);
}
}
/**
* 列出模型配置,永不返回明文密钥。
*
* @return 模型列表
*/
public List<ModelView> list() {
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
.query(ModelService::mapModel)
.list();
}
/**
* 保存新增或已有模型配置。
*
* @param id 可选模型 ID
* @param input 模型输入
* @param principal 当前用户
* @return 保存后的模型
*/
@Transactional
public ModelView save(UUID id, ModelInput input, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
contextWindow(input.capabilities());
if (id == null) {
if (input.apiKey() == null || input.apiKey().isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
}
id = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, created_by)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
""")
.param("id", id)
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("userId", userId)
.update();
} else {
int updated = input.apiKey() == null || input.apiKey().isBlank()
? jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update()
: jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
}
return require(id);
}
/**
* 将模型设置为所有角色默认模型。
*
* @param id 模型 ID
* @param principal 当前用户
*/
@Transactional
public void setDefault(UUID id, Principal principal) {
require(id);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
.param("id", id)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("""
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
VALUES (:role, :id, :userId)
ON CONFLICT (role) DO UPDATE
SET model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
""")
.param("role", role)
.param("id", id)
.param("userId", userId)
.update();
}
}
/**
* 使用最小 Chat Completion 请求测试连接。
*
* @param id 模型 ID
* @return 测试结果
*/
public ConnectionResult test(UUID id) {
ModelSecret model = requireSecret(id);
String requestJson = json(Map.of(
"model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
"max_tokens", 8,
"stream", false));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(model.baseUrl() + "/chat/completions"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + model.apiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
long started = System.nanoTime();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
long elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED",
"模型连接失败,服务返回 HTTP " + response.statusCode());
}
return new ConnectionResult(true, elapsed, "连接正常");
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED", "无法连接模型服务");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "MODEL_CONNECTION_INTERRUPTED", "模型连接测试已中断");
}
}
/**
* 获取当前默认模型及明文 Key仅供模型调用。
*
* @return 默认模型机密配置
*/
public ModelSecret defaultModelSecret() {
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
return requireSecret(id);
}
private ModelView require(UUID id) {
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
.param("id", id)
.query(ModelService::mapModel)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
}
private ModelSecret requireSecret(UUID id) {
return jdbc.sql("""
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
FROM app.model_config WHERE id = :id AND enabled
""")
.param("id", id)
.query((rs, rowNum) -> new ModelSecret(
rs.getObject("id", UUID.class),
rs.getString("base_url"),
rs.getString("model_id"),
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
}
/**
* 读取并校验模型上下文窗口。
*
* @param capabilities 模型能力配置
* @return 上下文 Token 上限
*/
private int contextWindow(Map<String, Object> capabilities) {
Object value = capabilities == null ? null : capabilities.get("contextWindow");
if (!(value instanceof Number number) || number.intValue() < 8_192) {
throw new ApiException(
HttpStatus.BAD_REQUEST,
"MODEL_CONTEXT_WINDOW_INVALID",
"上下文窗口不能小于 8192 Token");
}
return number.intValue();
}
/**
* 解析数据库中的模型能力配置。
*
* @param json 能力 JSON
* @return 能力键值
*/
@SuppressWarnings("unchecked")
private Map<String, Object> parseCapabilities(String json) {
try {
return objectMapper.readValue(json, Map.class);
} catch (IOException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MODEL_CAPABILITIES_INVALID",
"模型能力配置无法读取");
}
}
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ModelView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("provider"),
rs.getString("base_url"),
rs.getString("model_id"),
rs.getString("api_key_hint"),
rs.getString("config_json"),
rs.getString("capabilities_json"),
rs.getBoolean("enabled"),
rs.getBoolean("is_default"),
rs.getObject("updated_at", OffsetDateTime.class));
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
}
}
private String normalizeBaseUrl(String baseUrl) {
String value = baseUrl.trim();
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
private static String hint(String key) {
return "••••" + key.substring(Math.max(0, key.length() - 4));
}
private static final String MODEL_SELECT = """
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
capabilities_json, enabled, is_default, updated_at
FROM app.model_config
""";
/**
* 模型编辑输入。
*
* @param name 配置名称
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 新密钥;空值表示保留
* @param config 高级配置
* @param capabilities 能力声明
*/
public record ModelInput(
@NotBlank String name,
@NotBlank String baseUrl,
@NotBlank String modelId,
String apiKey,
Map<String, Object> config,
Map<String, Object> capabilities) {
}
/**
* 对外模型视图。
*
* @param id 模型 ID
* @param name 配置名称
* @param provider 服务商
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKeyHint 密钥遮罩
* @param configJson 高级配置
* @param capabilitiesJson 能力配置
* @param enabled 是否启用
* @param defaultModel 是否默认
* @param updatedAt 更新时间
*/
public record ModelView(
UUID id,
String name,
String provider,
String baseUrl,
String modelId,
String apiKeyHint,
String configJson,
String capabilitiesJson,
boolean enabled,
boolean defaultModel,
OffsetDateTime updatedAt) {
}
/**
* 内部模型机密配置。
*
* @param id 模型 ID
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 明文密钥
* @param contextWindow 上下文 Token 上限
*/
public record ModelSecret(UUID id, String baseUrl, String modelId, String apiKey, int contextWindow) {
}
/**
* 连接测试结果。
*
* @param success 是否成功
* @param latencyMs 往返耗时
* @param message 状态说明
*/
public record ConnectionResult(boolean success, long latencyMs, String message) {
}
}