From 112978f14fbb8d4c96659133da023ba62e487f3f Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 24 Feb 2026 17:11:22 +0100 Subject: [PATCH] Erweiterungen --- .../votianlt/ai/config/LlmConfig.java | 71 +++++++++----- .../votianlt/ai/service/LlmRestClient.java | 95 +++++++++++++------ src/main/resources/application.properties | 19 +++- 3 files changed, 124 insertions(+), 61 deletions(-) diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java index fa85818..f193ee3 100644 --- a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java +++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java @@ -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; } } diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java index 1744e25..f22bcf1 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java @@ -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 request = Map.of("model", model, "messages", - List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""), + Map 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; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1874462..918a45e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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