Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions charts/chat2db/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions charts/chat2db/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ service:
cors:
allowedOrigin: ""

ai:
openai:
existingSecret: ""
secretKey: api-key
baseUrl: ""
models: []

persistence:
storageClassName: ""
accessModes:
Expand Down
2 changes: 1 addition & 1 deletion chat2db-community-client/src/constants/runtimeEdition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const communityConfig: RuntimeEditionConfig = {
commercialAccount: false,
remoteAppConfig: false,
remoteSubscription: false,
remoteAiModelOptions: false,
remoteAiModelOptions: true,
aiDataCollection: false,
spmTracking: false,
googleAds: false,
Expand Down
17 changes: 5 additions & 12 deletions chat2db-community-client/src/service/aiModelConfig.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -199,7 +202,7 @@ export const testAIModelConfig = async (payload: IAIModelConfigSaveRequest) => {

export const listAvailableModelOptions = async (): Promise<IModelOptionItem[]> => {
const presetOptions = runtimeEditionConfig.remoteAiModelOptions
? (await aiStreamService.getModelOptions(undefined as void)) || []
? filterRemoteModelOptions((await aiStreamService.getModelOptions(undefined as void)) || [])
: [];

if (!runtimeEditionConfig.localPersistence) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
20 changes: 20 additions & 0 deletions chat2db-community-client/src/service/serverModelOptionPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ServerModelOption {
modelConfigId?: string;
model?: string;
customOption?: boolean;
}

export const filterRemoteModelOptions = <T extends ServerModelOption>(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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:";
Expand All @@ -64,6 +66,8 @@ public class AiModelConfigServiceImpl implements IAiModelConfigService {
private final AiModelConfigConverter aiModelConfigConverter;
private final IIdentityService identityService;
private final AesGcmUtil aesGcmUtil;
private final Function<String, String> environment;
private final boolean serverManagedOpenAi;

private final Path storagePath;

Expand All @@ -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<String, String> environment) {
this.objectMapper = objectMapper;
this.aiModelConfigConverter = aiModelConfigConverter;
this.identityService = identityService;
this.storagePath = storagePath;
this.aesGcmUtil = aesGcmUtil;
this.environment = environment;
this.serverManagedOpenAi = validateServerManagedOpenAiConfiguration();
}

@PostConstruct
Expand Down Expand Up @@ -128,6 +140,9 @@ public synchronized List<AiModelOptionItem> listModelOptions() {
});

getPresetModelMap().forEach((provider, models) -> {
if (!hasServerManagedProviderCredential(provider.name())) {
return;
}
for (String model : models) {
AiModelOptionItem item = new AiModelOptionItem();
item.setValue(presetOptionValue(provider, model));
Expand Down Expand Up @@ -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())
Expand All @@ -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()))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -452,12 +490,67 @@ private Map<AiProviderEnum, List<String>> getPresetModelMap() {

private Map<AiProviderEnum, List<String>> localPresetModelMap() {
Map<AiProviderEnum, List<String>> 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<String> configuredModels(String configuredValue, List<String> fallback) {
if (StringUtils.isBlank(configuredValue)) {
return fallback;
}
List<String> 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");
Expand Down
Loading
Loading