Erweiterungen
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user