Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
582e1fa
Add AiModelService for improving JabRef handling of language models
st-rm-ng Oct 30, 2025
c740ba7
Replaced statement lambda with expression lambda in AiTabViewModel
st-rm-ng Oct 30, 2025
aba180f
Merge branch 'JabRef:main' into fix-for-issue-13860
st-rm-ng Nov 18, 2025
5bbf415
Refactor AiModelService to use BackgroundTask instead of CompletableF…
st-rm-ng Nov 18, 2025
dccddf9
Merge branch 'main' into fix-for-issue-13860
st-rm-ng Nov 20, 2025
b324140
Add unit tests for AiModelService, FetchAiModelsBackgroundTask and Op…
st-rm-ng Nov 20, 2025
852e3bf
Remove redundand FetchThread timeout mechanism for simpler synchronous
st-rm-ng Nov 22, 2025
f3bcaf5
Merge branch 'main' into fix-for-issue-13860
st-rm-ng Nov 22, 2025
cffd2d7
Simplify AI model fetching by removing custom threading and using
st-rm-ng Nov 22, 2025
0ee8fb4
Merge branch 'main' into fix-for-issue-13860
st-rm-ng Nov 23, 2025
9aef720
Removed redundant imports in AiTabViewModel
st-rm-ng Nov 23, 2025
f19156f
Reformat OpenAiCompatibleModelProvider
st-rm-ng Nov 23, 2025
c33fc22
Add missing localization key for AI model fetching task title
st-rm-ng Nov 23, 2025
88d45f7
Fix exception logging to include full stack traces
st-rm-ng Nov 23, 2025
90b0380
Ensure Unirest initialization before running tests
st-rm-ng Nov 23, 2025
655909d
Use modern Java for lists
koppor Nov 23, 2025
c046361
More modern Java
koppor Nov 23, 2025
16a39e3
Merge remote-tracking branch 'origin/main' into fix-for-issue-13860
koppor Nov 23, 2025
ad6f844
Merge branch 'JabRef:main' into fix-for-issue-13860
st-rm-ng Nov 29, 2025
42b2163
Used @NullMarked and @Nullable instead of null checks
st-rm-ng Nov 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public AiTab() {
}

