001/*
002 *  Copyright 2025 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.plugins.ai.provider.impl;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.OutputStream;
022import java.net.URI;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.Base64;
028import java.util.List;
029
030import org.apache.avalon.framework.activity.Disposable;
031import org.apache.avalon.framework.activity.Initializable;
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.configuration.Configurable;
034import org.apache.avalon.framework.configuration.Configuration;
035import org.apache.avalon.framework.configuration.ConfigurationException;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.commons.io.IOUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.hc.client5.http.classic.methods.HttpGet;
041import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
042
043import org.ametys.core.util.HttpUtils;
044import org.ametys.plugins.ai.AIHelper;
045import org.ametys.plugins.ai.provider.AIProvider;
046import org.ametys.runtime.config.Config;
047import org.ametys.runtime.i18n.I18nizableText;
048import org.ametys.runtime.plugin.component.AbstractLogEnabled;
049import org.ametys.runtime.plugin.component.DeferredServiceable;
050import org.ametys.runtime.plugin.component.PluginAware;
051import org.ametys.runtime.util.AmetysHomeHelper;
052
053import dev.langchain4j.data.document.Document;
054import dev.langchain4j.data.document.DocumentSplitter;
055import dev.langchain4j.data.document.splitter.DocumentSplitters;
056import dev.langchain4j.data.embedding.Embedding;
057import dev.langchain4j.data.image.Image;
058import dev.langchain4j.data.message.SystemMessage;
059import dev.langchain4j.data.message.UserMessage;
060import dev.langchain4j.data.segment.TextSegment;
061import dev.langchain4j.exception.AuthenticationException;
062import dev.langchain4j.exception.RateLimitException;
063import dev.langchain4j.memory.ChatMemory;
064import dev.langchain4j.model.TokenCountEstimator;
065import dev.langchain4j.model.chat.ChatModel;
066import dev.langchain4j.model.chat.request.ChatRequest;
067import dev.langchain4j.model.chat.response.ChatResponse;
068import dev.langchain4j.model.embedding.EmbeddingModel;
069import dev.langchain4j.model.image.ImageModel;
070import dev.langchain4j.model.output.Response;
071
072/**
073 * This is an abstract class for AI providers.
074 */
075public abstract class AbstractAIProvider extends AbstractLogEnabled implements AIProvider, Configurable, Initializable, PluginAware, DeferredServiceable, Component, Disposable
076{
077    /** The configuration parameter for the API key  */
078    public static final String CONFIG_API_KEY = "ai.apikey";
079    
080    /** The configuration parameter for URL */
081    public static final String CONFIG_SERVER_URL = "ai.serverURL";
082    
083    /** The configuration parameter for text model */
084    public static final String CONFIG_TEXT_MODEL = "ai.model.text";
085    
086    /** The configuration parameter for image model */
087    public static final String CONFIG_IMAGE_MODEL = "ai.model.image";
088    
089    /** The configuration parameter for image generation timeout */
090    public static final String CONFIG_IMAGE_TIMEOUT = "ai.model.image.timeout";
091
092    /** The configuration parameter for embedding model */
093    public static final String CONFIG_EMBEDDING_MODEL = "ai.model.embedding";
094    
095    /** The provider id */
096    protected String _id;
097    /** The provider label */
098    protected I18nizableText _label;
099    /** The plugin name */
100    protected String _pluginName;
101    
102    /** The provider models existing for text generation */
103    protected List<String> _textModelNames;
104    /** The provider models existing for image generation */
105    protected List<String> _imageModelNames;
106    /** The provider models existing for embedding */
107    protected List<EmbeddingModelInfo> _embeddingModelInfos;
108    
109    /** Current text model */
110    protected ChatModel _textModel;
111    /** The context window for text generation */
112    protected int _textModelContextWindow;
113    /** Current image model */
114    protected ImageModel _imageModel;
115    /** The context window for image generation */
116    protected int _imageModelContextWindow;
117    /** Current embedding model */
118    protected EmbeddingModel _embeddingModel;
119    
120    /** The AI helper */
121    protected AIHelper _aiHelper;
122    
123    /** The HTTP client used to fetch images */
124    protected CloseableHttpClient _httpClient;
125
126    /**
127     * Info for an embedding model
128     * @param name The name of the model
129     * @param vectorDimension The vector dimension
130     */
131    public record EmbeddingModelInfo(String name, int vectorDimension) { }
132    
133
134    public void setPluginInfo(String pluginName, String featureName, String id)
135    {
136        _pluginName = pluginName;
137        _id = id;
138    }
139
140    public void configure(Configuration configuration) throws ConfigurationException
141    {
142        _label = I18nizableText.parseI18nizableText(configuration.getChild("label"), "plugin." + _pluginName, "");
143        
144        Configuration modelConfiguration = configuration.getChild("model");
145        
146        // Text
147        Configuration textConfiguration = modelConfiguration.getChild("text");
148        
149        _textModelNames = Arrays.stream(textConfiguration.getChildren("name"))
150            .map(c -> c.getValue(null))
151            .filter(StringUtils::isNotBlank)
152            .toList();
153        
154        int contextWindow = textConfiguration.getChild("contextWindow").getValueAsInteger(-1);
155        if (contextWindow == -1)
156        {
157            throw new ConfigurationException("Missing <contextWindow> for model <text>", modelConfiguration);
158        }
159        _textModelContextWindow = contextWindow;
160
161        // Image
162        Configuration imageConfiguration = modelConfiguration.getChild("image", false);
163        if (imageConfiguration != null)
164        {
165            _imageModelNames = Arrays.stream(imageConfiguration.getChildren("name"))
166                    .map(c -> c.getValue(null))
167                    .filter(StringUtils::isNotBlank)
168                    .toList();
169                
170            contextWindow = imageConfiguration.getChild("contextWindow").getValueAsInteger(-1);
171            if (contextWindow == -1)
172            {
173                throw new ConfigurationException("Missing <contextWindow> for model <image>", modelConfiguration);
174            }
175            
176            _imageModelContextWindow = contextWindow;
177        }
178        else
179        {
180            _imageModelNames = List.of();
181        }
182
183
184        // Embedding
185        Configuration embeddingConfiguration = modelConfiguration.getChild("embedding", false);
186        if (embeddingConfiguration != null)
187        {
188            _embeddingModelInfos = Arrays.stream(embeddingConfiguration.getChildren("name"))
189                    .map(c -> new EmbeddingModelInfo(c.getValue(null), Integer.parseInt(c.getAttribute("vectorDimension", ""))))
190                    .filter(v -> StringUtils.isNotBlank(v.name()))
191                    .toList();
192        }
193        else
194        {
195            _embeddingModelInfos = List.of();
196        }
197    }
198    
199    private synchronized void _initModels()
200    {
201        if (_textModel == null)
202        {
203            _textModel = createChatModel(Config.getInstance().getValue(CONFIG_TEXT_MODEL));
204            
205            if (_imageModelContextWindow != -1)
206            {
207                String imageModelName = Config.getInstance().getValue(CONFIG_IMAGE_MODEL);
208                if (StringUtils.isNotBlank(imageModelName))
209                {
210                    _imageModel = createImageModel(imageModelName);
211                }
212            }
213
214            if (_embeddingModelInfos.size() > 0)
215            {
216                String embeddingModelName = Config.getInstance().getValue(CONFIG_EMBEDDING_MODEL);
217                if (StringUtils.isNotBlank(embeddingModelName))
218                {
219                    _embeddingModel = createEmbeddingModel(embeddingModelName);
220                }
221            }
222        }
223    }
224    
225    /**
226     * Creates the text model implementation
227     * @param modelName The model name
228     * @return The chat model
229     */
230    protected abstract ChatModel createChatModel(String modelName);
231    
232    /**
233     * Creates the image model implementation
234     * @param modelName The model name
235     * @return The image model
236     */
237    protected ImageModel createImageModel(String modelName)
238    {
239        throw new UnsupportedOperationException(this.getClass().getName() + " does not support image generation");
240    }
241    
242    /**
243     * Creates the embedding model implementation
244     * @param modelName The model name
245     * @return The embedding model
246     */
247    protected abstract EmbeddingModel createEmbeddingModel(String modelName);
248
249    public void initialize() throws Exception
250    {
251        _httpClient = HttpUtils.createHttpClient(0, 2, false);
252    }
253    
254    public void dispose()
255    {
256        try
257        {
258            _httpClient.close();
259        }
260        catch (IOException e)
261        {
262            throw new RuntimeException(e);
263        }
264    }
265    
266    public void deferredService(ServiceManager manager) throws ServiceException
267    {
268        _aiHelper = (AIHelper) manager.lookup(AIHelper.ROLE);
269    }
270    
271    public String getId()
272    {
273        return _id;
274    }
275
276    public I18nizableText getLabel()
277    {
278        return _label;
279    }
280    
281    /**
282     * Get the list of known text models for this provider
283     * @return The list
284     */
285    public List<String> getKnownTextModels()
286    {
287        return _textModelNames;
288    }
289
290    /**
291     * Get the list of known image models for this provider
292     * @return The non null list
293     */
294    public List<String> getKnownImageModels()
295    {
296        return _imageModelNames;
297    }
298    
299    /**
300     * Get the list of known embedding models for this provider
301     * @return the non null list
302     */
303    public List<EmbeddingModelInfo> getKnownEmbeddingModels()
304    {
305        return _embeddingModelInfos;
306    }
307    
308    /**
309     * Creates a token count estimator
310     * @return The non null token count estimator
311     */
312    protected TokenCountEstimator _getTokenCountEstimator()
313    {
314        return new DefaultTokenCountEstimator();
315    }
316
317    public String textToSummary(String text, int maxLength) throws Exception
318    {
319        _initModels();
320        
321        TokenCountEstimator estimator = _getTokenCountEstimator();
322        
323        // Get the token number of the prompt without the input text, to not exceed context window.
324        int tokenForPrompt = estimator.estimateTokenCountInText(_aiHelper.getTextSummaryPrompt(maxLength));
325        int contextWindow = _textModelContextWindow;
326        int tokenMax = contextWindow - tokenForPrompt;
327        
328        List<String> summaries = new ArrayList<>();
329        DocumentSplitter splitter = DocumentSplitters.recursive(tokenMax , 5, estimator);
330        List<TextSegment> segments = splitter.split(Document.from(text));
331        for (TextSegment segment : segments)
332        {
333            boolean success = false;
334            int retries = 0;
335            while (!success && retries < 3)
336            {
337                try
338                {
339                    ChatResponse response = _textModel.chat(new SystemMessage(_aiHelper.getTextSummaryPrompt(maxLength)), new UserMessage(segment.text()));
340                    summaries.add(response.aiMessage().text());
341                    success = true;
342                }
343                catch (RateLimitException e)
344                {
345                    retries++;
346                    Thread.sleep(40000); // Waiting a little to low the token per minute
347                }
348            }
349            
350            if (!success)
351            {
352                getLogger().warn("An error occurred during summary generation of the segment. So we ignore it.");
353            }
354        }
355        
356        // Only if one summary exists, return the summary
357        if (summaries.size() == 1)
358        {
359            return summaries.get(0);
360        }
361        // Else, return a final summary of all summaries
362        String summary = String.join(" ", summaries);
363        String prompt = _aiHelper.getTextSummaryPrompt(maxLength);
364        
365        ChatResponse response = _textModel.chat(SystemMessage.from(prompt), UserMessage.from(summary));
366        return response.aiMessage().text();
367    }
368    
369    public boolean isImageGenerationSupported()
370    {
371        _initModels();
372        
373        return _imageModel != null;
374    }
375    
376    public Path textToImage(String text) throws Exception
377    {
378        _initModels();
379        
380        TokenCountEstimator estimator = new DefaultTokenCountEstimator();
381        
382        // Get the token number of the prompt without the input text, to not exceed context window.
383        int tokenForPrompt = estimator.estimateTokenCountInText(_aiHelper.getImageGenerationPrompt());
384        int contextWindow = _imageModelContextWindow;
385        int tokenMax = contextWindow - tokenForPrompt;
386        
387        DocumentSplitter splitter = DocumentSplitters.recursive(tokenMax, 0, estimator);
388        List<TextSegment> segments = splitter.split(Document.from(text));
389
390        if (segments.isEmpty())
391        {
392            throw new IllegalArgumentException("An error occurred generating the image. Can't build a segment for the given text " + text);
393        }
394        String res = segments.get(0).text();
395
396        String prompt = _aiHelper.getImageGenerationPrompt();
397        
398        Response<Image> response = _imageModel.generate(prompt + " Here is the user text: " + res);
399        Image image = response.content();
400        
401        if (image.base64Data() != null)
402        {
403            String tmpFolderName = Long.toString(Math.round(Math.random() * 1000000.0));
404            String fileName = "image.png";
405            
406            Path targetFile = AmetysHomeHelper.getAmetysHomeTmp().toPath().resolve(tmpFolderName).resolve(fileName);
407            Files.createDirectories(targetFile.getParent());
408
409            byte[] imageBytes = Base64.getDecoder().decode(image.base64Data());
410            Files.write(targetFile, imageBytes);
411            return targetFile;
412        }
413        else
414        {
415            return _download(image.url());
416        }
417    }
418    
419    /**
420     * Helper to download the given url to a temporary file
421     * @param uri The uri to download
422     * @return The created file
423     * @throws IOException If an error occurred
424     */
425    protected Path _download(URI uri) throws IOException
426    {
427        HttpGet httpGet = new HttpGet(uri);
428        
429        String tmpFolderName = Long.toString(Math.round(Math.random() * 1000000.0));
430        String fileName = StringUtils.substringAfterLast(uri.getPath(), "/");
431        
432        Path targetFile = AmetysHomeHelper.getAmetysHomeTmp().toPath().resolve(tmpFolderName).resolve(fileName);
433        Files.createDirectories(targetFile.getParent());
434        
435        return _httpClient.execute(httpGet, httpResponse ->
436        {
437            if (httpResponse.getCode() != 200)
438            {
439                throw new IOException("Error " + httpResponse.getCode() + " while accessing to the URI " + uri);
440            }
441            
442            try (InputStream is = httpResponse.getEntity().getContent();
443                    OutputStream os = Files.newOutputStream(targetFile))
444            {
445                IOUtils.copy(is, os);
446            }
447            
448            return targetFile;
449        });
450    }
451
452    
453    /**
454     * Get the API key from the configuration
455     * @return the API key
456     */
457    protected String getAPIKey()
458    {
459        return Config.getInstance().getValue(CONFIG_API_KEY);
460    }
461
462    /**
463     * Get the server URL from the configuration
464     * @return the server URL
465     */
466    protected String getServerURL()
467    {
468        return Config.getInstance().getValue(CONFIG_SERVER_URL);
469    }
470    
471    public boolean isEmbeddingSupported()
472    {
473        _initModels();
474        
475        return _embeddingModel != null;
476    }
477    
478    public String askChatbot(ChatMemory memory)
479    {
480        _initModels();
481        
482        ChatRequest chatRequest = ChatRequest.builder()
483                .messages(memory.messages())
484                .build();
485        
486        ChatResponse chatResponse = _textModel.chat(chatRequest);
487        return chatResponse.aiMessage().text();
488    }
489    
490    public List<Float> computeEmbedding(String text)
491    {
492        try
493        {
494            _initModels();
495            
496            Embedding embedding = _embeddingModel.embed(text).content();
497            List<Float> embeddingList = embedding.vectorAsList();
498            
499            if (isEmbeddingNormalizationRequired())
500            {
501                return normalize(embeddingList);
502            }
503            else
504            {
505                return embeddingList;
506            }
507        }
508        catch (AuthenticationException e)
509        {
510            getLogger().warn("Authentication error while computing embedding. Please check the API key.", e);
511            return null;
512        }
513    }
514    
515    /**
516     * Is normalization required for the embeddings of this provider
517     * @return true if normalization is required
518     */
519    protected boolean isEmbeddingNormalizationRequired()
520    {
521        return false;
522    }
523
524    /**
525     * Necessary for open models who often are not normalized.
526     * @param vector the vector to normalize
527     * @return the normalized vector
528     */
529    public List<Float> normalize (List<Float> vector)
530    {
531        double sumSquares = 0.0;
532        for (Float v : vector)
533        {
534            sumSquares += v * v;
535        }
536        double norm = Math.sqrt(sumSquares);
537        if (norm == 0)
538        {
539            return vector;
540        }
541        List<Float> normalized = new ArrayList<>(vector.size());
542        for (Float v : vector)
543        {
544            normalized.add((float) (v / norm));
545        }
546        return normalized;
547    }
548    
549    public int getEmbeddingDimension()
550    {
551        String modelName = Config.getInstance().getValue(CONFIG_EMBEDDING_MODEL);
552        if (StringUtils.isNotBlank(modelName))
553        {
554            for (EmbeddingModelInfo info : _embeddingModelInfos)
555            {
556                if (info.name().equals(modelName))
557                {
558                    return info.vectorDimension();
559                }
560            }
561        }
562        return -1;
563    }
564    
565    public String getEmbeddingKey()
566    {
567        if (isEmbeddingSupported())
568        {
569            return this.getId() + "_" + Config.getInstance().getValue(CONFIG_EMBEDDING_MODEL) + ":"  + getEmbeddingDimension();
570        }
571        return null;
572    }
573}