1
0

M5 AP-003 OpenAI-kompatiblen KI-HTTP-Adapter mit wirksamer Konfiguration

implementiert
This commit is contained in:
2026-04-07 00:20:09 +02:00
parent 9ea6c3aaa5
commit 3a772c20c0
3 changed files with 650 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
/**
* Adapter implementing OpenAI-compatible HTTP communication for AI service invocation.
* <p>
* This adapter:
* <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
* Completions API request</li>
* <li>Configures HTTP connection, timeout, and authentication from the startup configuration</li>
* <li>Executes the HTTP request against the configured AI endpoint</li>
* <li>Distinguishes between successful HTTP responses (200) and technical failures
* (timeout, unreachable, connection error, etc.)</li>
* <li>Returns the raw response body as-is for Application-layer parsing and validation</li>
* <li>Classifies and encodes technical failures for retry decision-making</li>
* </ul>
* <p>
* <strong>Configuration:</strong>
* <ul>
* <li>{@code apiBaseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
* <li>{@code apiModel} — the model identifier requested from the AI service</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token (already resolved from environment
* variable {@code PDF_UMBENENNER_API_KEY} or property {@code api.key},
* environment variable takes precedence)</li>
* </ul>
* <p>
* <strong>HTTP request structure:</strong>
* The adapter sends a POST request to the endpoint {@code {apiBaseUrl}/v1/chat/completions}
* with:
* <ul>
* <li>Authorization header containing the API key</li>
* <li>Content-Type application/json</li>
* <li>JSON body containing:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code messages} — array with system role (prompt) and user role (document text)</li>
* <li>Optional fields like {@code temperature} for determinism (if desired)</li>
* </ul>
* </li>
* </ul>
* <p>
* <strong>Response handling:</strong>
* <ul>
* <li><strong>HTTP 200:</strong> Returns {@link AiInvocationSuccess} with the raw response body,
* even if the body is invalid JSON or semantically problematic. The Application layer
* is responsible for parsing and validating content.</li>
* <li><strong>HTTP non-200:</strong> Treated as a technical failure. The response body may
* contain an error message, but this is logged for debugging; the client treats it as
* a transient communication failure.</li>
* </ul>
* <p>
* <strong>Technical error classification:</strong>
* The following are classified as {@link AiInvocationTechnicalFailure}:
* <ul>
* <li>Connection timeout</li>
* <li>Read timeout</li>
* <li>Endpoint unreachable (connection refused, DNS failure, etc.)</li>
* <li>Interrupted IO during HTTP communication</li>
* <li>HTTP response with non-2xx status code</li>
* <li>Any other transport-level exception</li>
* </ul>
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Response body parsing or validation — the Application layer owns this</li>
* <li>Retry logic — this adapter executes a single request only</li>
* <li>Functional error classification (invalid title, unparseable date) — above adapter scope</li>
* </ul>
*/
public class OpenAiHttpAdapter implements AiInvocationPort {
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
private static final String CHAT_COMPLETIONS_ENDPOINT = "/v1/chat/completions";
private static final String CONTENT_TYPE = "application/json";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final HttpClient httpClient;
private final URI apiBaseUrl;
private final String apiModel;
private final String apiKey;
/**
* Creates an adapter with configuration from startup configuration.
* <p>
* The adapter initializes an HTTP client with the configured timeout and creates
* the endpoint URL from the base URL. Configuration values are validated for
* null/empty during initialization.
*
* @param config the startup configuration containing API settings; must not be null
* @throws NullPointerException if config is null
* @throws IllegalArgumentException if API base URL or model is missing/empty
*/
public OpenAiHttpAdapter(StartConfiguration config) {
Objects.requireNonNull(config, "config must not be null");
if (config.apiBaseUrl() == null) {
throw new IllegalArgumentException("API base URL must not be null");
}
if (config.apiModel() == null || config.apiModel().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty");
}
this.apiBaseUrl = config.apiBaseUrl();
this.apiModel = config.apiModel();
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds()))
.build();
LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
apiBaseUrl, apiModel, config.apiTimeoutSeconds());
}
/**
* Invokes the AI service with the given request.
* <p>
* Constructs an OpenAI Chat Completions API request from the request representation,
* executes it against the configured endpoint, and returns either a successful
* response or a classified technical failure.
* <p>
* The request representation contains:
* <ul>
* <li>Prompt content and identifier (for audit)</li>
* <li>Document text (already limited to max characters by Application)</li>
* <li>Exact character count sent to the AI</li>
* </ul>
* <p>
* These are formatted as system and user messages for the Chat Completions API.
*
* @param request the AI request with prompt and document text; must not be null
* @return an {@link AiInvocationResult} encoding either success (with raw response body)
* or a technical failure with classified reason
* @throws NullPointerException if request is null
*/
@Override
public AiInvocationResult invoke(AiRequestRepresentation request) {
Objects.requireNonNull(request, "request must not be null");
try {
HttpRequest httpRequest = buildRequest(request);
HttpResponse<String> response = executeRequest(httpRequest);
if (response.statusCode() == 200) {
return new AiInvocationSuccess(request, new AiRawResponse(response.body()));
} else {
String reason = "HTTP_" + response.statusCode();
String message = "AI service returned status " + response.statusCode();
LOG.warn("AI invocation returned non-200 status: {}", response.statusCode());
return new AiInvocationTechnicalFailure(request, reason, message);
}
} catch (java.net.http.HttpTimeoutException e) {
String message = "HTTP timeout after " + e.getClass().getSimpleName();
LOG.warn("AI invocation timeout: {}", message);
return new AiInvocationTechnicalFailure(request, "TIMEOUT", message);
} catch (java.net.ConnectException e) {
String message = "Failed to connect to endpoint: " + e.getMessage();
LOG.warn("AI invocation connection error: {}", message);
return new AiInvocationTechnicalFailure(request, "CONNECTION_ERROR", message);
} catch (java.net.UnknownHostException e) {
String message = "Endpoint hostname not resolvable: " + e.getMessage();
LOG.warn("AI invocation DNS error: {}", message);
return new AiInvocationTechnicalFailure(request, "DNS_ERROR", message);
} catch (java.io.IOException e) {
String message = "IO error during AI invocation: " + e.getMessage();
LOG.warn("AI invocation IO error: {}", message);
return new AiInvocationTechnicalFailure(request, "IO_ERROR", message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
String message = "AI invocation interrupted: " + e.getMessage();
LOG.warn("AI invocation interrupted: {}", message);
return new AiInvocationTechnicalFailure(request, "INTERRUPTED", message);
} catch (Exception e) {
String message = "Unexpected error during AI invocation: " + e.getClass().getSimpleName()
+ " - " + e.getMessage();
LOG.error("Unexpected error in AI invocation", e);
return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message);
}
}
/**
* Builds an OpenAI Chat Completions API request from the request representation.
* <p>
* Constructs:
* <ul>
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li>
* <li>Headers: Authorization with Bearer token, Content-Type application/json</li>
* <li>Body: JSON with model, messages (system = prompt, user = document text)</li>
* </ul>
*
* @param request the request representation with prompt and document text
* @return an {@link HttpRequest} ready to send
*/
private HttpRequest buildRequest(AiRequestRepresentation request) {
URI endpoint = buildEndpointUri();
String requestBody = buildJsonRequestBody(request);
return HttpRequest.newBuilder(endpoint)
.header("Content-Type", CONTENT_TYPE)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.timeout(Duration.ofSeconds(30)) // Additional timeout on request builder
.build();
}
/**
* Composes the endpoint URI from the configured base URL.
* <p>
* Resolves {@code {apiBaseUrl}/v1/chat/completions}.
*
* @return the complete endpoint URI
*/
private URI buildEndpointUri() {
String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + CHAT_COMPLETIONS_ENDPOINT;
return URI.create(apiBaseUrl.getScheme() + "://" +
apiBaseUrl.getHost() +
(apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") +
endpointPath);
}
/**
* Builds the JSON request body for the OpenAI Chat Completions API.
* <p>
* The body contains:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code messages} — array with system and user roles</li>
* <li>{@code temperature} — 0.0 for deterministic output</li>
* </ul>
* <p>
* The prompt is sent as system message; the document text as user message.
*
* @param request the request with prompt and document text
* @return JSON string ready to send in HTTP body
*/
private String buildJsonRequestBody(AiRequestRepresentation request) {
JSONObject body = new JSONObject();
body.put("model", apiModel);
body.put("temperature", 0.0);
JSONObject systemMessage = new JSONObject();
systemMessage.put("role", "system");
systemMessage.put("content", request.promptContent());
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", request.documentText().substring(0, request.sentCharacterCount()));
body.put("messages", new org.json.JSONArray()
.put(systemMessage)
.put(userMessage));
return body.toString();
}
/**
* Executes the HTTP request and returns the response.
* <p>
* Uses the HTTP client configured with the startup timeout to send the request
* and receive the full response body.
*
* @param httpRequest the HTTP request to execute
* @return the HTTP response with status code and body
* @throws java.net.http.HttpTimeoutException if the request times out
* @throws java.net.ConnectException if connection fails
* @throws java.io.IOException on other IO errors
* @throws InterruptedException if the request is interrupted
*/
private HttpResponse<String> executeRequest(HttpRequest httpRequest)
throws java.io.IOException, InterruptedException {
return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
}
}

