mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-15 07:20:40 +00:00
fix: restore admin-managed model keys and refresh cached clients
This commit is contained in:
@@ -4,13 +4,10 @@ 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;
|
||||
@@ -68,19 +65,12 @@ 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;
|
||||
|
||||
@@ -2,11 +2,9 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -30,8 +28,6 @@ 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;
|
||||
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -86,21 +85,6 @@ public class ChatModelVo implements Serializable {
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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";
|
||||
public static final String ATLAS_PROVIDER = "atlas";
|
||||
private static final Set<String> ATLAS_API_HOSTS = Set.of(
|
||||
"https://api.atlascloud.ai", "https://api.atlascloud.ai/",
|
||||
"https://api.atlascloud.ai/v1", "https://api.atlascloud.ai/v1/",
|
||||
"https://api.atlascloud.ai/api/v1", "https://api.atlascloud.ai/api/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 (ATLAS_PROVIDER.equals(providerCode)) {
|
||||
requireAtlasConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
return;
|
||||
}
|
||||
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 if (ATLAS_PROVIDER.equals(providerCode)) {
|
||||
expectedReference = "env:ATLAS_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 if (ATLAS_PROVIDER.equals(providerCode)) {
|
||||
requireAtlasConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
} else {
|
||||
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Atlas credentials may only be sent to its official media API origin. */
|
||||
public static void requireAtlasConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String apiKey) {
|
||||
if (!ATLAS_PROVIDER.equals(providerCode)) {
|
||||
throw new IllegalArgumentException("Atlas model provider is not trusted");
|
||||
}
|
||||
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._/-]{0,254}")) {
|
||||
throw new IllegalArgumentException("Atlas model ID is invalid");
|
||||
}
|
||||
requireSecureApiHost(apiHost);
|
||||
if (!ATLAS_API_HOSTS.contains(apiHost)) {
|
||||
throw new IllegalArgumentException("Atlas API host is not allowlisted");
|
||||
}
|
||||
requireProviderReference(providerCode, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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|ATLAS)_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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
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. */
|
||||
/** Normalizes the OpenAI/Anthropic endpoint configured in model management. */
|
||||
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() {
|
||||
}
|
||||
@@ -19,51 +15,19 @@ public final class CustomApiCredentialPolicy {
|
||||
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);
|
||||
if (apiHost == null || apiHost.isBlank()) {
|
||||
throw new IllegalArgumentException("请填写自定义厂商的请求地址");
|
||||
}
|
||||
URI uri = URI.create(apiHost);
|
||||
if (uri.getHost() == null || !("http".equalsIgnoreCase(uri.getScheme())
|
||||
|| "https".equalsIgnoreCase(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException("请求地址必须是有效的 HTTP 或 HTTPS 地址");
|
||||
}
|
||||
String normalized = apiHost.replaceAll("/+$", "");
|
||||
String suffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/messages" : "/chat/completions";
|
||||
String otherSuffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/chat/completions" : "/messages";
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Tag("dev")
|
||||
class AtlasCredentialPolicyTest {
|
||||
private static final String MODEL = "bytedance/seed-audio-1.0";
|
||||
private static final String HOST = "https://api.atlascloud.ai/v1";
|
||||
private static final String REF = "env:ATLAS_API_KEY";
|
||||
|
||||
@Test
|
||||
void acceptsOfficialEndpointFormsAtSaveAndUse() {
|
||||
for (String suffix : new String[]{"", "/", "/v1", "/v1/", "/api/v1", "/api/v1/"}) {
|
||||
String host = "https://api.atlascloud.ai" + suffix;
|
||||
assertDoesNotThrow(() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, host, REF));
|
||||
assertEquals("test-secret", ChatModelCredentialPolicy.resolveApiKeyForUse("atlas", "atlas", MODEL, host, REF,
|
||||
name -> "ATLAS_API_KEY".equals(name) ? "test-secret" : null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUntrustedEndpointsBeforeReadingEnvironment() {
|
||||
for (String host : new String[]{"http://api.atlascloud.ai/v1", "https://evil.example/v1",
|
||||
"https://api.atlascloud.ai.evil.example/v1", "https://api.atlascloud.ai:8443/v1",
|
||||
"https://user@api.atlascloud.ai/v1", "https://api.atlascloud.ai/v1?x=1", "https://api.atlascloud.ai/other"}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, host, REF));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ChatModelCredentialPolicy.resolveApiKeyForUse("atlas", "atlas", MODEL, host, REF,
|
||||
name -> { fail("Must validate endpoint before resolving secret"); return null; }));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongProviderReferenceAndMissingSecret() {
|
||||
assertThrows(IllegalArgumentException.class, () -> ChatModelCredentialPolicy.resolveApiKeyForUse(
|
||||
"openai", "atlas", MODEL, HOST, REF, name -> "secret"));
|
||||
for (String key : new String[]{"env:PPIO_API_KEY", "plaintext", "", null}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, HOST, key));
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> ChatModelCredentialPolicy.resolveApiKeyForUse(
|
||||
"atlas", "atlas", MODEL, HOST, REF, name -> null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", "bad model", HOST, REF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
|
||||
import org.ruoyi.common.core.validate.AddGroup;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Tag("dev")
|
||||
class DifyApiKeyConfigurationTest {
|
||||
@Test
|
||||
void acceptsAnAppKeyForCloudAndSelfHostedDify() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
for (String host : new String[]{"https://api.dify.ai/v1", "http://localhost:8080/v1"}) {
|
||||
var model = new ChatModelBo();
|
||||
model.setCategory("chat");
|
||||
model.setProviderCode("dify");
|
||||
model.setModelName("dify-chat");
|
||||
model.setApiHost(host);
|
||||
model.setApiKey("app-test-key");
|
||||
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchUpdatesStillRequireAProviderAndANonblankKey() {
|
||||
var batch = new ModelBatchKeyBo();
|
||||
batch.setProviderCode("dify");
|
||||
batch.setApiKey("");
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertFalse(factory.getValidator().validate(batch).isEmpty());
|
||||
batch.setApiKey("app-test-key");
|
||||
assertTrue(factory.getValidator().validate(batch).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.core.validate.AddGroup;
|
||||
import org.ruoyi.common.core.validate.EditGroup;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ModelApiKeyValidationTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"qianwen", "zhipu", "openai", "deepseek", "ppio", "atlas",
|
||||
"minimax", "dify", "coze", "xiaomi", "custom_api", "custom_anthropic"})
|
||||
void acceptsAdminConfiguredKeysForEveryProvider(String provider) {
|
||||
var model = new ChatModelBo();
|
||||
model.setId(1L);
|
||||
model.setCategory("chat");
|
||||
model.setProviderCode(provider);
|
||||
model.setModelName("configured-model");
|
||||
model.setApiHost("http://localhost:11434/v1");
|
||||
model.setApiKey("test-key-" + provider);
|
||||
var batch = new ModelBatchKeyBo();
|
||||
batch.setProviderCode(provider);
|
||||
batch.setApiKey(model.getApiKey());
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
assertTrue(validator.validate(model, AddGroup.class, EditGroup.class).isEmpty());
|
||||
assertTrue(validator.validate(batch).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsKeylessLocalModelsAndDoesNotRequireHiddenHostField() {
|
||||
var model = new ChatModelBo();
|
||||
model.setCategory("chat");
|
||||
model.setProviderCode("ollama");
|
||||
model.setModelName("local-model");
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyRemainsWritableButIsNotSerializedOrIncludedInToString() throws Exception {
|
||||
var mapper = new ObjectMapper();
|
||||
var model = mapper.readValue("{\"providerCode\":\"qianwen\",\"apiKey\":\"test-secret\"}", ChatModelBo.class);
|
||||
assertEquals("test-secret", model.getApiKey());
|
||||
var view = new ChatModelVo();
|
||||
view.setApiKey(model.getApiKey());
|
||||
assertFalse(mapper.writeValueAsString(model).contains("test-secret"));
|
||||
assertFalse(mapper.writeValueAsString(view).contains("apiKey"));
|
||||
assertFalse(model.toString().contains("test-secret"));
|
||||
assertFalse(view.toString().contains("test-secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customEndpointsNeedNoDeploymentVariablesAndSupportLocalServices() {
|
||||
assertEquals("http://localhost:11434/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
|
||||
"custom_api", "http://localhost:11434/v1/chat/completions"));
|
||||
assertEquals("https://example.com/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
|
||||
"custom_anthropic", "https://example.com/v1/messages"));
|
||||
assertThrows(IllegalArgumentException.class, () -> CustomApiCredentialPolicy.normalizeBaseUrl(
|
||||
"custom_anthropic", "https://example.com/v1/chat/completions"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user