Erweiterungen

This commit is contained in:
2026-02-24 17:11:22 +01:00
parent 0d7223b6b6
commit 112978f14f
3 changed files with 124 additions and 61 deletions

View File

@@ -10,48 +10,60 @@ import java.net.URI;
import java.net.URL;
/**
* Configuration for LLM integration via LM Studio. LM Studio provides an
* OpenAI-compatible API.
* Configuration for LLM integration. Supports LM Studio and Moonshot AI.
* Switch provider via {@code app.ai.provider=lmstudio|moonshot} in application.properties.
*/
@Configuration
@Slf4j
public class LlmConfig {
@Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}")
private String baseUrl;
@Value("${app.ai.provider:lmstudio}")
private String provider;
@Value("${spring.ai.openai.chat.options.model:local-model}")
private String model;
@Value("${app.ai.lmstudio.base-url:https://lmstudio.appcreation.de}")
private String lmstudioBaseUrl;
@Value("${app.ai.lmstudio.model:local-model}")
private String lmstudioModel;
@Value("${app.ai.moonshot.base-url:https://api.moonshot.ai}")
private String moonshotBaseUrl;
@Value("${app.ai.moonshot.api-key:}")
private String moonshotApiKey;
@Value("${app.ai.moonshot.model:moonshot-v1-8k}")
private String moonshotModel;
@PostConstruct
public void logConfig() {
log.info("=== LLM Configuration ===");
log.info("Base URL: {}", baseUrl);
log.info("Model: {}", model);
testConnection();
log.info("Provider: {}", provider);
if ("moonshot".equalsIgnoreCase(provider)) {
log.info("Base URL: {}", moonshotBaseUrl);
log.info("Model: {}", moonshotModel);
log.info("API Key: {}***", moonshotApiKey.length() > 8 ? moonshotApiKey.substring(0, 8) : "***");
testConnection(moonshotBaseUrl, moonshotModel, moonshotApiKey);
} else {
log.info("Base URL: {}", lmstudioBaseUrl);
log.info("Model: {}", lmstudioModel);
testConnection(lmstudioBaseUrl, lmstudioModel, null);
}
}
private void testConnection() {
private void testConnection(String baseUrl, String model, String apiKey) {
log.info("Testing LLM connection to: {}", baseUrl);
// Test 1: Basic connectivity
testEndpoint(baseUrl + "/v1/models", "GET", null);
// Test 1: Models endpoint
testEndpoint(baseUrl + "/v1/models", "GET", null, apiKey);
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal
// payload)
// Test 2: Chat completions (no streaming)
String testPayload = "{\"model\":\"" + model
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
log.info("Test payload (stream=false): {}", testPayload);
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
// Test 3: Chat completions WITH streaming to compare behavior
String streamPayload = "{\"model\":\"" + model
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
log.info("Test payload (stream=true): {}", streamPayload);
testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload, apiKey);
}
private void testEndpoint(String endpoint, String method, String payload) {
private void testEndpoint(String endpoint, String method, String payload, String apiKey) {
try {
log.info("Testing endpoint: {} {}", method, endpoint);
URL url = URI.create(endpoint).toURL();
@@ -60,6 +72,10 @@ public class LlmConfig {
connection.setConnectTimeout(5000);
connection.setReadTimeout(10000);
if (apiKey != null && !apiKey.isBlank()) {
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
}
if (payload != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
@@ -74,7 +90,6 @@ public class LlmConfig {
if (responseCode >= 200 && responseCode < 300) {
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
} else {
// Read error body
String errorBody = "";
try (var is = connection.getErrorStream()) {
if (is != null) {
@@ -95,11 +110,15 @@ public class LlmConfig {
}
}
public String getProvider() {
return provider;
}
public String getBaseUrl() {
return baseUrl;
return "moonshot".equalsIgnoreCase(provider) ? moonshotBaseUrl : lmstudioBaseUrl;
}
public String getModel() {
return model;
return "moonshot".equalsIgnoreCase(provider) ? moonshotModel : lmstudioModel;
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
@@ -13,8 +14,9 @@ import java.util.List;
import java.util.Map;
/**
* Direct REST client for LM Studio API. Uses Spring WebClient like
* aimailassistant - bypasses Spring AI.
* Direct REST client for LLM APIs (LM Studio or Moonshot AI).
* Provider is selected via {@code app.ai.provider} in application.properties.
* Both providers expose an OpenAI-compatible /v1/chat/completions endpoint.
*/
@Component
@Slf4j
@@ -23,24 +25,45 @@ public class LlmRestClient {
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String model;
private final String provider;
public LlmRestClient(@Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}") String baseUrl,
@Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) {
public LlmRestClient(
@Value("${app.ai.provider:lmstudio}") String provider,
@Value("${app.ai.lmstudio.base-url:https://lmstudio.appcreation.de}") String lmstudioBaseUrl,
@Value("${app.ai.lmstudio.model:local-model}") String lmstudioModel,
@Value("${app.ai.moonshot.base-url:https://api.moonshot.ai}") String moonshotBaseUrl,
@Value("${app.ai.moonshot.api-key:}") String moonshotApiKey,
@Value("${app.ai.moonshot.model:moonshot-v1-8k}") String moonshotModel,
ObjectMapper objectMapper) {
this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build();
this.model = model;
this.provider = provider.trim().toLowerCase();
this.objectMapper = objectMapper;
log.info("LlmRestClient initialized - URL: {}/v1/chat/completions, Model: {}", baseUrl, model);
WebClient.Builder builder = WebClient.builder();
if ("moonshot".equals(this.provider)) {
this.model = moonshotModel;
builder.baseUrl(moonshotBaseUrl + "/v1/chat/completions");
if (moonshotApiKey != null && !moonshotApiKey.isBlank()) {
builder.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + moonshotApiKey);
}
log.info("LlmRestClient initialized - Provider: moonshot, URL: {}/v1/chat/completions, Model: {}",
moonshotBaseUrl, moonshotModel);
} else {
this.model = lmstudioModel;
builder.baseUrl(lmstudioBaseUrl + "/v1/chat/completions");
log.info("LlmRestClient initialized - Provider: lmstudio, URL: {}/v1/chat/completions, Model: {}",
lmstudioBaseUrl, lmstudioModel);
}
this.webClient = builder.build();
}
/**
* Send a chat completion request to LM Studio.
* Send a chat completion request.
*
* @param systemPrompt
* System prompt for context
* @param userMessage
* User message/question
* @param systemPrompt System prompt for context
* @param userMessage User message/question
* @return LLM response text, or null on error
*/
public String chat(String systemPrompt, String userMessage) {
@@ -48,31 +71,36 @@ public class LlmRestClient {
}
/**
* Send a chat completion request to LM Studio with custom parameters.
* Send a chat completion request with custom parameters.
*
* @param systemPrompt
* System prompt for context
* @param userMessage
* User message/question
* @param temperature
* Temperature for response randomness (0.0-1.0)
* @param maxTokens
* Maximum tokens in response
* @param systemPrompt System prompt for context
* @param userMessage User message/question
* @param temperature Temperature for response randomness (0.0-1.0)
* @param maxTokens Maximum tokens in response
* @return LLM response text, or null on error
*/
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
try {
Map<String, Object> request = Map.of("model", model, "messages",
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
Map<String, Object> request = Map.of(
"model", model,
"messages", List.of(
Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
Map.of("role", "user", "content", userMessage)),
"temperature", temperature, "max_tokens", maxTokens, "stream", false // WICHTIG: Kein Streaming!
);
"temperature", temperature,
"max_tokens", maxTokens,
"stream", false);
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
log.info("Sending request to LLM [{}] (model: {}, prompt length: {} chars)...",
provider, model, userMessage.length());
long startTime = System.currentTimeMillis();
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
String response = webClient.post()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(120))
.block();
long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration);
@@ -81,7 +109,7 @@ public class LlmRestClient {
return extractContent(response);
} catch (Exception e) {
log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
log.error("Error calling LLM API [{}]: {} - {}", provider, e.getClass().getSimpleName(), e.getMessage());
if (log.isDebugEnabled()) {
log.debug("Full stack trace:", e);
}
@@ -96,6 +124,14 @@ public class LlmRestClient {
return chat(null, userMessage);
}
public String getProvider() {
return provider;
}
public String getModel() {
return model;
}
private String extractContent(String response) {
if (response == null || response.isBlank()) {
log.warn("LLM returned null or blank response");
@@ -106,7 +142,6 @@ public class LlmRestClient {
JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) {
String content = choices.get(0).path("message").path("content").asText();
// asText() returns empty string for null/missing nodes - treat as null
if (content == null || content.isBlank()) {
log.warn("LLM response content is empty");
return null;

View File

@@ -73,17 +73,26 @@ app.version=@project.version@
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
# ===========================================
# LLM Configuration (LM Studio)
# LLM Configuration
# Provider: lmstudio | moonshot
# ===========================================
app.ai.provider=moonshot
# --- LM Studio ---
app.ai.lmstudio.base-url=https://lmstudio.appcreation.de
app.ai.lmstudio.model=local-model
# --- Moonshot AI (kimi) ---
app.ai.moonshot.base-url=https://api.moonshot.ai
app.ai.moonshot.api-key=sk-EfHJfwCsxiZbOoBJ21OLWb9RUJQXSXAFIFGKnOedKke5JYZp
app.ai.moonshot.model=moonshot-v1-8k
# Spring AI OpenAI properties (Pflicht für Auto-Configuration, werden vom LlmRestClient überschrieben)
spring.ai.openai.base-url=https://lmstudio.appcreation.de
spring.ai.openai.api-key=not-used
spring.ai.openai.chat.options.model=local-model
spring.ai.openai.chat.options.temperature=0.7
# WICHTIG: Streaming deaktivieren - LM Studio/Docker können Streaming-Responses nicht korrekt handlen
spring.ai.openai.chat.options.stream=false
# Timeouts für OpenAI Client
spring.ai.openai.connect-timeout=10s
spring.ai.openai.read-timeout=120s