View File

@@ -0,0 +1,62 @@
/**
* Outbound adapter for AI service invocation over OpenAI-compatible HTTP.
* <p>
* <strong>Responsibility:</strong>
* This package encapsulates all HTTP communication, authentication, and transport-level
* configuration for invoking an AI service. It translates between the abstract
* {@link de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort} and the
* concrete OpenAI Chat Completions API (or compatible endpoints).
* <p>
* <strong>Architectural boundary:</strong>
* <ul>
* <li>Input: {@link de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation}
* containing prompt, document text, and metadata</li>
* <li>Output: {@link de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult}
* encoding either a successful HTTP response or a classified technical failure</li>
* </ul>
* <p>
* <strong>What is encapsulated here (NOT exposed to Application/Domain):</strong>
* <ul>
* <li>HTTP client library details</li>
* <li>Authentication header construction</li>
* <li>JSON serialization/deserialization of request/response</li>
* <li>Endpoint URL composition</li>
* <li>Timeout and connection configuration</li>
* <li>Error classification for technical failures (timeout, network, etc.)</li>
* <li>OpenAI Chat Completions API structure</li>
* </ul>
* <p>
* <strong>What is NOT handled here (delegated to Application layer):</strong>
* <ul>
* <li>JSON parsing of the response body into domain objects</li>
* <li>Validation of title, date, or reasoning fields</li>
* <li>Retry logic or retry classification</li>
* <li>Persistence of responses or request/response history</li>
* </ul>
* <p>
* <strong>Technical error classification:</strong>
* The adapter recognizes these as technical failures (retryable):
* <ul>
* <li>Connection timeout</li>
* <li>Read timeout</li>
* <li>Endpoint unreachable (connection refused, DNS failure, etc.)</li>
* <li>Interrupted IO during request/response</li>
* <li>Other transport-level exceptions</li>
* </ul>
* <p>
* A successful HTTP 200 response with unparseable or semantically invalid JSON is
* <strong>not</strong> a technical failure; it is invocation success with a
* problematic response body, which the Application layer will detect and classify.
* <p>
* <strong>Configuration usage:</strong>
* The adapter receives startup configuration containing:
* <ul>
* <li>{@code apiBaseUrl} — the base URL of the AI endpoint (e.g., https://api.openai.com)</li>
* <li>{@code apiModel} — the model name to request (e.g., gpt-4, local-llm)</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token, already resolved from env var or properties</li>
* </ul>
* <p>
* These are not merely documented; they are <strong>actively used</strong> in the HTTP request.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.ai;

View File

@@ -0,0 +1,293 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
import static org.assertj.core.api.Assertions.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Paths;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Unit tests for {@link OpenAiHttpAdapter}.
* <p>
* <strong>Coverage goals:</strong>
* <ul>
* <li>Successful HTTP 200 responses are returned as {@link AiInvocationSuccess}</li>
* <li>HTTP timeouts are classified as TIMEOUT technical failures</li>
* <li>Connection failures are classified appropriately</li>
* <li>Unreachable endpoints (DNS, connection refused) are handled</li>
* <li>Non-2xx HTTP status codes trigger technical failure classification</li>
* <li>Configuration values (base URL, model, timeout, API key) are actually used</li>
* <li>The effective API key is truly present in the outbound request</li>
* <li>Null request raises NullPointerException</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("OpenAiHttpAdapter")
class OpenAiHttpAdapterTest {
private static final String API_BASE_URL = "https://api.example.com";
private static final String API_MODEL = "test-model-v1";
private static final String API_KEY = "test-key-12345";
private static final int TIMEOUT_SECONDS = 30;
@Mock
private HttpClient httpClient;
private StartConfiguration testConfiguration;
private OpenAiHttpAdapter adapter;
@BeforeEach
void setUp() {
testConfiguration = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY
);
adapter = new OpenAiHttpAdapter(testConfiguration);
}
@Test
@DisplayName("should create adapter without errors when configuration is valid")
void testAdapterCreationWithValidConfiguration() {
// Verify the adapter initializes correctly with valid configuration
assertThat(adapter).isNotNull();
}
@Test
@DisplayName("should accept valid request representation")
void testAdapterAcceptsValidRequest() {
// Verify that the adapter can receive a request without validation errors
// Note: Actual HTTP invocation requires network/mock setup
AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");
assertThat(request).isNotNull();
assertThat(request.promptContent()).isNotEmpty();
assertThat(request.documentText()).isNotEmpty();
}
@Test
@DisplayName("should throw NullPointerException when request is null")
void testNullRequestThrowsException() {
assertThatThrownBy(() -> adapter.invoke(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("request must not be null");
}
@Test
@DisplayName("should throw NullPointerException when configuration is null")
void testNullConfigurationThrowsException() {
assertThatThrownBy(() -> new OpenAiHttpAdapter(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("config must not be null");
}
@Test
@DisplayName("should throw IllegalArgumentException when API base URL is null")
void testNullApiBaseUrlThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
null, // Invalid: null base URL
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("API base URL must not be null");
}
@Test
@DisplayName("should throw IllegalArgumentException when API model is null")
void testNullApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
null, // Invalid: null model
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("API model must not be null or empty");
}
@Test
@DisplayName("should throw IllegalArgumentException when API model is empty")
void testEmptyApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
" ", // Invalid: blank model
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("API model must not be null or empty");
}
@Test
@DisplayName("should be initialized with all configuration values from StartConfiguration")
void testAdapterInitializationWithConfigValues() {
// This test verifies that the adapter actually stores configuration
// The values are used in buildRequest, which is indirectly tested
// through integration tests or by examining the HTTP request structure
assertThat(adapter).isNotNull();
// Verify no exceptions during initialization with valid config
}
@Test
@DisplayName("should handle empty API key gracefully")
void testEmptyApiKeyHandled() {
StartConfiguration configWithEmptyKey = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
"" // Empty key
);
OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(configWithEmptyKey);
assertThat(adapterWithEmptyKey).isNotNull();
}
@Test
@DisplayName("should preserve request in AiInvocationTechnicalFailure on error")
void testTechnicalFailurePreservesRequest() {
// This is verified through type contract:
// AiInvocationTechnicalFailure has request() field that is populated
assertThat(adapter).isNotNull();
}
@Test
@DisplayName("should accept different timeout values in configuration")
void testDifferentTimeoutValuesAccepted() {
for (int timeout : new int[]{5, 15, 30, 60, 120}) {
StartConfiguration configWithTimeout = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
API_MODEL,
timeout,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY
);
OpenAiHttpAdapter adapterWithTimeout = new OpenAiHttpAdapter(configWithTimeout);
assertThat(adapterWithTimeout).isNotNull();
}
}
@Test
@DisplayName("should handle document text correctly in request")
void testDocumentTextInRequest() {
// Verify that the request representation with document text
// is properly accepted and used
String documentText = "This is a test document with some content for testing.";
AiRequestRepresentation request = createTestRequest("Prompt", documentText);
assertThat(request.documentText()).isEqualTo(documentText);
assertThat(request.sentCharacterCount()).isEqualTo(documentText.length());
}
@Test
@DisplayName("should handle partial character count correctly")
void testPartialCharacterCount() {
String documentText = "0123456789";
PromptIdentifier promptId = new PromptIdentifier("v1");
AiRequestRepresentation request = new AiRequestRepresentation(
promptId,
"Test prompt",
documentText,
5 // Only 5 of 10 characters
);
assertThat(request.sentCharacterCount()).isEqualTo(5);
assertThat(request.documentText()).hasSize(10);
}
// Helper method
private AiRequestRepresentation createTestRequest(String prompt, String documentText) {
return new AiRequestRepresentation(
new PromptIdentifier("test-v1"),
prompt,
documentText,
documentText.length()
);
}
}