public void initialize() {
this.viewModel = new AiTabViewModel(preferences);
this.viewModel = new AiTabViewModel(preferences, taskExecutor);

initializeEnableAi();
initializeAiProvider();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
import org.jabref.gui.preferences.PreferenceTabViewModel;
import org.jabref.logic.ai.AiDefaultPreferences;
import org.jabref.logic.ai.AiPreferences;
import org.jabref.logic.ai.models.AiModelService;
import org.jabref.logic.ai.models.FetchAiModelsBackgroundTask;
import org.jabref.logic.ai.templates.AiTemplate;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.preferences.CliPreferences;
import org.jabref.logic.util.LocalizedNumbers;
import org.jabref.logic.util.OptionalObjectProperty;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.logic.util.strings.StringUtil;
import org.jabref.model.ai.AiProvider;
import org.jabref.model.ai.EmbeddingModel;
Expand All @@ -36,7 +39,10 @@
import de.saxsys.mvvmfx.utils.validation.ValidationMessage;
import de.saxsys.mvvmfx.utils.validation.ValidationStatus;
import de.saxsys.mvvmfx.utils.validation.Validator;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked
public class AiTabViewModel implements PreferenceTabViewModel {
protected static SpinnerValueFactory<Integer> followUpQuestionsCountValueFactory = new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 5, 3);

Expand Down Expand Up @@ -113,6 +119,8 @@ AiTemplate.FOLLOW_UP_QUESTIONS, new SimpleStringProperty()
private final BooleanProperty disableExpertSettings = new SimpleBooleanProperty(true);

private final AiPreferences aiPreferences;
private final AiModelService aiModelService;
private final TaskExecutor taskExecutor;

private final Validator apiKeyValidator;
private final Validator chatModelValidator;
Expand All @@ -127,10 +135,12 @@ AiTemplate.FOLLOW_UP_QUESTIONS, new SimpleStringProperty()
private final Validator ragMinScoreTypeValidator;
private final Validator ragMinScoreRangeValidator;

public AiTabViewModel(CliPreferences preferences) {
public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) {
this.oldLocale = Locale.getDefault();

this.aiPreferences = preferences.getAiPreferences();
this.aiModelService = new AiModelService();
this.taskExecutor = taskExecutor;

this.enableAi.addListener((_, _, newValue) -> {
disableBasicSettings.set(!newValue);
Expand Down Expand Up @@ -439,6 +449,56 @@ public void resetCurrentTemplate() {
});
}

/**
* Fetches available models for the currently selected AI provider.
* Attempts to fetch models dynamically from the API, falling back to hardcoded models if fetch fails.
* This method runs asynchronously using a BackgroundTask and updates the chatModelsList when complete.
*/
public void refreshAvailableModels() {
AiProvider provider = selectedAiProvider.get();
if (provider == null) {
return;
}

String apiKey = currentApiKey.get();

// Get API base URL, defaulting to provider's default URL if not customized
String apiBaseUrl;
if (customizeExpertSettings.get()) {
String customUrl = currentApiBaseUrl.get();
apiBaseUrl = (customUrl != null && !customUrl.isBlank()) ? customUrl : provider.getApiUrl();
} else {
apiBaseUrl = provider.getApiUrl();
}

List<String> staticModels = aiModelService.getStaticModels(provider);
chatModelsList.setAll(staticModels);

FetchAiModelsBackgroundTask fetchTask = getAiModelsBackgroundTask(provider, apiBaseUrl, apiKey);

fetchTask.executeWith(taskExecutor);
}

private FetchAiModelsBackgroundTask getAiModelsBackgroundTask(AiProvider provider, String apiBaseUrl, @Nullable String apiKey) {
FetchAiModelsBackgroundTask fetchTask = new FetchAiModelsBackgroundTask(
aiModelService,
provider,
apiBaseUrl,
apiKey
);

fetchTask.onSuccess(dynamicModels -> {
if (!dynamicModels.isEmpty()) {
String currentModel = currentChatModel.get();
chatModelsList.setAll(dynamicModels);
if (currentModel != null && !currentModel.isBlank()) {
currentChatModel.set(currentModel);
}
}
});
return fetchTask;
}

@Override
public boolean validateSettings() {
if (enableAi.get()) {
Expand Down
1 change: 1 addition & 0 deletions jablib/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
exports org.jabref.model.groups.event;
exports org.jabref.logic.preview;
exports org.jabref.logic.ai;
exports org.jabref.logic.ai.models;
exports org.jabref.logic.pdf;
exports org.jabref.model.database.event;
exports org.jabref.model.entry.event;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.jabref.logic.ai.models;

import java.util.List;

import org.jabref.model.ai.AiProvider;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Interface for fetching available AI models from different providers.
* Implementations should handle API calls to retrieve model lists dynamically.
*/
@NullMarked
public interface AiModelProvider {
/**
* Fetches the list of available models for the given AI provider.
*
* @param aiProvider The AI provider to fetch models from
* @param apiBaseUrl The base URL for the API
* @param apiKey The API key for authentication (may be null for providers that don't require it)
* @return A list of available model names (never null, empty if fetch fails)
*/
List<String> fetchModels(AiProvider aiProvider, String apiBaseUrl, @Nullable String apiKey);

/**
* Checks if this provider supports the given AI provider type.
*
* @param aiProvider The AI provider to check
* @return true if this provider can fetch models for the given AI provider
*/
boolean supports(AiProvider aiProvider);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.jabref.logic.ai.models;

import java.util.List;

import org.jabref.logic.ai.AiDefaultPreferences;
import org.jabref.model.ai.AiProvider;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Service for managing AI models from different providers.
* Provides both static (hardcoded) and dynamic (API-fetched) model lists.
*/
@NullMarked
public class AiModelService {
private static final Logger LOGGER = LoggerFactory.getLogger(AiModelService.class);

private final List<AiModelProvider> modelProviders = List.of(new OpenAiCompatibleModelProvider());

/**
* Gets the list of available models for the given provider.
* First attempts to fetch models dynamically from the API.
* If that fails or times out, falls back to the hardcoded list.
*
* @param aiProvider The AI provider
* @param apiBaseUrl The base URL for the API
* @param apiKey The API key for authentication (may be null)
* @return A list of available model names
*/
public List<String> getAvailableModels(AiProvider aiProvider, String apiBaseUrl, @Nullable String apiKey) {
List<String> dynamicModels = fetchModelsSynchronously(aiProvider, apiBaseUrl, apiKey);

if (!dynamicModels.isEmpty()) {
LOGGER.info("Using {} dynamic models for {}", dynamicModels.size(), aiProvider.getLabel());
return dynamicModels;
}

List<String> staticModels = AiDefaultPreferences.getAvailableModels(aiProvider);
LOGGER.debug("Using {} hardcoded models for {}", staticModels.size(), aiProvider.getLabel());
return staticModels;
}

/**
* Gets the list of available models for the given provider, using only hardcoded values.
*
* @param aiProvider The AI provider
* @return A list of available model names
*/
public List<String> getStaticModels(AiProvider aiProvider) {
return AiDefaultPreferences.getAvailableModels(aiProvider);
}

/**
* Synchronously fetches the list of available models from the API.
* This method will block until the fetch completes or the HTTP client times out.
*
* @param aiProvider The AI provider
* @param apiBaseUrl The base URL for the API
* @param apiKey The API key for authentication (may be null)
* @return A list of model names, or an empty list if the fetch fails
*/
public List<String> fetchModelsSynchronously(AiProvider aiProvider, String apiBaseUrl, @Nullable String apiKey) {
for (AiModelProvider provider : modelProviders) {
if (provider.supports(aiProvider)) {
try {
List<String> models = provider.fetchModels(aiProvider, apiBaseUrl, apiKey);
if (models.isEmpty()) {
return models;
}
} catch (Exception e) {
LOGGER.debug("Failed to fetch models for {}", aiProvider.getLabel(), e);
}
}
}

return List.of();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.jabref.logic.ai.models;

import java.util.List;

import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.model.ai.AiProvider;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Background task for fetching AI models from a provider's API.
*/
@NullMarked
public class FetchAiModelsBackgroundTask extends BackgroundTask<List<String>> {

private final AiModelService aiModelService;
private final AiProvider aiProvider;
private final String apiBaseUrl;
@Nullable
private final String apiKey;

public FetchAiModelsBackgroundTask(AiModelService aiModelService, AiProvider aiProvider, String apiBaseUrl, @Nullable String apiKey) {
this.aiModelService = aiModelService;
this.aiProvider = aiProvider;
this.apiBaseUrl = apiBaseUrl;
this.apiKey = apiKey;

configure();
}

private void configure() {
showToUser(false);
titleProperty().set(Localization.lang("Fetching models for %0", aiProvider.getLabel()));
willBeRecoveredAutomatically(true);
}

@Override
public List<String> call() {
return aiModelService.fetchModelsSynchronously(
aiProvider,
apiBaseUrl,
apiKey
);
}
}
Loading
Loading