diff --git a/config/application-local.example.properties b/config/application-local.example.properties
index 93f817d..3389d12 100644
--- a/config/application-local.example.properties
+++ b/config/application-local.example.properties
@@ -67,7 +67,10 @@ ai.provider.openai-compatible.model=gpt-4o-mini
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
ai.provider.openai-compatible.timeoutSeconds=30
-# API-Schluessel. Die Umgebungsvariable OPENAI_COMPATIBLE_API_KEY hat Vorrang.
+# API-Schluessel.
+# Vorrangreihenfolge: OPENAI_COMPATIBLE_API_KEY (Umgebungsvariable) >
+# PDF_UMBENENNER_API_KEY (veraltete Umgebungsvariable, weiterhin akzeptiert) >
+# ai.provider.openai-compatible.apiKey (dieser Wert)
ai.provider.openai-compatible.apiKey=your-openai-api-key-here
# ---------------------------------------------------------------------------
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java
index 2ca104d..9854519 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java
@@ -64,6 +64,15 @@ public class MultiProviderConfigurationParser {
/** Environment variable for the OpenAI-compatible provider API key. */
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
+ /**
+ * Legacy environment variable for the OpenAI-compatible provider API key.
+ *
+ * Accepted as a fallback when {@code OPENAI_COMPATIBLE_API_KEY} is not set.
+ * Existing installations that set this variable continue to work without change.
+ * New installations should prefer {@code OPENAI_COMPATIBLE_API_KEY}.
+ */
+ static final String ENV_LEGACY_OPENAI_API_KEY = "PDF_UMBENENNER_API_KEY";
+
/** Environment variable for the Anthropic Claude provider API key. */
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
@@ -131,7 +140,7 @@ public class MultiProviderConfigurationParser {
String model = getOptionalString(props, PROP_OPENAI_MODEL);
int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL);
- String apiKey = resolveApiKey(props, PROP_OPENAI_API_KEY, ENV_OPENAI_API_KEY);
+ String apiKey = resolveOpenAiApiKey(props);
return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
}
@@ -179,6 +188,33 @@ public class MultiProviderConfigurationParser {
}
}
+ /**
+ * Resolves the effective API key for the OpenAI-compatible provider.
+ *
+ * Resolution order:
+ *
+ * - {@code OPENAI_COMPATIBLE_API_KEY} environment variable
+ * - {@code PDF_UMBENENNER_API_KEY} environment variable (legacy fallback;
+ * accepted for backward compatibility with existing installations)
+ * - {@code ai.provider.openai-compatible.apiKey} property
+ *
+ *
+ * @param props the configuration properties
+ * @return the resolved API key; never {@code null}, but may be blank
+ */
+ private String resolveOpenAiApiKey(Properties props) {
+ String primary = environmentLookup.apply(ENV_OPENAI_API_KEY);
+ if (primary != null && !primary.isBlank()) {
+ return primary.trim();
+ }
+ String legacy = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY);
+ if (legacy != null && !legacy.isBlank()) {
+ return legacy.trim();
+ }
+ String propsValue = props.getProperty(PROP_OPENAI_API_KEY);
+ return (propsValue != null) ? propsValue.trim() : "";
+ }
+
/**
* Resolves the effective API key for a provider family.
*
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java
index 6e499a4..7a30d97 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java
@@ -5,6 +5,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
+import java.net.URI;
import java.util.ArrayList;
import java.util.List;
@@ -16,8 +17,9 @@ import java.util.List;
*
{@code ai.provider.active} refers to a recognised provider family.
* {@code model} is non-blank.
* {@code timeoutSeconds} is a positive integer.
- * {@code baseUrl} is non-blank (required for the OpenAI-compatible family;
- * the Claude family always has a default).
+ * {@code baseUrl} is a syntactically valid absolute URI with scheme {@code http} or
+ * {@code https} (required for the OpenAI-compatible family; the Claude family always
+ * has a default, but it is validated with the same rules).
* {@code apiKey} is non-blank after environment-variable precedence has been applied
* by {@link MultiProviderConfigurationParser}.
*
@@ -83,16 +85,40 @@ public class MultiProviderConfigurationValidator {
}
/**
- * Validates base URL presence.
+ * Validates the base URL of the active provider.
*
+ * The URL must be:
+ *
+ * - non-blank
+ * - a syntactically valid URI
+ * - an absolute URI (has a scheme component)
+ * - using scheme {@code http} or {@code https}
+ *
* The OpenAI-compatible family requires an explicit base URL.
* The Claude family always has a default ({@code https://api.anthropic.com}) applied by the
- * parser, so this check is a safety net rather than a primary enforcement mechanism.
+ * parser, so this check serves both as a primary and safety-net enforcement.
*/
private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config,
String providerLabel, List errors) {
- if (config.baseUrl() == null || config.baseUrl().isBlank()) {
+ String baseUrl = config.baseUrl();
+ if (baseUrl == null || baseUrl.isBlank()) {
errors.add("- " + providerLabel + ".baseUrl: must not be blank");
+ return;
+ }
+ try {
+ URI uri = URI.create(baseUrl);
+ if (!uri.isAbsolute()) {
+ errors.add("- " + providerLabel + ".baseUrl: must be an absolute URI with http or https scheme, got: '"
+ + baseUrl + "'");
+ return;
+ }
+ String scheme = uri.getScheme();
+ if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
+ errors.add("- " + providerLabel + ".baseUrl: scheme must be http or https, got: '"
+ + scheme + "' in '" + baseUrl + "'");
+ }
+ } catch (IllegalArgumentException e) {
+ errors.add("- " + providerLabel + ".baseUrl: not a valid URI: '" + baseUrl + "' (" + e.getMessage() + ")");
}
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
index 4d7a5e3..650ffce 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
@@ -245,6 +245,144 @@ class MultiProviderConfigurationTest {
"Env var must override properties API key for Claude");
}
+ // =========================================================================
+ // Test: legacy env var PDF_UMBENENNER_API_KEY
+ // =========================================================================
+
+ /**
+ * {@code PDF_UMBENENNER_API_KEY} is set, {@code OPENAI_COMPATIBLE_API_KEY} is absent.
+ * The legacy variable must be accepted as a fallback for the OpenAI-compatible provider.
+ */
+ @Test
+ void legacyEnvVarPdfUmbenennerApiKeyUsedWhenPrimaryAbsent() {
+ Properties props = fullOpenAiProperties();
+ props.remove("ai.provider.openai-compatible.apiKey");
+
+ Function envWithLegacy = key ->
+ MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key)
+ ? "legacy-env-key" : null;
+
+ MultiProviderConfiguration config = parseAndValidate(props, envWithLegacy);
+ assertEquals("legacy-env-key", config.openAiCompatibleConfig().apiKey(),
+ "Legacy env var PDF_UMBENENNER_API_KEY must be used when OPENAI_COMPATIBLE_API_KEY is absent");
+ }
+
+ /**
+ * {@code OPENAI_COMPATIBLE_API_KEY} takes precedence over {@code PDF_UMBENENNER_API_KEY}.
+ */
+ @Test
+ void primaryEnvVarTakesPrecedenceOverLegacyEnvVar() {
+ Properties props = fullOpenAiProperties();
+ props.remove("ai.provider.openai-compatible.apiKey");
+
+ Function envBoth = key -> {
+ if (MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)) return "primary-key";
+ if (MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key)) return "legacy-key";
+ return null;
+ };
+
+ MultiProviderConfiguration config = parseAndValidate(props, envBoth);
+ assertEquals("primary-key", config.openAiCompatibleConfig().apiKey(),
+ "OPENAI_COMPATIBLE_API_KEY must take precedence over PDF_UMBENENNER_API_KEY");
+ }
+
+ /**
+ * Neither env var is set; the properties value is used as final fallback.
+ */
+ @Test
+ void propertiesApiKeyUsedWhenNoEnvVarSet() {
+ Properties props = fullOpenAiProperties();
+ props.setProperty("ai.provider.openai-compatible.apiKey", "props-only-key");
+
+ MultiProviderConfiguration config = parseAndValidate(props, NO_ENV);
+ assertEquals("props-only-key", config.openAiCompatibleConfig().apiKey(),
+ "Properties API key must be used when no env var is set");
+ }
+
+ // =========================================================================
+ // Tests: base URL validation
+ // =========================================================================
+
+ /**
+ * OpenAI-compatible provider with an invalid (non-URI) base URL must be rejected.
+ */
+ @Test
+ void rejectsInvalidBaseUrlForActiveOpenAiProvider() {
+ Properties props = fullOpenAiProperties();
+ props.setProperty("ai.provider.openai-compatible.baseUrl", "not a valid url at all ://");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("baseUrl"),
+ "Error message must reference baseUrl");
+ }
+
+ /**
+ * Claude provider with an invalid base URL must be rejected when Claude is active.
+ */
+ @Test
+ void rejectsInvalidBaseUrlForActiveClaudeProvider() {
+ Properties props = fullClaudeProperties();
+ props.setProperty("ai.provider.claude.baseUrl", "ftp://api.anthropic.com");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("baseUrl"),
+ "Error message must reference baseUrl");
+ assertTrue(ex.getMessage().contains("ftp"),
+ "Error message must mention the invalid scheme");
+ }
+
+ /**
+ * A relative URI (no scheme, no host) must be rejected.
+ */
+ @Test
+ void rejectsRelativeUriAsBaseUrl() {
+ Properties props = fullOpenAiProperties();
+ props.setProperty("ai.provider.openai-compatible.baseUrl", "/v1/chat");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("baseUrl"),
+ "Error message must reference baseUrl");
+ }
+
+ /**
+ * A non-http/https scheme (e.g. {@code ftp://}) must be rejected.
+ */
+ @Test
+ void rejectsNonHttpSchemeAsBaseUrl() {
+ Properties props = fullOpenAiProperties();
+ props.setProperty("ai.provider.openai-compatible.baseUrl", "ftp://api.example.com");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("baseUrl"),
+ "Error message must reference baseUrl");
+ assertTrue(ex.getMessage().contains("ftp"),
+ "Error message must mention the invalid scheme");
+ }
+
// =========================================================================
// Mandatory test case 9
// =========================================================================