diff --git a/src/main/java/it/cnr/isti/workflow/manager/assistant/model/AssistantConfigView.java b/src/main/java/it/cnr/isti/workflow/manager/assistant/model/AssistantConfigView.java index 3f38e0c..6a10552 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/assistant/model/AssistantConfigView.java +++ b/src/main/java/it/cnr/isti/workflow/manager/assistant/model/AssistantConfigView.java @@ -5,5 +5,6 @@ public record AssistantConfigView( String defaultModel, String availableProvidersRetrieverUrl, String availableModelsRetrieverUrlTemplate, - AssistantModelSelection defaultPhaseModels) { + AssistantModelSelection defaultPhaseModels, + String providerCatalogUrl) { } diff --git a/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/UserSecretsFieldRetriever.java b/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/UserSecretsFieldRetriever.java new file mode 100644 index 0000000..11d5daa --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/configurations/retrievers/UserSecretsFieldRetriever.java @@ -0,0 +1,56 @@ +package it.cnr.isti.workflow.manager.configurations.retrievers; + +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; +import it.cnr.isti.workflow.manager.vault.UserSecretService; +import it.cnr.isti.workflow.manager.vault.model.VaultSecretView; + +@Component +public class UserSecretsFieldRetriever implements SecureDynamicFieldRetriever { + + private final UserSecretService userSecretService; + + public UserSecretsFieldRetriever(UserSecretService userSecretService) { + this.userSecretService = userSecretService; + } + + @Override + public String getCategory() { + return "UserSecrets"; + } + + @Override + public List retrieve(String parameter, Map params, LoginEntity user) { + if (!"forProvider".equals(parameter)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown UserSecrets retriever parameter: " + parameter); + } + if (user == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user is required"); + } + String provider = params == null ? null : params.get("provider"); + return userSecretService.list(user.getUsername()).stream() + .filter(VaultSecretView::active) + .filter(secret -> !StringUtils.hasText(provider) || secret.provider().equalsIgnoreCase(provider)) + .map(secret -> new RetrieverItem( + new RetrieverItemDescriptor(secret.label(), secret.description(), metadata(secret)), + secret.id(), false, true, List.of())) + .toList(); + } + + private Map metadata(VaultSecretView secret) { + java.util.LinkedHashMap metadata = new java.util.LinkedHashMap<>(); + metadata.put("id", secret.id()); + metadata.put("provider", secret.provider()); + if (secret.lastUsedAt() != null) { + metadata.put("lastUsedAt", secret.lastUsedAt()); + } + return metadata; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/AssistantController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/AssistantController.java index 880ee67..fa4b657 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/AssistantController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/AssistantController.java @@ -38,6 +38,7 @@ public class AssistantController { private static final String PROVIDERS_RETRIEVER_URL = "/retriever/LLM/providers"; private static final String MODELS_RETRIEVER_URL_TEMPLATE = "/retriever/LLM/models?provider={provider}"; + private static final String PROVIDER_CATALOG_URL = "/llm/providers"; @Autowired private FlowAssistantService flowAssistantService; @@ -121,7 +122,8 @@ public class AssistantController { defaultAssistantModel, PROVIDERS_RETRIEVER_URL, MODELS_RETRIEVER_URL_TEMPLATE, - new AssistantModelSelection(defaultPlanningModel, defaultJsonModel, defaultRepairModel)); + new AssistantModelSelection(defaultPlanningModel, defaultJsonModel, defaultRepairModel), + PROVIDER_CATALOG_URL); } @PostMapping("/sessions") diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/LLMProviderCatalogController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/LLMProviderCatalogController.java new file mode 100644 index 0000000..d4c65c8 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/LLMProviderCatalogController.java @@ -0,0 +1,30 @@ +package it.cnr.isti.workflow.manager.controllers; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import it.cnr.isti.workflow.manager.llms.LLMProviderCatalogService; +import it.cnr.isti.workflow.manager.llms.LLMProviderMetadata; + +@RestController +@RequestMapping("/llm/providers") +@SecurityRequirement(name = "bearerAuth") +public class LLMProviderCatalogController { + + private final LLMProviderCatalogService catalogService; + + public LLMProviderCatalogController(LLMProviderCatalogService catalogService) { + this.catalogService = catalogService; + } + + @GetMapping + @Operation(summary = "List LLM provider capabilities", description = "Returns non-sensitive provider metadata for assistant and block configuration UIs.") + public List list() { + return catalogService.list(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 493ae3b..bb08d57 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -1495,14 +1495,11 @@ public class ExecutionsService { return; } LLMProvider provider = resolveProvider(descriptor.provider()); + // LLM credentials are always references to the user vault and are resolved at call time. + // Do not expose a raw authorization requirement in an execution view or snapshot. if (provider == null || !provider.requiresAuthorization()) { return; } - String key = provider.authorizationKey(); - requirements.computeIfAbsent(key, - ignored -> new RequirementAccumulator(key, provider.getName(), provider.authorizationFieldName(), - provider.authorizationDescription())) - .addStepReference(block == null ? null : block.getId(), stepName); } private void collectHttpRequirement(Map requirements, Block block) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java index 719c357..ab6f582 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ChatInteractionExecutor.java @@ -24,6 +24,7 @@ import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.llms.ChatMessage; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component @@ -34,6 +35,9 @@ public class ChatInteractionExecutor implements BlockExecutor llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -57,7 +61,7 @@ public class ChatInteractionExecutor implements BlockExecutor history = new ArrayList<>(); @@ -115,11 +119,7 @@ public class ChatInteractionExecutor implements BlockExecutor updatedHistory = new ArrayList<>(history); updatedHistory.add(formatConversationLine(ChatMessage.Role.USER, resolvedMessage)); @@ -174,15 +174,9 @@ public class ChatInteractionExecutor implements BlockExecutor authorizations) { - if (!provider.requiresAuthorization()) { - return null; - } - String authKey = provider.authorizationKey(); - if (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey)))) { - throw new IllegalArgumentException("Missing authorization for provider: " + descriptor.provider()); - } - return String.valueOf(authorizations.get(authKey)); + private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, + Map executionVariables) { + return credentialResolver.resolve(provider, descriptor, executionVariables); } private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java index 7268950..573f42e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/ConditionalExecutor.java @@ -26,6 +26,7 @@ import it.cnr.isti.workflow.manager.executions.executors.BooleanLlmResponseParse import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component @@ -45,6 +46,9 @@ public class ConditionalExecutor implements BlockExecutor @Autowired private Map llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -93,16 +97,12 @@ public class ConditionalExecutor implements BlockExecutor Map authorizations, Map executionVariables) { LLMDescriptor llmDescriptor = config.getLlmDescriptor(); LLMProvider llmProvider = resolveProvider(llmDescriptor.provider()); - String authKey = llmProvider.authorizationKey(); - if (llmProvider.requiresAuthorization() - && (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) { - throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider()); - } + String authorization = credentialResolver.resolve(llmProvider, llmDescriptor, executionVariables); String prompt = buildLlmPrompt(config, inputValues, executionVariables); - String response = llmProvider.requiresAuthorization() - ? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()) - : llmProvider.generate(llmDescriptor.model(), prompt); + String response = authorization == null + ? llmProvider.generate(llmDescriptor.model(), prompt) + : llmProvider.generate(llmDescriptor.model(), prompt, authorization); try { return parseBooleanResponse(response); } catch (IllegalArgumentException e) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanDecisionExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanDecisionExecutor.java index 37ee3fb..917ff67 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanDecisionExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanDecisionExecutor.java @@ -21,6 +21,7 @@ import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component @@ -29,6 +30,9 @@ public class HumanDecisionExecutor implements BlockExecutor llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -56,11 +60,7 @@ public class HumanDecisionExecutor implements BlockExecutor "%s= %s".formatted(input.getDescriptor().getName(), input.getValue())) @@ -81,9 +81,9 @@ public class HumanDecisionExecutor implements BlockExecutor" : ""); prompt = BiasRuntimeSupport.decoratePrompt(prompt, executionVariables); - String response = llmProvider.requiresAuthorization() - ? llmProvider.generate(simulatorDescriptor.model(), prompt, String.valueOf(authorizations.get(authKey))) - : llmProvider.generate(simulatorDescriptor.model(), prompt); + String response = authorization == null + ? llmProvider.generate(simulatorDescriptor.model(), prompt) + : llmProvider.generate(simulatorDescriptor.model(), prompt, authorization); if (!StringUtils.hasText(response)) { throw new IllegalArgumentException("Simulated HumanDecision produced an empty response"); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java index c796bb9..0b7605a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/HumanInteractionExecutor.java @@ -17,6 +17,7 @@ import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component @@ -27,6 +28,9 @@ public class HumanInteractionExecutor implements BlockExecutor llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -61,17 +65,11 @@ public class HumanInteractionExecutor implements BlockExecutor { @Autowired private SkillPromptService skillPromptService; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, Map executionVariableDescriptors, @@ -68,17 +72,11 @@ public class LLMExecutor implements BlockExecutor { if (llmProvider == null) { throw new IllegalArgumentException("Provider not found: " + llmDescriptor.provider()); } - String authKey = llmProvider.authorizationKey(); - if (llmProvider.requiresAuthorization() && (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) { - throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider()); - } + String authorization = credentialResolver.resolve(llmProvider, llmDescriptor, executionVariables); - String response; - if (llmProvider.requiresAuthorization()) { - response = llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()); - } else { - response = llmProvider.generate(llmDescriptor.model(), prompt); - } + String response = authorization == null + ? llmProvider.generate(llmDescriptor.model(), prompt) + : llmProvider.generate(llmDescriptor.model(), prompt, authorization); if (eventLogger != null) { eventLogger.info(ExecutionEventType.LLM_REQUEST, "Called LLM model " + llmDescriptor.model(), diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java index 2f143de..111d0cd 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/MCPAgentChatExecutor.java @@ -24,6 +24,7 @@ import it.cnr.isti.workflow.manager.executions.InteractionResult; import it.cnr.isti.workflow.manager.executions.steps.Input; import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; import it.cnr.isti.workflow.manager.mcp.MCPAgentService; import it.cnr.isti.workflow.manager.mcp.MCPSharedSessionRegistry; @@ -40,6 +41,9 @@ public class MCPAgentChatExecutor implements BlockExecutor llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -66,7 +70,7 @@ public class MCPAgentChatExecutor implements BlockExecutor history = new ArrayList<>(); @@ -225,15 +229,9 @@ public class MCPAgentChatExecutor implements BlockExecutor new IllegalArgumentException("Provider not found: " + providerName)); } - private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, Map authorizations) { - if (!provider.requiresAuthorization()) { - return null; - } - String authKey = provider.authorizationKey(); - if (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey)))) { - throw new IllegalArgumentException("Missing authorization for provider: " + descriptor.provider()); - } - return String.valueOf(authorizations.get(authKey)); + private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, + Map executionVariables) { + return credentialResolver.resolve(provider, descriptor, executionVariables); } private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor, diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java index 3b4f40e..ead0f76 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/blocks/SwitchExecutor.java @@ -31,6 +31,7 @@ import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport; import it.cnr.isti.workflow.manager.ios.IODescriptor; import it.cnr.isti.workflow.manager.ios.IOType; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; +import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @Component @@ -51,6 +52,9 @@ public class SwitchExecutor implements BlockExecutor { @Autowired private Map llmProviders; + @Autowired + private LLMCredentialResolver credentialResolver; + @Override public Map execute(Block block, List inputs, Map authorizations, Map executionVariables, @@ -119,16 +123,12 @@ public class SwitchExecutor implements BlockExecutor { Map authorizations, Map executionVariables, Set allowedOutputs) { LLMDescriptor llmDescriptor = config.getLlmDescriptor(); LLMProvider llmProvider = resolveProvider(llmDescriptor.provider()); - String authKey = llmProvider.authorizationKey(); - if (llmProvider.requiresAuthorization() - && (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) { - throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider()); - } + String authorization = credentialResolver.resolve(llmProvider, llmDescriptor, executionVariables); String prompt = buildLlmPrompt(config, inputValues, executionVariables, allowedOutputs); - String response = llmProvider.requiresAuthorization() - ? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()) - : llmProvider.generate(llmDescriptor.model(), prompt); + String response = authorization == null + ? llmProvider.generate(llmDescriptor.model(), prompt) + : llmProvider.generate(llmDescriptor.model(), prompt, authorization); return parseSelectedOutput(response, allowedOutputs); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMCredentialResolver.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMCredentialResolver.java new file mode 100644 index 0000000..568bf33 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMCredentialResolver.java @@ -0,0 +1,41 @@ +package it.cnr.isti.workflow.manager.llms; + +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.executions.ExecutionRuntimeContextSupport; +import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; +import it.cnr.isti.workflow.manager.vault.UserSecretService; + +/** Resolves a flow's credential reference without ever putting plaintext in its persisted state. */ +@Component +public class LLMCredentialResolver { + + private final UserSecretService userSecretService; + + public LLMCredentialResolver(UserSecretService userSecretService) { + this.userSecretService = userSecretService; + } + + public String resolve(LLMProvider provider, LLMDescriptor descriptor, Map executionVariables) { + if (!provider.requiresAuthorization()) { + return null; + } + if (!StringUtils.hasText(descriptor.credentialId())) { + throw new IllegalArgumentException("Missing credentialId for provider: " + descriptor.provider()); + } + String owner = executionVariables == null ? null + : asText(executionVariables.get(ExecutionRuntimeContextSupport.EXECUTION_OWNER)); + if (!StringUtils.hasText(owner)) { + throw new IllegalArgumentException( + "A user-owned execution is required to resolve credential for provider: " + provider.getName()); + } + return userSecretService.resolveValue(owner, descriptor.credentialId().trim(), provider.getName()); + } + + private String asText(Object value) { + return value == null ? null : String.valueOf(value); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java index 7ffd690..bce2dea 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java @@ -1,10 +1,19 @@ package it.cnr.isti.workflow.manager.llms; import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever; +import it.cnr.isti.workflow.manager.configurations.annotations.UiDescription; import jakarta.validation.constraints.NotBlank; import lombok.Builder; @Builder public record LLMDescriptor( @NotBlank @FieldRetriever(name = "LLM", url = "/retriever/LLM/providers") String provider, - @NotBlank @FieldRetriever(name = "LLM", url = "/retriever/LLM/models", dependsOn = {"provider"}) String model) {} + @NotBlank @FieldRetriever(name = "LLM", url = "/retriever/LLM/models", dependsOn = {"provider"}) String model, + @UiDescription("Saved user credential. Required when the selected provider declares requiresCredential.") + @FieldRetriever(name = "UserSecrets", url = "/secure-retriever/UserSecrets/forProvider/items", dependsOn = {"provider"}, requiresAuth = true) + String credentialId) { + + public LLMDescriptor(String provider, String model) { + this(provider, model, null); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderCatalogService.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderCatalogService.java new file mode 100644 index 0000000..d5e65ca --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderCatalogService.java @@ -0,0 +1,28 @@ +package it.cnr.isti.workflow.manager.llms; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; + +@Service +public class LLMProviderCatalogService { + + private final Map providers; + + public LLMProviderCatalogService(Map providers) { + this.providers = providers; + } + + public List list() { + return providers.values().stream() + .map(provider -> new LLMProviderMetadata(provider.getName(), provider.requiresAuthorization())) + .filter(provider -> StringUtils.hasText(provider.name())) + .distinct() + .sorted(java.util.Comparator.comparing(LLMProviderMetadata::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderMetadata.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderMetadata.java new file mode 100644 index 0000000..9773b8d --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMProviderMetadata.java @@ -0,0 +1,5 @@ +package it.cnr.isti.workflow.manager.llms; + +/** Public, non-sensitive capabilities used by every LLM selection UI. */ +public record LLMProviderMetadata(String name, boolean requiresCredential) { +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java index 2fa159c..7ea2d08 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java @@ -708,6 +708,7 @@ public class AssistantControllerTest { assertEquals(MODEL, config.defaultModel()); assertEquals("/retriever/LLM/providers", config.availableProvidersRetrieverUrl()); assertEquals("/retriever/LLM/models?provider={provider}", config.availableModelsRetrieverUrlTemplate()); + assertEquals("/llm/providers", config.providerCatalogUrl()); assertNotNull(config.defaultPhaseModels()); assertEquals(MODEL, config.defaultPhaseModels().planningModel()); assertEquals(MODEL, config.defaultPhaseModels().jsonModel()); diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java index 875de85..fc8ed26 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java @@ -32,7 +32,6 @@ import it.cnr.isti.workflow.manager.executions.ExecutionEvent; import it.cnr.isti.workflow.manager.executions.ExecutionEventType; import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest; import it.cnr.isti.workflow.manager.executions.ExecutionStatus; -import it.cnr.isti.workflow.manager.executions.ExecutionAuthorizationValueRequest; import it.cnr.isti.workflow.manager.executions.ExecutionsService; import it.cnr.isti.workflow.manager.executions.api.ExecutionGroupView; import it.cnr.isti.workflow.manager.executions.api.ExecutionView; @@ -47,6 +46,9 @@ import it.cnr.isti.workflow.manager.ios.IOType; import it.cnr.isti.workflow.manager.llms.ChatMessage; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; +import it.cnr.isti.workflow.manager.vault.UserSecretService; +import it.cnr.isti.workflow.manager.vault.model.VaultSecretCreateRequest; +import it.cnr.isti.workflow.manager.vault.model.VaultSecretView; @SpringBootTest @TestPropertySource(locations = "classpath:test.properties") @@ -91,6 +93,36 @@ public class ExecutionControllerTest { } }; } + + @Bean + public LLMProvider vaultProvider() { + return new LLMProvider() { + @Override + public String getName() { + return "VaultProvider"; + } + + @Override + public List getRegisteredModels() { + return List.of("vault-model"); + } + + @Override + public String generate(String model, String prompt) { + throw new IllegalArgumentException("VaultProvider requires a user credential"); + } + + @Override + public String generate(String model, String prompt, String authorization) { + return "authorized:" + authorization; + } + + @Override + public boolean requiresAuthorization() { + return true; + } + }; + } } @Autowired @@ -105,6 +137,9 @@ public class ExecutionControllerTest { @Autowired private ExecutionsService executionsService; + @Autowired + private UserSecretService userSecretService; + @Test public void createFlowAndExecution(){ LLMDescriptor llmDescriptor = LLMDescriptor.builder() @@ -649,7 +684,7 @@ public class ExecutionControllerTest { } @Test - public void executionExposesRequiredAuthorizationsAndAcceptsProvidedValues() { + public void externalLlmExecutionDoesNotExposeRawAuthorizationRequirements() { LLMDescriptor llmDescriptor = LLMDescriptor.builder() .provider("Gemini") .model("gemini-2.0-flash") @@ -663,32 +698,40 @@ public class ExecutionControllerTest { FlowCreateRequest request = new FlowCreateRequest( "Gemini Flow", - "Flow requiring provider authorization", + "Flow using an external provider", FlowData.builder().block(llmBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), new LoginEntity("testuser", "testpassword")); - org.junit.jupiter.api.Assertions.assertEquals(1, executionObject.getRequiredAuthorizations().size()); - org.junit.jupiter.api.Assertions.assertEquals("LLMProvider::Gemini::authorization", - executionObject.getRequiredAuthorizations().getFirst().key()); + org.junit.jupiter.api.Assertions.assertTrue(executionObject.getRequiredAuthorizations().isEmpty()); + } - String executionId = executionObject.getId(); + @Test + public void executionResolvesProviderCredentialFromTheOwnerVault() { + VaultSecretView secret = userSecretService.create("testuser", new VaultSecretCreateRequest( + "vault-provider-" + java.util.UUID.randomUUID(), "VaultProvider", null, "vault-api-key")); + LLMDescriptor descriptor = new LLMDescriptor("VaultProvider", "vault-model", secret.id()); + Block block = blocksController.create(LLMBlockConfiguration.builder() + .name("Vault-backed LLM") + .llmDescriptor(descriptor) + .prompt("Analyze ${{candidate}}") + .build()); + ResponseEntity flow = flowController.createFlow(new FlowCreateRequest( + "Vault LLM flow", "Uses a saved credential", FlowData.builder().block(block).build()), testUser()); + ExecutionView execution = executionsController.create(flow.getBody().id(), testUser()); - executionsController.prepareStringInputs(executionId, llmBlock.getId(), - llmBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); + org.junit.jupiter.api.Assertions.assertTrue(execution.getRequiredAuthorizations().isEmpty()); + executionsController.prepareStringInputs(execution.getId(), block.getId(), + block.getInputs().getFirst().getName(), "Ada", testUser()); + executionsController.start(execution.getId(), testUser()); + waitForExecutionStatus(execution, ExecutionStatus.SUCCESS); - IllegalStateException missingAuthorization = org.junit.jupiter.api.Assertions.assertThrows( - IllegalStateException.class, - () -> executionsController.start(executionId, testUser())); - org.junit.jupiter.api.Assertions.assertTrue(missingAuthorization.getMessage().contains("LLMProvider::Gemini::authorization")); - - executionObject = executionsController.provideAuthorization(executionId, - new ExecutionAuthorizationValueRequest("LLMProvider::Gemini::authorization", "test-api-key"), testUser()); - - org.junit.jupiter.api.Assertions.assertTrue(executionObject.getMissingAuthorizationKeys().isEmpty()); - org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.READY, executionObject.getContext().getStatus()); + it.cnr.isti.workflow.manager.executions.ExecutionObject completed = executionsService.getExecution(execution.getId()); + org.junit.jupiter.api.Assertions.assertEquals("authorized:vault-api-key", + completed.getContext().getResult() + .get(new it.cnr.isti.workflow.manager.executions.FieldKey(block.getId(), "response"))); } @Test