V1.1 Legacy-API-Key-Fallback und Base-URL-Validierung korrigiert
This commit is contained in:
@@ -67,7 +67,10 @@ ai.provider.openai-compatible.model=gpt-4o-mini
|
|||||||
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||||
ai.provider.openai-compatible.timeoutSeconds=30
|
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
|
ai.provider.openai-compatible.apiKey=your-openai-api-key-here
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ public class MultiProviderConfigurationParser {
|
|||||||
/** Environment variable for the OpenAI-compatible provider API key. */
|
/** Environment variable for the OpenAI-compatible provider API key. */
|
||||||
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_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. */
|
/** Environment variable for the Anthropic Claude provider API key. */
|
||||||
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_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);
|
String model = getOptionalString(props, PROP_OPENAI_MODEL);
|
||||||
int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
|
int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
|
||||||
String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL);
|
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);
|
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.
|
* Resolves the effective API key for a provider family.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@@ -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.MultiProviderConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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 ai.provider.active} refers to a recognised provider family.</li>
|
||||||
* <li>{@code model} is non-blank.</li>
|
* <li>{@code model} is non-blank.</li>
|
||||||
* <li>{@code timeoutSeconds} is a positive integer.</li>
|
* <li>{@code timeoutSeconds} is a positive integer.</li>
|
||||||
* <li>{@code baseUrl} is non-blank (required for the OpenAI-compatible family;
|
* <li>{@code baseUrl} is a syntactically valid absolute URI with scheme {@code http} or
|
||||||
* the Claude family always has a default).</li>
|
* {@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
|
* <li>{@code apiKey} is non-blank after environment-variable precedence has been applied
|
||||||
* by {@link MultiProviderConfigurationParser}.</li>
|
* by {@link MultiProviderConfigurationParser}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -83,16 +85,40 @@ public class MultiProviderConfigurationValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates base URL presence.
|
* Validates the base URL of the active provider.
|
||||||
* <p>
|
* <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 OpenAI-compatible family requires an explicit base URL.
|
||||||
* The Claude family always has a default ({@code https://api.anthropic.com}) applied by the
|
* 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,
|
private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config,
|
||||||
String providerLabel, List<String> errors) {
|
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");
|
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() + ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,144 @@ class MultiProviderConfigurationTest {
|
|||||||
"Env var must override properties API key for Claude");
|
"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
|
// Mandatory test case 9
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user