Erweiterungen
This commit is contained in:
@@ -10,48 +10,60 @@ import java.net.URI;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for LLM integration via LM Studio. LM Studio provides an
|
* Configuration for LLM integration. Supports LM Studio and Moonshot AI.
|
||||||
* OpenAI-compatible API.
|
* Switch provider via {@code app.ai.provider=lmstudio|moonshot} in application.properties.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LlmConfig {
|
public class LlmConfig {
|
||||||
|
|
||||||
@Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}")
|
@Value("${app.ai.provider:lmstudio}")
|
||||||
private String baseUrl;
|
private String provider;
|
||||||
|
|
||||||
@Value("${spring.ai.openai.chat.options.model:local-model}")
|
@Value("${app.ai.lmstudio.base-url:https://lmstudio.appcreation.de}")
|
||||||
private String model;
|
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
|
@PostConstruct
|
||||||
public void logConfig() {
|
public void logConfig() {
|
||||||
log.info("=== LLM Configuration ===");
|
log.info("=== LLM Configuration ===");
|
||||||
log.info("Base URL: {}", baseUrl);
|
log.info("Provider: {}", provider);
|
||||||
log.info("Model: {}", model);
|
if ("moonshot".equalsIgnoreCase(provider)) {
|
||||||
testConnection();
|
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);
|
log.info("Testing LLM connection to: {}", baseUrl);
|
||||||
|
|
||||||
// Test 1: Basic connectivity
|
// Test 1: Models endpoint
|
||||||
testEndpoint(baseUrl + "/v1/models", "GET", null);
|
testEndpoint(baseUrl + "/v1/models", "GET", null, apiKey);
|
||||||
|
|
||||||
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal
|
// Test 2: Chat completions (no streaming)
|
||||||
// payload)
|
|
||||||
String testPayload = "{\"model\":\"" + model
|
String testPayload = "{\"model\":\"" + model
|
||||||
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
||||||
log.info("Test payload (stream=false): {}", testPayload);
|
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload, apiKey);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void testEndpoint(String endpoint, String method, String payload) {
|
private void testEndpoint(String endpoint, String method, String payload, String apiKey) {
|
||||||
try {
|
try {
|
||||||
log.info("Testing endpoint: {} {}", method, endpoint);
|
log.info("Testing endpoint: {} {}", method, endpoint);
|
||||||
URL url = URI.create(endpoint).toURL();
|
URL url = URI.create(endpoint).toURL();
|
||||||
@@ -60,6 +72,10 @@ public class LlmConfig {
|
|||||||
connection.setConnectTimeout(5000);
|
connection.setConnectTimeout(5000);
|
||||||
connection.setReadTimeout(10000);
|
connection.setReadTimeout(10000);
|
||||||
|
|
||||||
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (payload != null) {
|
if (payload != null) {
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
@@ -74,7 +90,6 @@ public class LlmConfig {
|
|||||||
if (responseCode >= 200 && responseCode < 300) {
|
if (responseCode >= 200 && responseCode < 300) {
|
||||||
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
|
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
|
||||||
} else {
|
} else {
|
||||||
// Read error body
|
|
||||||
String errorBody = "";
|
String errorBody = "";
|
||||||
try (var is = connection.getErrorStream()) {
|
try (var is = connection.getErrorStream()) {
|
||||||
if (is != null) {
|
if (is != null) {
|
||||||
@@ -95,11 +110,15 @@ public class LlmConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
public String getBaseUrl() {
|
public String getBaseUrl() {
|
||||||
return baseUrl;
|
return "moonshot".equalsIgnoreCase(provider) ? moonshotBaseUrl : lmstudioBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getModel() {
|
public String getModel() {
|
||||||
return model;
|
return "moonshot".equalsIgnoreCase(provider) ? moonshotModel : lmstudioModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
@@ -13,8 +14,9 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct REST client for LM Studio API. Uses Spring WebClient like
|
* Direct REST client for LLM APIs (LM Studio or Moonshot AI).
|
||||||
* aimailassistant - bypasses Spring AI.
|
* Provider is selected via {@code app.ai.provider} in application.properties.
|
||||||
|
* Both providers expose an OpenAI-compatible /v1/chat/completions endpoint.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -23,24 +25,45 @@ public class LlmRestClient {
|
|||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final String model;
|
private final String model;
|
||||||
|
private final String provider;
|
||||||
|
|
||||||
public LlmRestClient(@Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}") String baseUrl,
|
public LlmRestClient(
|
||||||
@Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) {
|
@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.provider = provider.trim().toLowerCase();
|
||||||
this.model = model;
|
|
||||||
this.objectMapper = objectMapper;
|
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
|
* @param systemPrompt System prompt for context
|
||||||
* System prompt for context
|
* @param userMessage User message/question
|
||||||
* @param userMessage
|
|
||||||
* User message/question
|
|
||||||
* @return LLM response text, or null on error
|
* @return LLM response text, or null on error
|
||||||
*/
|
*/
|
||||||
public String chat(String systemPrompt, String userMessage) {
|
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
|
* @param systemPrompt System prompt for context
|
||||||
* System prompt for context
|
* @param userMessage User message/question
|
||||||
* @param userMessage
|
* @param temperature Temperature for response randomness (0.0-1.0)
|
||||||
* User message/question
|
* @param maxTokens Maximum tokens in response
|
||||||
* @param temperature
|
|
||||||
* Temperature for response randomness (0.0-1.0)
|
|
||||||
* @param maxTokens
|
|
||||||
* Maximum tokens in response
|
|
||||||
* @return LLM response text, or null on error
|
* @return LLM response text, or null on error
|
||||||
*/
|
*/
|
||||||
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> request = Map.of("model", model, "messages",
|
Map<String, Object> request = Map.of(
|
||||||
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
"model", model,
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
||||||
Map.of("role", "user", "content", userMessage)),
|
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();
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
|
String response = webClient.post()
|
||||||
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.timeout(Duration.ofSeconds(120))
|
||||||
|
.block();
|
||||||
|
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("LLM response received in {}ms", duration);
|
log.info("LLM response received in {}ms", duration);
|
||||||
@@ -81,7 +109,7 @@ public class LlmRestClient {
|
|||||||
return extractContent(response);
|
return extractContent(response);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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()) {
|
if (log.isDebugEnabled()) {
|
||||||
log.debug("Full stack trace:", e);
|
log.debug("Full stack trace:", e);
|
||||||
}
|
}
|
||||||
@@ -96,6 +124,14 @@ public class LlmRestClient {
|
|||||||
return chat(null, userMessage);
|
return chat(null, userMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
private String extractContent(String response) {
|
private String extractContent(String response) {
|
||||||
if (response == null || response.isBlank()) {
|
if (response == null || response.isBlank()) {
|
||||||
log.warn("LLM returned null or blank response");
|
log.warn("LLM returned null or blank response");
|
||||||
@@ -106,7 +142,6 @@ public class LlmRestClient {
|
|||||||
JsonNode choices = root.path("choices");
|
JsonNode choices = root.path("choices");
|
||||||
if (choices.isArray() && !choices.isEmpty()) {
|
if (choices.isArray() && !choices.isEmpty()) {
|
||||||
String content = choices.get(0).path("message").path("content").asText();
|
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()) {
|
if (content == null || content.isBlank()) {
|
||||||
log.warn("LLM response content is empty");
|
log.warn("LLM response content is empty");
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -73,17 +73,26 @@ app.version=@project.version@
|
|||||||
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
|
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.base-url=https://lmstudio.appcreation.de
|
||||||
spring.ai.openai.api-key=not-used
|
spring.ai.openai.api-key=not-used
|
||||||
spring.ai.openai.chat.options.model=local-model
|
spring.ai.openai.chat.options.model=local-model
|
||||||
spring.ai.openai.chat.options.temperature=0.7
|
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
|
spring.ai.openai.chat.options.stream=false
|
||||||
|
|
||||||
# Timeouts für OpenAI Client
|
|
||||||
spring.ai.openai.connect-timeout=10s
|
spring.ai.openai.connect-timeout=10s
|
||||||
spring.ai.openai.read-timeout=120s
|
spring.ai.openai.read-timeout=120s
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user