1
0

V1.1 Legacy-API-Key-Fallback und Base-URL-Validierung korrigiert

This commit is contained in:
2026-04-09 06:29:42 +02:00
parent 5099ff4aca
commit 8fd9e350e5
4 changed files with 210 additions and 7 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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.
* <p>
* 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.
* <p>
* Resolution order:
* <ol>
* <li>{@code OPENAI_COMPATIBLE_API_KEY} environment variable</li>
* <li>{@code PDF_UMBENENNER_API_KEY} environment variable (legacy fallback;
* accepted for backward compatibility with existing installations)</li>
* <li>{@code ai.provider.openai-compatible.apiKey} property</li>
* </ol>
*
* @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.
* <p>

View File

@@ -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;
* <li>{@code ai.provider.active} refers to a recognised provider family.</li>
* <li>{@code model} is non-blank.</li>
* <li>{@code timeoutSeconds} is a positive integer.</li>
* <li>{@code baseUrl} is non-blank (required for the OpenAI-compatible family;
* the Claude family always has a default).</li>
* <li>{@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).</li>
* <li>{@code apiKey} is non-blank after environment-variable precedence has been applied
* by {@link MultiProviderConfigurationParser}.</li>
* </ul>
@@ -83,16 +85,40 @@ public class MultiProviderConfigurationValidator {
}
/**
* Validates base URL presence.
* Validates the base URL of the active provider.
* <p>
* The URL must be:
* <ul>
* <li>non-blank</li>
* <li>a syntactically valid URI</li>
* <li>an absolute URI (has a scheme component)</li>
* <li>using scheme {@code http} or {@code https}</li>
* </ul>
* 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<String> 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() + ")");
}
}

View File

@@ -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<String, String> 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<String, String> 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
// =========================================================================