LlmProviderConfig.java

package com.taxonomy.analysis.service;

import com.taxonomy.dto.AiAvailabilityLevel;
import com.taxonomy.shared.service.LocalEmbeddingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;

/** Centralises LLM provider selection, credentials, endpoints and readiness diagnostics. */
@Component
public class LlmProviderConfig {

    private static final Logger log = LoggerFactory.getLogger(LlmProviderConfig.class);

    static final String GEMINI_URL =
            "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=";

    static final String OPENAI_URL = "https://api.openai.com/v1/chat/completions";
    static final String DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions";
    static final String QWEN_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
    static final String LLAMA_URL = "https://api.llama-api.com/chat/completions";
    static final String MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions";

    /** Internal marker for an intentionally unauthenticated custom endpoint. Never sent as a header. */
    static final String CUSTOM_NO_AUTH_API_KEY = "__taxonomy_custom_no_auth__";

    static final String OPENAI_MODEL = "gpt-4o-mini";
    static final String DEEPSEEK_MODEL = "deepseek-chat";
    static final String QWEN_MODEL = "qwen-plus";
    static final String LLAMA_MODEL = "llama3.1-70b";
    static final String MISTRAL_MODEL = "mistral-small-latest";

    private static final ThreadLocal<LlmProvider> requestProviderOverride = new ThreadLocal<>();

    @Value("${llm.provider:}")
    private String llmProviderConfig;

    @Value("${llm.mock:false}")
    private boolean llmMock;

    @Value("${gemini.api.key:}")
    private String geminiApiKey;

    @Value("${openai.api.key:}")
    private String openaiApiKey;

    @Value("${deepseek.api.key:}")
    private String deepseekApiKey;

    @Value("${qwen.api.key:}")
    private String qwenApiKey;

    @Value("${llama.api.key:}")
    private String llamaApiKey;

    @Value("${mistral.api.key:}")
    private String mistralApiKey;

    @Value("${custom.llm.url:}")
    private String customLlmUrl;

    @Value("${custom.llm.model:}")
    private String customLlmModel;

    @Value("${custom.llm.api.key:}")
    private String customLlmApiKey;

    private final LocalEmbeddingService localEmbeddingService;

    public LlmProviderConfig(LocalEmbeddingService localEmbeddingService) {
        this.localEmbeddingService = localEmbeddingService;
    }

    /** Structured, operator-facing validation result for {@code CUSTOM_OPENAI}. */
    public record CustomOpenAiConfigurationStatus(boolean valid, String code, String message) {
        static CustomOpenAiConfigurationStatus ready() {
            return new CustomOpenAiConfigurationStatus(true, "READY", "CUSTOM_OPENAI is configured.");
        }

        static CustomOpenAiConfigurationStatus invalid(String code, String message) {
            return new CustomOpenAiConfigurationStatus(false, code, message);
        }
    }

    public void setRequestProvider(LlmProvider provider) {
        requestProviderOverride.set(provider);
    }

    public void clearRequestProvider() {
        requestProviderOverride.remove();
    }

    public LlmProvider getActiveProvider() {
        LlmProvider override = requestProviderOverride.get();
        if (override != null) return override;

        if (llmProviderConfig != null && !llmProviderConfig.isBlank()) {
            try {
                return LlmProvider.valueOf(llmProviderConfig.trim().toUpperCase());
            } catch (IllegalArgumentException exception) {
                log.warn("Unknown LLM provider '{}' in config; falling back to auto-detect",
                        llmProviderConfig);
            }
        }

        if (hasText(geminiApiKey)) return LlmProvider.GEMINI;
        if (hasText(openaiApiKey)) return LlmProvider.OPENAI;
        if (hasText(deepseekApiKey)) return LlmProvider.DEEPSEEK;
        if (hasText(qwenApiKey)) return LlmProvider.QWEN;
        if (hasText(llamaApiKey)) return LlmProvider.LLAMA;
        if (hasText(mistralApiKey)) return LlmProvider.MISTRAL;
        if (isCustomOpenAiConfigured()) return LlmProvider.CUSTOM_OPENAI;
        return LlmProvider.GEMINI;
    }

    public String getActiveProviderName() {
        if (llmMock) return "Mock";
        return switch (getActiveProvider()) {
            case GEMINI -> "Gemini";
            case OPENAI -> "OpenAI";
            case DEEPSEEK -> "DeepSeek";
            case QWEN -> "Qwen";
            case LLAMA -> "Llama";
            case MISTRAL -> "Mistral";
            case CUSTOM_OPENAI -> "Custom OpenAI-compatible";
            case LOCAL_ONNX -> "Local (bge-small-en-v1.5)";
        };
    }

    public List<String> getAvailableProviders() {
        List<String> providers = new ArrayList<>();
        providers.add("LOCAL_ONNX");
        if (hasText(geminiApiKey)) providers.add("GEMINI");
        if (hasText(openaiApiKey)) providers.add("OPENAI");
        if (hasText(deepseekApiKey)) providers.add("DEEPSEEK");
        if (hasText(qwenApiKey)) providers.add("QWEN");
        if (hasText(llamaApiKey)) providers.add("LLAMA");
        if (hasText(mistralApiKey)) providers.add("MISTRAL");
        if (isCustomOpenAiConfigured()) providers.add("CUSTOM_OPENAI");
        return providers;
    }

    /**
     * Returns the transport credential. CUSTOM_OPENAI always returns either the real optional
     * bearer token or an internal no-auth marker, even when URL/model validation fails. This keeps
     * provider-agnostic callers from misdiagnosing an invalid endpoint as a missing API key.
     */
    public String getApiKey(LlmProvider provider) {
        return switch (provider) {
            case GEMINI -> geminiApiKey;
            case OPENAI -> openaiApiKey;
            case DEEPSEEK -> deepseekApiKey;
            case QWEN -> qwenApiKey;
            case LLAMA -> llamaApiKey;
            case MISTRAL -> mistralApiKey;
            case CUSTOM_OPENAI -> customOpenAiTransportApiKey();
            case LOCAL_ONNX -> null;
        };
    }

    public boolean hasConfiguredApiKey(LlmProvider provider) {
        return switch (provider) {
            case GEMINI -> hasText(geminiApiKey);
            case OPENAI -> hasText(openaiApiKey);
            case DEEPSEEK -> hasText(deepseekApiKey);
            case QWEN -> hasText(qwenApiKey);
            case LLAMA -> hasText(llamaApiKey);
            case MISTRAL -> hasText(mistralApiKey);
            case CUSTOM_OPENAI -> hasText(customLlmApiKey);
            case LOCAL_ONNX -> false;
        };
    }

    public String getOpenAiCompatibleUrl(LlmProvider provider) {
        return switch (provider) {
            case OPENAI -> OPENAI_URL;
            case DEEPSEEK -> DEEPSEEK_URL;
            case QWEN -> QWEN_URL;
            case LLAMA -> LLAMA_URL;
            case MISTRAL -> MISTRAL_URL;
            case CUSTOM_OPENAI -> trimToEmpty(customLlmUrl);
            default -> throw new IllegalArgumentException("Not an OpenAI-compatible provider: " + provider);
        };
    }

    public String getOpenAiCompatibleModel(LlmProvider provider) {
        return switch (provider) {
            case OPENAI -> OPENAI_MODEL;
            case DEEPSEEK -> DEEPSEEK_MODEL;
            case QWEN -> QWEN_MODEL;
            case LLAMA -> LLAMA_MODEL;
            case MISTRAL -> MISTRAL_MODEL;
            case CUSTOM_OPENAI -> trimToEmpty(customLlmModel);
            default -> throw new IllegalArgumentException("Unknown provider: " + provider);
        };
    }

    public String getGeminiUrl() {
        return GEMINI_URL;
    }

    public CustomOpenAiConfigurationStatus getCustomOpenAiConfigurationStatus() {
        return validateCustomOpenAiConfiguration(customLlmUrl, customLlmModel);
    }

    /** Shared validation used by readiness diagnostics and the HTTP gateway. */
    static CustomOpenAiConfigurationStatus validateCustomOpenAiConfiguration(
            String configuredUrl, String configuredModel) {
        if (!hasText(configuredUrl)) {
            return CustomOpenAiConfigurationStatus.invalid("MISSING_URL",
                    "CUSTOM_LLM_URL is required when LLM_PROVIDER=CUSTOM_OPENAI.");
        }
        if (!hasText(configuredModel)) {
            return CustomOpenAiConfigurationStatus.invalid("MISSING_MODEL",
                    "CUSTOM_LLM_MODEL is required when LLM_PROVIDER=CUSTOM_OPENAI.");
        }

        URI uri;
        try {
            uri = URI.create(configuredUrl.trim());
        } catch (IllegalArgumentException exception) {
            return CustomOpenAiConfigurationStatus.invalid("INVALID_URL",
                    "CUSTOM_LLM_URL is not a valid URI.");
        }

        String scheme = uri.getScheme();
        if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
            return CustomOpenAiConfigurationStatus.invalid("UNSUPPORTED_SCHEME",
                    "CUSTOM_LLM_URL must use http or https.");
        }
        if (uri.getHost() == null) {
            return CustomOpenAiConfigurationStatus.invalid("MISSING_HOST",
                    "CUSTOM_LLM_URL must contain a host name.");
        }
        if (uri.getUserInfo() != null) {
            return CustomOpenAiConfigurationStatus.invalid("USER_INFO_NOT_ALLOWED",
                    "CUSTOM_LLM_URL must not contain credentials; use optional CUSTOM_LLM_API_KEY instead.");
        }
        String path = uri.getPath();
        if (path == null || !path.endsWith("/chat/completions")) {
            return CustomOpenAiConfigurationStatus.invalid("INVALID_PATH",
                    "CUSTOM_LLM_URL must end with /chat/completions.");
        }
        return CustomOpenAiConfigurationStatus.ready();
    }

    public boolean isCustomOpenAiConfigured() {
        return getCustomOpenAiConfigurationStatus().valid();
    }

    public boolean isProviderConfigured(LlmProvider provider) {
        return switch (provider) {
            case GEMINI -> hasText(geminiApiKey);
            case OPENAI -> hasText(openaiApiKey);
            case DEEPSEEK -> hasText(deepseekApiKey);
            case QWEN -> hasText(qwenApiKey);
            case LLAMA -> hasText(llamaApiKey);
            case MISTRAL -> hasText(mistralApiKey);
            case CUSTOM_OPENAI -> isCustomOpenAiConfigured();
            case LOCAL_ONNX -> localEmbeddingService.isAvailable();
        };
    }

    /** Returns a precise operator-facing reason when the selected provider is not ready. */
    public String getProviderConfigurationError(LlmProvider provider) {
        return switch (provider) {
            case GEMINI -> hasText(geminiApiKey) ? null : "GEMINI_API_KEY is required.";
            case OPENAI -> hasText(openaiApiKey) ? null : "OPENAI_API_KEY is required.";
            case DEEPSEEK -> hasText(deepseekApiKey) ? null : "DEEPSEEK_API_KEY is required.";
            case QWEN -> hasText(qwenApiKey) ? null : "DASHSCOPE_API_KEY is required.";
            case LLAMA -> hasText(llamaApiKey) ? null : "LLAMA_API_KEY is required.";
            case MISTRAL -> hasText(mistralApiKey) ? null : "MISTRAL_API_KEY is required.";
            case CUSTOM_OPENAI -> {
                CustomOpenAiConfigurationStatus status = getCustomOpenAiConfigurationStatus();
                yield status.valid() ? null : status.message();
            }
            case LOCAL_ONNX -> localEmbeddingService.isAvailable()
                    ? null
                    : "Local semantic embedding is disabled or unavailable. Set "
                    + "TAXONOMY_EMBEDDING_ENABLED=true and provide a local model or explicitly "
                    + "allow a runtime download.";
        };
    }

    public boolean hasAnyCloudApiKey() {
        return hasText(geminiApiKey)
                || hasText(openaiApiKey)
                || hasText(deepseekApiKey)
                || hasText(qwenApiKey)
                || hasText(llamaApiKey)
                || hasText(mistralApiKey)
                || hasText(customLlmApiKey);
    }

    public boolean hasAnyHttpProviderConfiguration() {
        return hasText(geminiApiKey)
                || hasText(openaiApiKey)
                || hasText(deepseekApiKey)
                || hasText(qwenApiKey)
                || hasText(llamaApiKey)
                || hasText(mistralApiKey)
                || isCustomOpenAiConfigured();
    }

    public AiAvailabilityLevel getAvailabilityLevel() {
        if (llmMock) return AiAvailabilityLevel.FULL;
        LlmProvider provider = getActiveProvider();
        if (provider == LlmProvider.LOCAL_ONNX) {
            return localEmbeddingService.isAvailable()
                    ? AiAvailabilityLevel.LIMITED
                    : AiAvailabilityLevel.UNAVAILABLE;
        }
        if (isProviderConfigured(provider)) return AiAvailabilityLevel.FULL;
        return localEmbeddingService.isAvailable()
                ? AiAvailabilityLevel.LIMITED
                : AiAvailabilityLevel.UNAVAILABLE;
    }

    public boolean isAvailable() {
        return getAvailabilityLevel() != AiAvailabilityLevel.UNAVAILABLE;
    }

    public boolean isMockMode() {
        return llmMock;
    }

    private String customOpenAiTransportApiKey() {
        return hasText(customLlmApiKey) ? customLlmApiKey : CUSTOM_NO_AUTH_API_KEY;
    }

    private static boolean hasText(String value) {
        return value != null && !value.isBlank();
    }

    private static String trimToEmpty(String value) {
        return value == null ? "" : value.trim();
    }
}