diff --git a/.hazyforge/clusters/anvil-primaris/namespace/chat2db/deploy.yaml b/.hazyforge/clusters/anvil-primaris/namespace/chat2db/deploy.yaml index 4dd714a5f2..43d2010a07 100644 --- a/.hazyforge/clusters/anvil-primaris/namespace/chat2db/deploy.yaml +++ b/.hazyforge/clusters/anvil-primaris/namespace/chat2db/deploy.yaml @@ -23,6 +23,14 @@ encryptionKey: cors: allowedOrigin: https://chat2db.hazyforge.io +ai: + openai: + existingSecret: chat2db-openai-proxy + secretKey: api-key + baseUrl: https://codex.hazycloud.io + models: + - gpt-5.4 + resources: requests: cpu: 500m diff --git a/.hazyforge/clusters/anvil-primaris/namespace/chat2db/manifests/openai-proxy-external-secret.yaml b/.hazyforge/clusters/anvil-primaris/namespace/chat2db/manifests/openai-proxy-external-secret.yaml new file mode 100644 index 0000000000..90a70b559c --- /dev/null +++ b/.hazyforge/clusters/anvil-primaris/namespace/chat2db/manifests/openai-proxy-external-secret.yaml @@ -0,0 +1,18 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: chat2db-openai-proxy + namespace: chat2db +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: azurekv-cluster-secret-store + target: + name: chat2db-openai-proxy + creationPolicy: Owner + deletionPolicy: Delete + data: + - secretKey: api-key + remoteRef: + key: secret/openai-key-proxy-client-api-key diff --git a/charts/chat2db/templates/deployment.yaml b/charts/chat2db/templates/deployment.yaml index bce0a8d531..b7391f96c3 100644 --- a/charts/chat2db/templates/deployment.yaml +++ b/charts/chat2db/templates/deployment.yaml @@ -42,6 +42,21 @@ spec: - name: CHAT2DB_WEB_CORS_ALLOWED_ORIGIN value: {{ . | quote }} {{- end }} + {{- with .Values.ai.openai.existingSecret }} + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: {{ . | quote }} + key: {{ $.Values.ai.openai.secretKey | quote }} + {{- end }} + {{- with .Values.ai.openai.baseUrl }} + - name: OPENAI_BASE_URL + value: {{ . | quote }} + {{- end }} + {{- with .Values.ai.openai.models }} + - name: CHAT2DB_OPENAI_MODELS + value: {{ join "," . | quote }} + {{- end }} ports: - name: http containerPort: 10825 diff --git a/charts/chat2db/values.yaml b/charts/chat2db/values.yaml index d6e7045eb6..3b8d65e403 100644 --- a/charts/chat2db/values.yaml +++ b/charts/chat2db/values.yaml @@ -15,6 +15,13 @@ service: cors: allowedOrigin: "" +ai: + openai: + existingSecret: "" + secretKey: api-key + baseUrl: "" + models: [] + persistence: storageClassName: "" accessModes: diff --git a/chat2db-community-client/src/constants/runtimeEdition.ts b/chat2db-community-client/src/constants/runtimeEdition.ts index 5c3e5689db..cc2798071f 100644 --- a/chat2db-community-client/src/constants/runtimeEdition.ts +++ b/chat2db-community-client/src/constants/runtimeEdition.ts @@ -174,7 +174,7 @@ const communityConfig: RuntimeEditionConfig = { commercialAccount: false, remoteAppConfig: false, remoteSubscription: false, - remoteAiModelOptions: false, + remoteAiModelOptions: true, aiDataCollection: false, spmTracking: false, googleAds: false, diff --git a/chat2db-community-client/src/service/aiModelConfig.ts b/chat2db-community-client/src/service/aiModelConfig.ts index b3c24f0a77..a78afb6f52 100644 --- a/chat2db-community-client/src/service/aiModelConfig.ts +++ b/chat2db-community-client/src/service/aiModelConfig.ts @@ -1,6 +1,9 @@ import { runtimeEditionConfig } from '@/constants/runtimeEdition'; import aiStreamService, { IModelOptionItem } from './aiStream'; import createRequest from './base'; +import { filterRemoteModelOptions, serverPresetRequestPayload } from './serverModelOptionPolicy'; + +export { filterRemoteModelOptions } from './serverModelOptionPolicy'; export type AIProvider = 'OPENAI' | 'CLAUDE' | 'GEMINI'; @@ -199,7 +202,7 @@ export const testAIModelConfig = async (payload: IAIModelConfigSaveRequest) => { export const listAvailableModelOptions = async (): Promise => { const presetOptions = runtimeEditionConfig.remoteAiModelOptions - ? (await aiStreamService.getModelOptions(undefined as void)) || [] + ? filterRemoteModelOptions((await aiStreamService.getModelOptions(undefined as void)) || []) : []; if (!runtimeEditionConfig.localPersistence) { @@ -237,17 +240,7 @@ export const resolveModelRequestPayload = async (option: IModelOptionItem) => { } if (option.modelConfigId) { - return { - modelConfigId: option.modelConfigId, - provider: undefined, - model: option.model, - apiKey: undefined, - baseUrl: undefined, - projectId: undefined, - location: undefined, - temperature: undefined, - maxTokens: undefined, - }; + return serverPresetRequestPayload(option); } return { diff --git a/chat2db-community-client/src/service/serverModelOptionPolicy.test.ts b/chat2db-community-client/src/service/serverModelOptionPolicy.test.ts new file mode 100644 index 0000000000..25f4c379ce --- /dev/null +++ b/chat2db-community-client/src/service/serverModelOptionPolicy.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { filterRemoteModelOptions, serverPresetRequestPayload } from './serverModelOptionPolicy'; + +const remoteOptions = filterRemoteModelOptions([ + { + modelConfigId: 'config:server-user-config', + model: 'gpt-private', + customOption: true, + }, + { + modelConfigId: 'preset:OPENAI:gpt-5.4', + model: 'gpt-5.4', + customOption: false, + }, +]); + +assert.equal(remoteOptions.length, 1); +assert.equal(remoteOptions[0].modelConfigId, 'preset:OPENAI:gpt-5.4'); +assert.deepEqual(serverPresetRequestPayload(remoteOptions[0]), { + modelConfigId: 'preset:OPENAI:gpt-5.4', + provider: undefined, + model: undefined, + apiKey: undefined, + baseUrl: undefined, + projectId: undefined, + location: undefined, + temperature: undefined, + maxTokens: undefined, +}); diff --git a/chat2db-community-client/src/service/serverModelOptionPolicy.ts b/chat2db-community-client/src/service/serverModelOptionPolicy.ts new file mode 100644 index 0000000000..e23648c089 --- /dev/null +++ b/chat2db-community-client/src/service/serverModelOptionPolicy.ts @@ -0,0 +1,20 @@ +export interface ServerModelOption { + modelConfigId?: string; + model?: string; + customOption?: boolean; +} + +export const filterRemoteModelOptions = (options: T[]) => + options.filter((item) => !item.customOption); + +export const serverPresetRequestPayload = (option: ServerModelOption) => ({ + modelConfigId: option.modelConfigId, + provider: undefined, + model: undefined, + apiKey: undefined, + baseUrl: undefined, + projectId: undefined, + location: undefined, + temperature: undefined, + maxTokens: undefined, +}); diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImpl.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImpl.java index 98e8f5a78f..8c852f8cde 100644 --- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImpl.java +++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImpl.java @@ -47,6 +47,7 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -55,6 +56,7 @@ public class AiModelConfigServiceImpl implements IAiModelConfigService { private static final String DEFAULT_GEMINI_LOCATION = "us-central1"; private static final String DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; + private static final String OPENAI_MODELS_ENV = "CHAT2DB_OPENAI_MODELS"; private static final int TEST_ERROR_BODY_MAX_LENGTH = 2000; private static final String CONFIG_VALUE_PREFIX = "config:"; private static final String PRESET_VALUE_PREFIX = "preset:"; @@ -64,6 +66,8 @@ public class AiModelConfigServiceImpl implements IAiModelConfigService { private final AiModelConfigConverter aiModelConfigConverter; private final IIdentityService identityService; private final AesGcmUtil aesGcmUtil; + private final Function environment; + private final boolean serverManagedOpenAi; private final Path storagePath; @@ -77,16 +81,24 @@ public AiModelConfigServiceImpl(ObjectMapper objectMapper, AiModelConfigConverte IIdentityService identityService) { this(objectMapper, aiModelConfigConverter, identityService, Paths.get(ConfigUtils.getBasePath(), "ai-model-configs.json"), - ConfigUtils.isCommunity() ? AesGcmUtil.configured() : null); + ConfigUtils.isCommunity() ? AesGcmUtil.configured() : null, System::getenv); } AiModelConfigServiceImpl(ObjectMapper objectMapper, AiModelConfigConverter aiModelConfigConverter, IIdentityService identityService, Path storagePath, AesGcmUtil aesGcmUtil) { + this(objectMapper, aiModelConfigConverter, identityService, storagePath, aesGcmUtil, System::getenv); + } + + AiModelConfigServiceImpl(ObjectMapper objectMapper, AiModelConfigConverter aiModelConfigConverter, + IIdentityService identityService, Path storagePath, AesGcmUtil aesGcmUtil, + Function environment) { this.objectMapper = objectMapper; this.aiModelConfigConverter = aiModelConfigConverter; this.identityService = identityService; this.storagePath = storagePath; this.aesGcmUtil = aesGcmUtil; + this.environment = environment; + this.serverManagedOpenAi = validateServerManagedOpenAiConfiguration(); } @PostConstruct @@ -128,6 +140,9 @@ public synchronized List listModelOptions() { }); getPresetModelMap().forEach((provider, models) -> { + if (!hasServerManagedProviderCredential(provider.name())) { + return; + } for (String model : models) { AiModelOptionItem item = new AiModelOptionItem(); item.setValue(presetOptionValue(provider, model)); @@ -226,7 +241,21 @@ public synchronized AiRuntimeModel resolveRuntimeModel(AiChatRuntimeResolveReque if (StringUtils.isNotBlank(request.getModelConfigId())) { String modelConfigId = request.getModelConfigId().trim(); if (modelConfigId.startsWith(PRESET_VALUE_PREFIX)) { - throw new BusinessException(ERROR_MODEL_CONFIG_REQUIRED); + String preset = modelConfigId.substring(PRESET_VALUE_PREFIX.length()); + int separator = preset.indexOf(':'); + if (separator <= 0 || separator == preset.length() - 1) { + throw new BusinessException(ERROR_MODEL_CONFIG_REQUIRED); + } + String provider = preset.substring(0, separator); + String model = preset.substring(separator + 1); + if (!isPresetModel(provider, model) || !hasServerManagedProviderCredential(provider)) { + throw new BusinessException(ERROR_MODEL_CONFIG_REQUIRED); + } + baseConfig = new AiModelConfig(); + baseConfig.setProvider(provider); + baseConfig.setModel(model); + systemPreset = true; + rejectServerPresetOverrides(request); } else { String resolvedId = modelConfigId.startsWith(CONFIG_VALUE_PREFIX) ? modelConfigId.substring(CONFIG_VALUE_PREFIX.length()) @@ -246,14 +275,6 @@ public synchronized AiRuntimeModel resolveRuntimeModel(AiChatRuntimeResolveReque baseConfig.setLocation(request.getLocation()); baseConfig.setTemperature(request.getTemperature()); baseConfig.setMaxTokens(request.getMaxTokens()); - systemPreset = isPresetModel(request.getProvider(), request.getModel()) - && StringUtils.isBlank(request.getApiKey()) - && StringUtils.isBlank(request.getBaseUrl()) - && StringUtils.isBlank(request.getProjectId()) - && StringUtils.isBlank(request.getLocation()); - if (systemPreset) { - throw new BusinessException(ERROR_MODEL_CONFIG_REQUIRED); - } } else { baseConfig = userConfigMap.getOrDefault(userId, new ArrayList<>()).stream() .filter(c -> Boolean.TRUE.equals(c.getDefaultConfig())) @@ -281,6 +302,14 @@ public synchronized AiRuntimeModel resolveRuntimeModel(AiChatRuntimeResolveReque return runtimeModel; } + private void rejectServerPresetOverrides(AiChatRuntimeResolveRequest request) { + if (StringUtils.isNotBlank(request.getProvider()) || StringUtils.isNotBlank(request.getModel()) + || StringUtils.isNotBlank(request.getApiKey()) || StringUtils.isNotBlank(request.getBaseUrl()) + || StringUtils.isNotBlank(request.getProjectId()) || StringUtils.isNotBlank(request.getLocation())) { + throw new BusinessException(ERROR_MODEL_CONFIG_REQUIRED); + } + } + /** * Spring AI's OpenAiApi and AnthropicApi append version-prefixed request paths * ("/v1/chat/completions", "/v1/messages") to the configured base URL, so a base @@ -322,18 +351,24 @@ private void fillRuntimeFallback(AiRuntimeModel runtimeModel) { } if (provider == AiProviderEnum.OPENAI) { - runtimeModel.setApiKey(defaultValue(trimToNull(runtimeModel.getApiKey()), trimToNull(System.getenv("OPENAI_API_KEY")))); - runtimeModel.setBaseUrl(defaultValue(trimToNull(runtimeModel.getBaseUrl()), trimToNull(System.getenv("OPENAI_BASE_URL")))); + if (runtimeModel.isSystemPreset()) { + runtimeModel.setApiKey(defaultValue(trimToNull(runtimeModel.getApiKey()), trimToNull(environment.apply("OPENAI_API_KEY")))); + runtimeModel.setBaseUrl(defaultValue(trimToNull(runtimeModel.getBaseUrl()), trimToNull(environment.apply("OPENAI_BASE_URL")))); + } return; } if (provider == AiProviderEnum.CLAUDE) { - runtimeModel.setApiKey(defaultValue(trimToNull(runtimeModel.getApiKey()), trimToNull(System.getenv("ANTHROPIC_API_KEY")))); - runtimeModel.setBaseUrl(defaultValue(trimToNull(runtimeModel.getBaseUrl()), trimToNull(System.getenv("ANTHROPIC_BASE_URL")))); + if (runtimeModel.isSystemPreset()) { + runtimeModel.setApiKey(defaultValue(trimToNull(runtimeModel.getApiKey()), trimToNull(environment.apply("ANTHROPIC_API_KEY")))); + runtimeModel.setBaseUrl(defaultValue(trimToNull(runtimeModel.getBaseUrl()), trimToNull(environment.apply("ANTHROPIC_BASE_URL")))); + } return; } if (provider == AiProviderEnum.GEMINI) { - runtimeModel.setProjectId(defaultValue(trimToNull(runtimeModel.getProjectId()), trimToNull(System.getenv("GOOGLE_CLOUD_PROJECT")))); - runtimeModel.setLocation(defaultValue(trimToNull(runtimeModel.getLocation()), trimToNull(System.getenv("GOOGLE_CLOUD_LOCATION")))); + if (runtimeModel.isSystemPreset()) { + runtimeModel.setProjectId(defaultValue(trimToNull(runtimeModel.getProjectId()), trimToNull(environment.apply("GOOGLE_CLOUD_PROJECT")))); + runtimeModel.setLocation(defaultValue(trimToNull(runtimeModel.getLocation()), trimToNull(environment.apply("GOOGLE_CLOUD_LOCATION")))); + } runtimeModel.setLocation(defaultValue(runtimeModel.getLocation(), DEFAULT_GEMINI_LOCATION)); } } @@ -350,6 +385,9 @@ private void validateRuntimeModel(AiRuntimeModel runtimeModel) { throw new IllegalArgumentException("Unsupported provider: " + runtimeModel.getProvider()); } if (provider == AiProviderEnum.OPENAI || provider == AiProviderEnum.CLAUDE) { + if (StringUtils.isBlank(runtimeModel.getApiKey())) { + throw new IllegalArgumentException("API key is required for provider: " + provider.name()); + } return; } if (provider == AiProviderEnum.GEMINI) { @@ -452,12 +490,67 @@ private Map> getPresetModelMap() { private Map> localPresetModelMap() { Map> presets = new EnumMap<>(AiProviderEnum.class); - presets.put(AiProviderEnum.OPENAI, List.of("gpt-5.2")); + presets.put(AiProviderEnum.OPENAI, + configuredModels(environment.apply(OPENAI_MODELS_ENV), List.of("gpt-5.2"))); presets.put(AiProviderEnum.CLAUDE, List.of("claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001")); presets.put(AiProviderEnum.GEMINI, List.of("gemini-2.5-pro", "gemini-2.5-flash")); return presets; } + static List configuredModels(String configuredValue, List fallback) { + if (StringUtils.isBlank(configuredValue)) { + return fallback; + } + List models = java.util.Arrays.stream(configuredValue.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + return models.isEmpty() ? fallback : models; + } + + private boolean hasServerManagedProviderCredential(String providerValue) { + AiProviderEnum provider = AiProviderEnum.from(providerValue); + if (provider == AiProviderEnum.OPENAI) { + return serverManagedOpenAi; + } + if (provider == AiProviderEnum.CLAUDE) { + return StringUtils.isNotBlank(environment.apply("ANTHROPIC_API_KEY")); + } + if (provider == AiProviderEnum.GEMINI) { + return StringUtils.isNotBlank(environment.apply("GOOGLE_CLOUD_PROJECT")); + } + return false; + } + + private boolean validateServerManagedOpenAiConfiguration() { + String models = trimToNull(environment.apply(OPENAI_MODELS_ENV)); + String apiKey = trimToNull(environment.apply("OPENAI_API_KEY")); + String baseUrl = trimToNull(environment.apply("OPENAI_BASE_URL")); + boolean anyConfigured = models != null || apiKey != null || baseUrl != null; + if (!anyConfigured) { + return false; + } + if (models == null || apiKey == null || baseUrl == null) { + throw new IllegalStateException(OPENAI_MODELS_ENV + + ", OPENAI_API_KEY, and OPENAI_BASE_URL must be configured together"); + } + URI uri; + try { + uri = URI.create(baseUrl); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("OPENAI_BASE_URL must be an absolute HTTPS URL", e); + } + if (!"https".equals(uri.getScheme()) || StringUtils.isBlank(uri.getHost()) + || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) { + throw new IllegalStateException("OPENAI_BASE_URL must be an absolute HTTPS URL without credentials, query, or fragment"); + } + if (configuredModels(models, List.of()).isEmpty()) { + throw new IllegalStateException(OPENAI_MODELS_ENV + " must contain at least one model"); + } + return true; + } + private ModelConfigTestResponse testOpenAiCompatibleConfig(AiModelConfigSaveRequest request) { String baseUrl = StringUtils.defaultIfBlank(trimToNull(request.getBaseUrl()), DEFAULT_OPENAI_BASE_URL); String endpoint = appendPath(stripTrailingV1(baseUrl), "/v1/chat/completions"); diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplBaseUrlTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplBaseUrlTest.java index 61d72028f1..4cd693b1c9 100644 --- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplBaseUrlTest.java +++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplBaseUrlTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.provider.CsvSource; import java.nio.file.Path; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -47,7 +48,11 @@ private String resolveBaseUrl(String provider, String baseUrl) { } private AiModelConfigServiceImpl service() { + return service(Map.of()); + } + + private AiModelConfigServiceImpl service(Map environment) { return new AiModelConfigServiceImpl(new ObjectMapper().findAndRegisterModules(), new AiModelConfigConverter(), - () -> USER_ID, tempDirectory.resolve("ai-model-configs.json"), null); + () -> USER_ID, tempDirectory.resolve("ai-model-configs.json"), null, environment::get); } } diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplConfiguredModelsTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplConfiguredModelsTest.java new file mode 100644 index 0000000000..b822626ffd --- /dev/null +++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplConfiguredModelsTest.java @@ -0,0 +1,30 @@ +package ai.chat2db.community.domain.core.impl.ai; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AiModelConfigServiceImplConfiguredModelsTest { + + private static final List FALLBACK = List.of("gpt-default"); + + @Test + void usesFallbackWhenConfigurationIsMissing() { + assertEquals(FALLBACK, AiModelConfigServiceImpl.configuredModels(null, FALLBACK)); + assertEquals(FALLBACK, AiModelConfigServiceImpl.configuredModels(" ", FALLBACK)); + } + + @Test + void parsesTrimsAndDeduplicatesConfiguredModels() { + assertEquals(List.of("gpt-5.4", "gpt-5-mini"), + AiModelConfigServiceImpl.configuredModels(" gpt-5.4, gpt-5-mini, gpt-5.4 ", FALLBACK)); + } + + @Test + void ignoresEmptyItems() { + assertEquals(List.of("gpt-5.4"), + AiModelConfigServiceImpl.configuredModels(",,gpt-5.4,,", FALLBACK)); + } +} diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplServerPresetTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplServerPresetTest.java new file mode 100644 index 0000000000..7708aeca03 --- /dev/null +++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/impl/ai/AiModelConfigServiceImplServerPresetTest.java @@ -0,0 +1,109 @@ +package ai.chat2db.community.domain.core.impl.ai; + +import ai.chat2db.community.domain.api.model.request.ai.AiChatRuntimeResolveRequest; +import ai.chat2db.community.domain.core.converter.AiModelConfigConverter; +import ai.chat2db.community.tools.exception.BusinessException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiModelConfigServiceImplServerPresetTest { + + @TempDir + Path tempDirectory; + + @Test + void completeConfigurationExposesAndResolvesOnlyDeclaredPreset() { + AiModelConfigServiceImpl service = service(configuration("https://codex.hazycloud.io/v1")); + + assertEquals(List.of("preset:OPENAI:gpt-5.4"), + service.listModelOptions().stream().map(option -> option.getValue()).toList()); + + var runtime = service.resolveRuntimeModel(preset("preset:OPENAI:gpt-5.4")); + assertEquals("OPENAI", runtime.getProvider()); + assertEquals("gpt-5.4", runtime.getModel()); + assertEquals("proxy-client-key", runtime.getApiKey()); + assertEquals("https://codex.hazycloud.io", runtime.getBaseUrl()); + assertTrue(runtime.isSystemPreset()); + assertFalse(Files.exists(tempDirectory.resolve("ai-model-configs.json"))); + } + + @Test + void undeclaredOrMalformedPresetFailsClosed() { + AiModelConfigServiceImpl service = service(configuration("https://codex.hazycloud.io")); + + assertThrows(BusinessException.class, + () -> service.resolveRuntimeModel(preset("preset:OPENAI:gpt-unapproved"))); + assertThrows(BusinessException.class, + () -> service.resolveRuntimeModel(preset("preset:OPENAI"))); + } + + @Test + void declaredPresetRejectsEveryUpstreamOrModelOverride() { + AiModelConfigServiceImpl service = service(configuration("https://codex.hazycloud.io")); + + AiChatRuntimeResolveRequest baseUrlOverride = preset("preset:OPENAI:gpt-5.4"); + baseUrlOverride.setBaseUrl("https://attacker.example"); + assertThrows(BusinessException.class, () -> service.resolveRuntimeModel(baseUrlOverride)); + + AiChatRuntimeResolveRequest modelOverride = preset("preset:OPENAI:gpt-5.4"); + modelOverride.setModel("gpt-unapproved"); + assertThrows(BusinessException.class, () -> service.resolveRuntimeModel(modelOverride)); + + AiChatRuntimeResolveRequest providerOverride = preset("preset:OPENAI:gpt-5.4"); + providerOverride.setProvider("CLAUDE"); + assertThrows(BusinessException.class, () -> service.resolveRuntimeModel(providerOverride)); + + AiChatRuntimeResolveRequest keyOverride = preset("preset:OPENAI:gpt-5.4"); + keyOverride.setApiKey("browser-supplied-key"); + assertThrows(BusinessException.class, () -> service.resolveRuntimeModel(keyOverride)); + } + + @Test + void directKeylessRequestCannotBorrowServerCredential() { + AiChatRuntimeResolveRequest request = new AiChatRuntimeResolveRequest(); + request.setProvider("OPENAI"); + request.setModel("gpt-5.4"); + + assertThrows(IllegalArgumentException.class, + () -> service(configuration("https://codex.hazycloud.io")).resolveRuntimeModel(request)); + } + + @Test + void partialOrUnsafeConfigurationFailsConstruction() { + assertThrows(IllegalStateException.class, + () -> service(Map.of("OPENAI_API_KEY", "proxy-client-key"))); + assertThrows(IllegalStateException.class, + () -> service(configuration("http://codex.hazycloud.io"))); + assertThrows(IllegalStateException.class, + () -> service(configuration("https://user@codex.hazycloud.io/v1"))); + } + + private AiChatRuntimeResolveRequest preset(String id) { + AiChatRuntimeResolveRequest request = new AiChatRuntimeResolveRequest(); + request.setModelConfigId(id); + return request; + } + + private Map configuration(String baseUrl) { + return Map.of( + "CHAT2DB_OPENAI_MODELS", "gpt-5.4", + "OPENAI_API_KEY", "proxy-client-key", + "OPENAI_BASE_URL", baseUrl); + } + + private AiModelConfigServiceImpl service(Map environment) { + return new AiModelConfigServiceImpl(new ObjectMapper().findAndRegisterModules(), new AiModelConfigConverter(), + () -> 42L, tempDirectory.resolve("ai-model-configs.json"), null, environment::get); + } +} diff --git a/chat2db-community-server/chat2db-community-start/src/main/resources/logback-hazyforge.xml b/chat2db-community-server/chat2db-community-start/src/main/resources/logback-hazyforge.xml index 37627a0014..7382ed2466 100644 --- a/chat2db-community-server/chat2db-community-start/src/main/resources/logback-hazyforge.xml +++ b/chat2db-community-server/chat2db-community-start/src/main/resources/logback-hazyforge.xml @@ -13,6 +13,7 @@ + diff --git a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactory.java b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactory.java index 1addedb03e..f675640e0a 100644 --- a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactory.java +++ b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactory.java @@ -53,9 +53,6 @@ public AiModelFactory(GenericApplicationContext applicationContext) { public AiChatClient create(AiRuntimeModel runtimeModel, RequestMode requestMode) { AiProviderEnum provider = AiProviderEnum.from(runtimeModel.getProvider()); - if (runtimeModel.isSystemPreset()) { - throw new IllegalArgumentException("Community edition requires a user configured AI model."); - } RetryTemplate retryTemplate = createRetryTemplate(requestMode); if (provider == AiProviderEnum.OPENAI) { return openAiClient(runtimeModel, retryTemplate); @@ -215,10 +212,7 @@ private String maskSecret(String value) { if (StringUtils.isBlank(value)) { return value; } - if (value.length() <= 8) { - return "****"; - } - return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + return "[redacted]"; } @Getter diff --git a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactoryRetryPolicyTest.java b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactoryRetryPolicyTest.java index 22f9295b71..9c7ebb2451 100644 --- a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactoryRetryPolicyTest.java +++ b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/adapter/ai/AiModelFactoryRetryPolicyTest.java @@ -1,11 +1,14 @@ package ai.chat2db.community.web.api.adapter.ai; +import ai.chat2db.community.domain.api.model.ai.AiRuntimeModel; import org.junit.jupiter.api.Test; +import org.springframework.context.support.GenericApplicationContext; import org.springframework.retry.support.RetryTemplate; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; class AiModelFactoryRetryPolicyTest { @@ -25,6 +28,21 @@ void synchronousRequestsPreserveTheDefaultRetryPolicy() { AiModelFactory.RequestMode.SYNCHRONOUS))); } + @Test + void validatedServerPresetCanConstructOpenAiClient() { + try (GenericApplicationContext context = new GenericApplicationContext()) { + context.refresh(); + AiRuntimeModel runtime = new AiRuntimeModel(); + runtime.setProvider("OPENAI"); + runtime.setModel("gpt-5.4"); + runtime.setApiKey("proxy-client-key"); + runtime.setBaseUrl("https://codex.hazycloud.io"); + runtime.setSystemPreset(true); + + assertNotNull(new AiModelFactory(context).create(runtime, AiModelFactory.RequestMode.STREAMING)); + } + } + private int countAttempts(RetryTemplate retryTemplate) { AtomicInteger attempts = new AtomicInteger(); assertThrows(IllegalStateException.class, () -> retryTemplate.execute(context -> {