feat: cloud AI chat provider via OpenAI-compatible API

This commit is contained in:
Rodrigo Verdiani 2026-07-04 15:38:33 -03:00
parent 7c721cd75f
commit 75275f992d
7 changed files with 93 additions and 6 deletions

View File

@ -1,13 +1,17 @@
package ai.decompile.ai.config;
import io.micrometer.observation.ObservationRegistry;
import java.time.Duration;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.ollama.api.OllamaEmbeddingOptions;
import org.springframework.ai.ollama.management.ModelManagementOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -29,12 +33,25 @@ public class AiConfig {
@Value("${spring.ai.ollama.embedding.options.model:qwen3-embedding:latest}")
private String ollamaEmbeddingModel;
@Value("${AI_CLOUD_API_KEY:}")
private String cloudApiKey;
@Value("${AI_CLOUD_BASE_URL:http://localhost:11434/v1}")
private String cloudBaseUrl;
@Value("${AI_CLOUD_CHAT_MODEL:gemma4:12b}")
private String cloudChatModel;
@Bean
OllamaApi ollamaApi() {
return OllamaApi.builder().baseUrl(ollamaBaseUrl).build();
}
@Bean
@ConditionalOnProperty(
name = "app.ai.default-chat-provider",
havingValue = "ollama",
matchIfMissing = true)
OllamaChatModel ollamaChatModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
return OllamaChatModel.builder()
.ollamaApi(ollamaApi)
@ -57,4 +74,20 @@ public class AiConfig {
.modelManagementOptions(ModelManagementOptions.defaults())
.build();
}
@Bean
@ConditionalOnExpression(
"'${app.ai.default-chat-provider:ollama}' != 'ollama' && '${AI_CLOUD_API_KEY:}' != ''")
OpenAiChatModel openAiChatModel(ObservationRegistry observationRegistry) {
return OpenAiChatModel.builder()
.options(
OpenAiChatOptions.builder()
.baseUrl(cloudBaseUrl)
.apiKey(cloudApiKey)
.model(cloudChatModel)
.timeout(Duration.ofMinutes(5))
.build())
.observationRegistry(observationRegistry)
.build();
}
}

View File

@ -45,7 +45,7 @@ public class ChatController {
public ResponseEntity<SessionResponse> createSession(
@PathVariable UUID binaryId,
@RequestParam(required = false) UUID projectId,
@RequestParam(required = false, defaultValue = "gemma4:12b") String model) {
@RequestParam(required = false) String model) {
if (projectId == null) {
return ResponseEntity.badRequest().build();
}

View File

@ -30,8 +30,14 @@ public class ChatSessionService {
private final StaticAnalysisRepository analysisRepository;
private final StreamingModelProvider streamingModelProvider;
@Value("${app.ai.chat.default-model:gemma4:12b}")
private String defaultModel;
@Value("${AI_CLOUD_CHAT_MODEL:gemma4:12b}")
private String cloudChatModel;
@Value("${spring.ai.ollama.chat.options.model:gemma4:12b}")
private String ollamaChatModel;
@Value("${app.ai.default-chat-provider:ollama}")
private String chatProvider;
@Transactional
public ChatSession createSession(UUID binaryId, UUID projectId, String model) {
@ -46,7 +52,7 @@ public class ChatSessionService {
+ "' has no static analysis yet. Run static analysis before starting a chat session.");
}
var effectiveModel = Objects.nonNull(model) ? model : defaultModel;
var effectiveModel = Objects.nonNull(model) ? model : resolveDefaultModel();
streamingModelProvider.resolve();
var session =
@ -60,6 +66,14 @@ public class ChatSessionService {
return sessionRepository.save(session);
}
private String resolveDefaultModel() {
if ("ollama".equals(chatProvider)) {
return ollamaChatModel;
}
return cloudChatModel;
}
@Transactional(readOnly = true)
public List<ChatSession> getSessions(UUID binaryId) {
return sessionRepository.findByBinaryIdOrderByUpdatedAtDesc(binaryId);

View File

@ -6,6 +6,7 @@ import lombok.extern.log4j.Log4j2;
import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@ -13,6 +14,7 @@ import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnExpression("'${app.ai.default-chat-provider:ollama}' == 'ollama'")
public class OllamaStreamingModelProvider implements StreamingModelProvider {
private final ObjectProvider<OllamaChatModel> ollamaChatModelProvider;

View File

@ -0,0 +1,32 @@
package ai.decompile.ai.service;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnExpression("'${app.ai.default-chat-provider:ollama}' != 'ollama'")
public class OpenAiStreamingModelProvider implements StreamingModelProvider {
private final ObjectProvider<OpenAiChatModel> openAiChatModelProvider;
@Override
public StreamingChatModel resolve() {
var model = openAiChatModelProvider.getIfAvailable();
if (Objects.isNull(model)) {
throw new IllegalStateException(
"Cloud chat is not configured. Set AI_CLOUD_API_KEY in your .env file.");
}
log.info("Using cloud chat provider with model: {}", model);
return model;
}
}

View File

@ -29,13 +29,18 @@ spring:
base-url: http://localhost:11434
embedding:
options:
model: ${AI_EMBEDDING_MODEL:qwen3-embedding:latest}
model: ${AI_EMBEDDING_MODEL:nomic-embed-text}
chat:
options:
model: ${AI_CHAT_MODEL_OLLAMA:gemma4:12b}
temperature: 0.3
top-p: 0.95
top-k: 64
openai:
base-url: ${AI_CLOUD_BASE_URL:http://localhost:11434/v1}
chat:
options:
model: ${AI_CLOUD_CHAT_MODEL:gemma4:12b}
app:
storage:
@ -63,7 +68,7 @@ app:
ai:
default-chat-provider: ${AI_DEFAULT_CHAT_PROVIDER:ollama}
embedding:
dimension: ${AI_EMBEDDING_DIMENSION:4096}
dimension: ${AI_EMBEDDING_DIMENSION:768}
chunk-max-chars: 3000
batch-size: 20
rag:

View File

@ -23,6 +23,7 @@ spring:
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration
- org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration
app:
storage: