mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-15 07:20:40 +00:00
feat: enhance coding harness, model security and system features
This commit is contained in:
@@ -68,6 +68,12 @@
|
||||
<artifactId>swagger-annotations</artifactId>
|
||||
<version>${swagger-annotations.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package org.ruoyi.common.chat.domain.bo.chat;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.common.chat.security.ChatModelSecretReference;
|
||||
import org.ruoyi.common.core.validate.AddGroup;
|
||||
import org.ruoyi.common.core.validate.EditGroup;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
@@ -63,11 +68,21 @@ public class ChatModelBo extends BaseEntity {
|
||||
/**
|
||||
* 请求地址
|
||||
*/
|
||||
@NotBlank(message = "请求地址不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
@Pattern(regexp = ChatModelCredentialPolicy.HTTPS_API_HOST_REGEXP,
|
||||
message = "请求地址必须是无凭据、查询参数和片段的 HTTPS 地址",
|
||||
groups = { AddGroup.class, EditGroup.class })
|
||||
private String apiHost;
|
||||
|
||||
/**
|
||||
* 密钥
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
|
||||
message = "密钥必须使用受信任的环境变量引用",
|
||||
groups = { AddGroup.class, EditGroup.class })
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.common.chat.domain.bo.chat;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Minimal, non-secret query contract for user-facing model selection. */
|
||||
@Data
|
||||
public class ChatModelSelectQuery {
|
||||
|
||||
/** Optional model category; defaults to chat at the controller boundary. */
|
||||
private String category;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package org.ruoyi.common.chat.domain.bo.chat;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.chat.security.ChatModelSecretReference;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
@@ -24,7 +28,12 @@ public class ModelBatchKeyBo extends BaseEntity {
|
||||
/**
|
||||
* 密钥
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
|
||||
message = "密钥必须使用受信任的环境变量引用")
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
}
|
||||
|
||||
@@ -75,6 +75,19 @@ public class ChatRequest {
|
||||
*/
|
||||
private Boolean enableThinking = false;
|
||||
|
||||
/**
|
||||
* 是否回放上下文中已经存在的模型思考内容。
|
||||
* 仅供需要在非思考动作轮中延续 DeepSeek 工具协议的内部调用使用。
|
||||
*/
|
||||
private Boolean replayThinking = false;
|
||||
|
||||
/**
|
||||
* Doubao-Seed-Evolving 思考等级(reasoning_effort)。
|
||||
* 取值 none/minimal/low/medium/high/xhigh/max;null 表示不发送 Doubao 专属参数。
|
||||
* 仅在 custom_api 供应商承载 Doubao 模型时使用,其他模型忽略此字段。
|
||||
*/
|
||||
private String reasoningEffort;
|
||||
|
||||
/**
|
||||
* 对话模型详情
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.ruoyi.common.chat.domain.vo.chat;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Public model option exposed to clients that can select a chat model.
|
||||
*
|
||||
* <p>This deliberately contains only the fields required to identify and display a model.
|
||||
* Operational configuration, including endpoints and credentials, remains server-side.</p>
|
||||
*/
|
||||
@Data
|
||||
public class ChatModelSelectVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Model configuration identifier. */
|
||||
private Long id;
|
||||
|
||||
/** Configured model name. */
|
||||
private String modelName;
|
||||
|
||||
/** User-facing model description; clients can fall back to modelName when blank. */
|
||||
private String modelDescribe;
|
||||
|
||||
/** Provider identifier used for display. */
|
||||
private String providerCode;
|
||||
|
||||
public static ChatModelSelectVo from(ChatModelVo source) {
|
||||
ChatModelSelectVo target = new ChatModelSelectVo();
|
||||
target.setId(source.getId());
|
||||
target.setModelName(source.getModelName());
|
||||
target.setModelDescribe(source.getModelDescribe());
|
||||
target.setProviderCode(source.getProviderCode());
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ package org.ruoyi.common.chat.domain.vo.chat;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
@@ -18,7 +22,7 @@ import java.io.Serializable;
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = ChatModel.class)
|
||||
@AutoMapper(target = ChatModel.class, convertGenerate = false)
|
||||
public class ChatModelVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
@@ -74,11 +78,29 @@ public class ChatModelVo implements Serializable {
|
||||
private String apiHost;
|
||||
|
||||
/**
|
||||
* 密钥
|
||||
* 密钥仅用于服务端模型调用和写入配置。任何读取接口、日志字符串或 Excel 导出
|
||||
* 都不得把它带出服务端边界。
|
||||
*/
|
||||
@ExcelProperty(value = "密钥")
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
/** Returns the stored configuration reference without touching the process environment. */
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the reference only after binding it to both the consuming provider and this row's
|
||||
* effective provider, model and endpoint. Non-allowlisted, misrouted, or legacy rows fail
|
||||
* closed before the environment is read.
|
||||
*/
|
||||
public String resolveApiKeyForConfiguredEndpoint(String consumingProviderCode) {
|
||||
return ChatModelCredentialPolicy.resolveApiKeyForUse(
|
||||
consumingProviderCode, providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.tenant.core.TenantEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
@@ -67,6 +68,8 @@ public class ChatModel extends TenantEntity {
|
||||
/**
|
||||
* 密钥
|
||||
*/
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Security policy binding model credential references to trusted provider endpoints. */
|
||||
public final class ChatModelCredentialPolicy {
|
||||
|
||||
public static final String HTTPS_API_HOST_REGEXP =
|
||||
"^https://(?![^/?#]*@)[^\\s/?#]+(?::[0-9]{1,5})?(?:/[^\\s?#]*)?$";
|
||||
|
||||
public static final String DEEPSEEK_PROVIDER = "deepseek";
|
||||
public static final String DEEPSEEK_API_HOST = "https://api.deepseek.com";
|
||||
public static final String PPIO_PROVIDER = "ppio";
|
||||
public static final String PPIO_API_HOST = "https://api.ppio.com/openai/v1";
|
||||
|
||||
private static final Set<String> PPIO_API_HOSTS = Set.of(
|
||||
PPIO_API_HOST, PPIO_API_HOST + "/",
|
||||
"https://api.ppio.com/openai", "https://api.ppio.com/openai/"
|
||||
);
|
||||
|
||||
private static final Set<String> DEEPSEEK_MODELS = Set.of(
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-vision-exp"
|
||||
);
|
||||
|
||||
private ChatModelCredentialPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a provider credential only after the effective provider/model/endpoint/reference
|
||||
* tuple passes the same allowlist used at the persistence boundary.
|
||||
*/
|
||||
public static String resolveApiKeyForUse(String consumingProviderCode,
|
||||
String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
return resolveApiKeyForUse(consumingProviderCode, providerCode, modelName, apiHost,
|
||||
apiKey, System::getenv);
|
||||
}
|
||||
|
||||
static String resolveApiKeyForUse(String consumingProviderCode,
|
||||
String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey,
|
||||
Function<String, String> environment) {
|
||||
if (!Objects.equals(consumingProviderCode, providerCode)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Credential consumer does not match the configured provider");
|
||||
}
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey, environment);
|
||||
} else {
|
||||
requireTrustedConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
return ChatModelSecretReference.resolveAllowlistedReference(apiKey,
|
||||
Objects.requireNonNull(environment, "environment"));
|
||||
}
|
||||
|
||||
/** Validates the effective row which will remain after an insert or partial update. */
|
||||
public static void requirePersistableConfiguration(String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
requireSecureApiHost(apiHost);
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
return;
|
||||
}
|
||||
if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
return;
|
||||
}
|
||||
if (apiKey != null) {
|
||||
ChatModelSecretReference.requirePersistableReference(apiKey);
|
||||
if (!isDeepSeekConfiguration(providerCode, modelName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Model API key reference is not allowed for this provider");
|
||||
}
|
||||
}
|
||||
if (isDeepSeekConfiguration(providerCode, modelName)) {
|
||||
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-validates the configuration immediately before a DeepSeek provider client is built. */
|
||||
public static void requireDeepSeekConfiguration(String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
if (!DEEPSEEK_PROVIDER.equals(providerCode)) {
|
||||
throw new IllegalArgumentException("DeepSeek model provider is not trusted");
|
||||
}
|
||||
if (!DEEPSEEK_MODELS.contains(modelName)) {
|
||||
throw new IllegalArgumentException("DeepSeek model is not allowlisted");
|
||||
}
|
||||
requireSecureApiHost(apiHost);
|
||||
if (!DEEPSEEK_API_HOST.equals(apiHost)) {
|
||||
throw new IllegalArgumentException("DeepSeek API host is not allowlisted");
|
||||
}
|
||||
if (apiKey == null) {
|
||||
throw new IllegalArgumentException("DeepSeek API key reference is required");
|
||||
}
|
||||
requireProviderReference(providerCode, apiKey);
|
||||
}
|
||||
|
||||
/** Binds each provider to its own environment variable, including batch credential updates. */
|
||||
public static void requireProviderReference(String providerCode, String apiKey) {
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireReference(providerCode, apiKey);
|
||||
return;
|
||||
}
|
||||
String expectedReference;
|
||||
if (DEEPSEEK_PROVIDER.equals(providerCode)) {
|
||||
expectedReference = "env:DEEPSEEK_API_KEY";
|
||||
} else if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
expectedReference = "env:PPIO_API_KEY";
|
||||
} else {
|
||||
throw new IllegalArgumentException("Model credential provider is not allowlisted");
|
||||
}
|
||||
ChatModelSecretReference.requirePersistableReference(apiKey);
|
||||
if (!expectedReference.equals(apiKey)) {
|
||||
throw new IllegalArgumentException("Model API key reference does not match the provider");
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates credential use without allowing another adapter to consume a provider's key. */
|
||||
public static void requireTrustedConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String apiKey) {
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
} else if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
} else {
|
||||
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates PPIO and returns the base URL expected by the OpenAI Chat Completions client. */
|
||||
public static String requirePpioConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String apiKey) {
|
||||
if (!PPIO_PROVIDER.equals(providerCode)) {
|
||||
throw new IllegalArgumentException("PPIO model provider is not trusted");
|
||||
}
|
||||
// PPIO hosts models from multiple vendors; use the full model ID from its catalog.
|
||||
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}")) {
|
||||
throw new IllegalArgumentException("PPIO model ID is invalid");
|
||||
}
|
||||
requireSecureApiHost(apiHost);
|
||||
if (!PPIO_API_HOSTS.contains(apiHost)) {
|
||||
throw new IllegalArgumentException("PPIO API host is not allowlisted");
|
||||
}
|
||||
requireProviderReference(providerCode, apiKey);
|
||||
return PPIO_API_HOST;
|
||||
}
|
||||
|
||||
public static String requireSecureApiHost(String apiHost) {
|
||||
if (apiHost == null || apiHost.isBlank()) {
|
||||
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
|
||||
}
|
||||
final URI uri;
|
||||
try {
|
||||
uri = new URI(apiHost);
|
||||
} catch (URISyntaxException invalid) {
|
||||
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
|
||||
}
|
||||
if (!uri.isAbsolute() || !"https".equals(uri.getScheme()) || uri.getHost() == null
|
||||
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null
|
||||
|| uri.getPort() > 65535) {
|
||||
throw new IllegalArgumentException(
|
||||
"Model API host must be HTTPS without userinfo, query, or fragment");
|
||||
}
|
||||
return apiHost;
|
||||
}
|
||||
|
||||
public static boolean isDeepSeekConfiguration(String providerCode, String modelName) {
|
||||
return DEEPSEEK_PROVIDER.equals(providerCode) || DEEPSEEK_MODELS.contains(modelName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Resolves an explicit {@code env:NAME} model credential without exposing it through DTO JSON. */
|
||||
public final class ChatModelSecretReference {
|
||||
|
||||
public static final String ENV_REFERENCE_REGEXP =
|
||||
"^env:(?:(?:DEEPSEEK|PPIO)_API_KEY|" + CustomApiCredentialPolicy.REFERENCE_PATTERN + ")$";
|
||||
|
||||
private static final String ENV_PREFIX = "env:";
|
||||
private static final Pattern ENV_REFERENCE = Pattern.compile(ENV_REFERENCE_REGEXP);
|
||||
|
||||
private ChatModelSecretReference() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private final resolution step. Callers must first bind provider, model and endpoint
|
||||
* through {@link ChatModelCredentialPolicy}; exposing a context-free resolver would recreate a
|
||||
* credential-confusion channel.
|
||||
*/
|
||||
static String resolveAllowlistedReference(String configuredValue,
|
||||
Function<String, String> environment) {
|
||||
String reference = requirePersistableReference(configuredValue);
|
||||
if (reference == null) {
|
||||
return null;
|
||||
}
|
||||
String variable = reference.substring(ENV_PREFIX.length());
|
||||
String resolved = Objects.requireNonNull(environment, "environment").apply(variable);
|
||||
if (resolved == null || resolved.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"Required model API key environment variable is not configured: " + variable);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the only credential representation that may cross a persistence boundary.
|
||||
* A {@code null} value means that an update did not request a credential change.
|
||||
*/
|
||||
public static String requirePersistableReference(String configuredValue) {
|
||||
if (configuredValue == null) {
|
||||
return null;
|
||||
}
|
||||
if (!ENV_REFERENCE.matcher(configuredValue).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid model API key reference; reference is not allowlisted");
|
||||
}
|
||||
return configuredValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow compatibility hook for an in-place legacy migration. It is deliberately package
|
||||
* private and requires an audit callback which never receives the credential value. Normal
|
||||
* provider resolution and all persistence paths must use the strict methods above.
|
||||
*/
|
||||
static String resolveForLegacyMigration(String configuredValue,
|
||||
Function<String, String> environment,
|
||||
Runnable legacyPlaintextAudit) {
|
||||
if (configuredValue == null || configuredValue.startsWith(ENV_PREFIX)) {
|
||||
return resolveAllowlistedReference(configuredValue, environment);
|
||||
}
|
||||
if (configuredValue.isBlank()) {
|
||||
throw new IllegalArgumentException("Legacy model API key cannot be blank");
|
||||
}
|
||||
Objects.requireNonNull(legacyPlaintextAudit, "legacyPlaintextAudit").run();
|
||||
return configuredValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Binds configurable OpenAI/Anthropic endpoints to dedicated deployment credentials. */
|
||||
public final class CustomApiCredentialPolicy {
|
||||
|
||||
public static final String OPENAI_PROVIDER = "custom_api";
|
||||
public static final String ANTHROPIC_PROVIDER = "custom_anthropic";
|
||||
public static final String REFERENCE_PATTERN =
|
||||
"CUSTOM_(?:OPENAI|ANTHROPIC)(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
|
||||
|
||||
private CustomApiCredentialPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isCustomProvider(String providerCode) {
|
||||
return OPENAI_PROVIDER.equals(providerCode) || ANTHROPIC_PROVIDER.equals(providerCode);
|
||||
}
|
||||
|
||||
public static void requireReference(String providerCode, String reference) {
|
||||
String protocol;
|
||||
if (OPENAI_PROVIDER.equals(providerCode)) {
|
||||
protocol = "OPENAI";
|
||||
} else if (ANTHROPIC_PROVIDER.equals(providerCode)) {
|
||||
protocol = "ANTHROPIC";
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的自定义厂商协议");
|
||||
}
|
||||
String pattern = "env:CUSTOM_" + protocol + "(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
|
||||
if (reference == null || !Pattern.matches(pattern, reference)) {
|
||||
throw new IllegalArgumentException("自定义厂商密钥必须使用与协议匹配的环境变量引用");
|
||||
}
|
||||
}
|
||||
|
||||
public static String requireConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String reference) {
|
||||
return requireConfiguration(providerCode, modelName, apiHost, reference, System::getenv);
|
||||
}
|
||||
|
||||
static String requireConfiguration(String providerCode, String modelName, String apiHost,
|
||||
String reference, Function<String, String> environment) {
|
||||
requireReference(providerCode, reference);
|
||||
if (modelName == null || modelName.isBlank()) {
|
||||
throw new IllegalArgumentException("自定义厂商的模型名称不能为空");
|
||||
}
|
||||
String baseUrl = normalizeBaseUrl(providerCode, apiHost);
|
||||
String keyVariable = reference.substring("env:".length());
|
||||
String hostVariable = keyVariable.substring(0, keyVariable.length() - "_API_KEY".length()) + "_BASE_URL";
|
||||
String configuredHost = environment.apply(hostVariable);
|
||||
if (configuredHost == null || configuredHost.isBlank()) {
|
||||
throw new IllegalArgumentException("请先在后端配置自定义厂商地址环境变量:" + hostVariable);
|
||||
}
|
||||
if (!baseUrl.equals(normalizeBaseUrl(providerCode, configuredHost))) {
|
||||
throw new IllegalArgumentException("模型请求地址与该密钥绑定的自定义厂商地址不一致");
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/** Accepts a base URL or the selected protocol's complete chat endpoint. */
|
||||
public static String normalizeBaseUrl(String providerCode, String apiHost) {
|
||||
if (!isCustomProvider(providerCode)) {
|
||||
throw new IllegalArgumentException("不支持的自定义厂商协议");
|
||||
}
|
||||
ChatModelCredentialPolicy.requireSecureApiHost(apiHost);
|
||||
String normalized = apiHost.replaceAll("/+$", "");
|
||||
String suffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/messages" : "/chat/completions";
|
||||
String otherSuffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/chat/completions" : "/messages";
|
||||
if (normalized.endsWith(otherSuffix)) {
|
||||
throw new IllegalArgumentException("请求地址与所选自定义厂商协议不匹配");
|
||||
}
|
||||
if (normalized.endsWith(suffix)) {
|
||||
normalized = normalized.substring(0, normalized.length() - suffix.length());
|
||||
} else if (URI.create(normalized).getPath().isEmpty()) {
|
||||
normalized += "/v1";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ public interface IChatModelService {
|
||||
*/
|
||||
List<ChatModelVo> queryList(ChatModelBo bo);
|
||||
|
||||
/** 查询当前租户中厂商已启用的模型,供用户选择或业务调用。 */
|
||||
List<ChatModelVo> queryAvailableList(ChatModelBo bo);
|
||||
|
||||
/**
|
||||
* 新增模型管理
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user