feat: resolve block LLM credentials from user vault

This commit is contained in:
Lucio Lelii 2026-09-01 17:02:49 +02:00
parent d7783e0d23
commit 86dfe1bb98
18 changed files with 301 additions and 100 deletions

View File

@ -5,5 +5,6 @@ public record AssistantConfigView(
String defaultModel,
String availableProvidersRetrieverUrl,
String availableModelsRetrieverUrlTemplate,
AssistantModelSelection defaultPhaseModels) {
AssistantModelSelection defaultPhaseModels,
String providerCatalogUrl) {
}

View File

@ -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<RetrieverItem> retrieve(String parameter, Map<String, String> 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<String, Object> metadata(VaultSecretView secret) {
java.util.LinkedHashMap<String, Object> 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;
}
}

View File

@ -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")

View File

@ -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<LLMProviderMetadata> list() {
return catalogService.list();
}
}

View File

@ -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<String, RequirementAccumulator> requirements, Block<?> block) {

View File

@ -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<ChatInteractionBlo
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<ChatInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -57,7 +61,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
LLMDescriptor chatDescriptor = configuration.getLlmDescriptor();
LLMProvider chatProvider = resolveProvider(chatDescriptor.provider());
String chatAuthorization = resolveAuthorization(chatProvider, chatDescriptor, authorizations);
String chatAuthorization = resolveAuthorization(chatProvider, chatDescriptor, executionVariables);
if (eventLogger != null) {
eventLogger.info(ExecutionEventType.LLM_REQUEST,
"Calling chat model " + chatDescriptor.model(),
@ -65,7 +69,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
}
LLMProvider simulatorProvider = resolveProvider(simulatorDescriptor.provider());
String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, authorizations);
String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, executionVariables);
String resolvedGoal = resolvePlaceholders(configuration.getGoalDescription(), inputs, executionVariables);
List<String> history = new ArrayList<>();
@ -115,11 +119,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
"Calling chat model " + llmDescriptor.model(),
Map.of("provider", llmDescriptor.provider(), "model", llmDescriptor.model()));
}
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey))))) {
throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider());
}
String authorization = resolveAuthorization(llmProvider, llmDescriptor, executionVariables);
if (interaction.containsKey(ChatInteractionBlockFactory.INTERACTION_FIELD)) {
Object messageValue = interaction.get(ChatInteractionBlockFactory.INTERACTION_FIELD);
@ -136,9 +136,9 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
.collect(Collectors.toList());
messages.add(new ChatMessage(ChatMessage.Role.USER, resolvedMessage));
String assistantResponse = llmProvider.requiresAuthorization()
? llmProvider.chat(llmDescriptor.model(), List.copyOf(messages), String.valueOf(authorizations.get(authKey)))
: llmProvider.chat(llmDescriptor.model(), List.copyOf(messages));
String assistantResponse = authorization == null
? llmProvider.chat(llmDescriptor.model(), List.copyOf(messages))
: llmProvider.chat(llmDescriptor.model(), List.copyOf(messages), authorization);
List<String> updatedHistory = new ArrayList<>(history);
updatedHistory.add(formatConversationLine(ChatMessage.Role.USER, resolvedMessage));
@ -174,15 +174,9 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
return llmProvider;
}
private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, Map<String, Object> 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<String, Object> executionVariables) {
return credentialResolver.resolve(provider, descriptor, executionVariables);
}
private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor,

View File

@ -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<ConditionalBlockType>
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<ConditionalBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -93,16 +97,12 @@ public class ConditionalExecutor implements BlockExecutor<ConditionalBlockType>
Map<String, Object> authorizations, Map<String, Object> 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) {

View File

@ -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<HumanDecisionBlockTy
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<HumanDecisionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -56,11 +60,7 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + simulatorDescriptor.provider());
}
String authKey = llmProvider.authorizationKey();
if (llmProvider.requiresAuthorization()
&& (!authorizations.containsKey(authKey) || !StringUtils.hasText(String.valueOf(authorizations.get(authKey))))) {
throw new IllegalArgumentException("Missing authorization for provider: " + simulatorDescriptor.provider());
}
String authorization = credentialResolver.resolve(llmProvider, simulatorDescriptor, executionVariables);
String context = inputs.stream()
.map(input -> "%s= %s".formatted(input.getDescriptor().getName(), input.getValue()))
@ -81,9 +81,9 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
configuration.isRationaleRequired() ? "RATIONALE: <short rationale>" : "");
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");
}

View File

@ -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<HumanInteractionB
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<HumanInteractionBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -61,17 +65,11 @@ public class HumanInteractionExecutor implements BlockExecutor<HumanInteractionB
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);
return Map.of("output", response);
}

View File

@ -20,6 +20,7 @@ import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver;
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.skills.SkillPromptService;
@ -34,6 +35,9 @@ public class LLMExecutor implements BlockExecutor<LLMBlockType> {
@Autowired
private SkillPromptService skillPromptService;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<LLMBlockType> block, List<Input> inputs, Map<String, Object> authorizations,
Map<String, Object> executionVariables, Map<String, ExecutionVariableDescriptor> executionVariableDescriptors,
@ -68,17 +72,11 @@ public class LLMExecutor implements BlockExecutor<LLMBlockType> {
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(),

View File

@ -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<MCPAgentChatBlockType
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<MCPAgentChatBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -66,7 +70,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
throw new IllegalArgumentException("Missing simulation descriptor for MCPAgentChat execution");
}
LLMProvider simulatorProvider = resolveProvider(simulatorDescriptor.provider());
String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, authorizations);
String simulatorAuthorization = resolveAuthorization(simulatorProvider, simulatorDescriptor, executionVariables);
String resolvedGoal = resolvePlaceholders(configuration.getGoalDescription(), inputs, executionVariables);
resolvedGoal = BiasRuntimeSupport.decoratePrompt(resolvedGoal, executionVariables);
List<String> history = new ArrayList<>();
@ -225,15 +229,9 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
}
private String resolveAuthorization(LLMProvider provider, LLMDescriptor descriptor, Map<String, Object> 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<String, Object> executionVariables) {
return credentialResolver.resolve(provider, descriptor, executionVariables);
}
private String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor,

View File

@ -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<SwitchBlockType> {
@Autowired
private Map<String, LLMProvider> llmProviders;
@Autowired
private LLMCredentialResolver credentialResolver;
@Override
public Map<String, Object> execute(Block<SwitchBlockType> block, List<Input> inputs,
Map<String, Object> authorizations, Map<String, Object> executionVariables,
@ -119,16 +123,12 @@ public class SwitchExecutor implements BlockExecutor<SwitchBlockType> {
Map<String, Object> authorizations, Map<String, Object> executionVariables, Set<String> 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);
}

View File

@ -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<String, Object> 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);
}
}

View File

@ -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);
}
}

View File

@ -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<String, LLMProvider> providers;
public LLMProviderCatalogService(Map<String, LLMProvider> providers) {
this.providers = providers;
}
public List<LLMProviderMetadata> 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();
}
}

View File

@ -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) {
}

View File

@ -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());

View File

@ -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<String> 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<FlowView> 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<LLMBlockType> block = blocksController.create(LLMBlockConfiguration.builder()
.name("Vault-backed LLM")
.llmDescriptor(descriptor)
.prompt("Analyze ${{candidate}}")
.build());
ResponseEntity<FlowView> 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