Resolve a ProviderCredential everywhere a provider is called, not a string

LLMCredentialResolver.resolve now returns ProviderCredential instead of a
bare String, so the endpoint travels with the value from the vault all
the way to the provider that needs it. Every one of the eight call sites
had to change to compile - there was no way to touch only one - so all of
them now pass the resolved credential straight through instead of
unwrapping it first.

That turned out to be the right amount of change, not more than
necessary. Where an endpoint-aware provider is not actually reachable yet
(the interaction simulator and the bias judge choose their descriptor
after the execution already exists, and never had their authorization
requirement computed up front to begin with - a separate, pre-existing
gap this does not close), a missing credential fails exactly as it always
did: LLMCredentialResolver still throws "Missing saved credential" when
the authorizations map has no entry for the provider's key, whether the
caller then unwraps .value() or keeps the whole ProviderCredential makes
no difference to that failure. The only place behaviour actually changes
is the success case, and only for a provider that reads the endpoint at
all - every existing provider still only reads .value() through the
interface's own default unwrapping, so Gemini, InternalOllama and every
test stub keep behaving exactly as before.

LLMCredentialResolverTest is new: this resolver was previously exercised
only indirectly, through a full execution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-17 12:28:12 +02:00
parent b32e5e7b12
commit 247de4485b
11 changed files with 157 additions and 32 deletions

View File

@ -30,6 +30,7 @@ import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation;
import it.cnr.isti.workflow.manager.flows.validation.ValidationErrorCode;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
import tools.jackson.databind.JsonNode;
@ -79,7 +80,7 @@ public class BiasImpactJudge {
public BiasJudgeSummary evaluate(BiasImpactReport comparison, LLMDescriptor descriptor, ExecutionObject baseline,
ExecutionObject biased) {
LLMProvider provider = resolveProvider(descriptor.provider());
String authorization = resolveAuthorization(provider, baseline);
ProviderCredential authorization = resolveAuthorization(provider, baseline);
String interventions = describeInterventions(biased, comparison.annotationIds());
List<BiasJudgeTarget> targets = BiasJudgeTarget.collect(comparison);
@ -117,7 +118,7 @@ public class BiasImpactJudge {
errors, verdicts);
}
private BiasJudgeVerdict judgeTarget(LLMProvider provider, LLMDescriptor descriptor, String authorization,
private BiasJudgeVerdict judgeTarget(LLMProvider provider, LLMDescriptor descriptor, ProviderCredential authorization,
String interventions, BiasJudgeTarget target, List<String> errors) {
String prompt = pairPrompt(interventions, target);
try {
@ -150,7 +151,7 @@ public class BiasImpactJudge {
* this, where the answer is the real one - and the extractor below pulls the object out of
* whatever prose it arrives wrapped in.
*/
private String askForJson(LLMProvider provider, LLMDescriptor descriptor, String authorization, String prompt) {
private String askForJson(LLMProvider provider, LLMDescriptor descriptor, ProviderCredential authorization, String prompt) {
String json = null;
try {
json = provider.generateJson(descriptor.model(), prompt, authorization, descriptor.parameters());
@ -183,7 +184,7 @@ public class BiasImpactJudge {
* per-pair verdicts in code, so that re-reading a report cannot show a different headline than
* the pairs it is made of.
*/
private String narrate(LLMProvider provider, LLMDescriptor descriptor, String authorization, String interventions,
private String narrate(LLMProvider provider, LLMDescriptor descriptor, ProviderCredential authorization, String interventions,
BiasImpactReport comparison, Map<String, BiasJudgeVerdict> verdicts, BiasJudgeImpactLevel impact,
BiasJudgeAttribution attribution, List<String> errors) {
String prompt = """
@ -504,7 +505,7 @@ public class BiasImpactJudge {
* run is reached the same way as the models that produced it, and the internal provider - the
* one a local install has - needs no credential at all.
*/
private String resolveAuthorization(LLMProvider provider, ExecutionObject baseline) {
private ProviderCredential resolveAuthorization(LLMProvider provider, ExecutionObject baseline) {
try {
return credentialResolver.resolve(provider, baseline.getProvidedAuthorizations(),
baseline.getContext().getResolvedExecutionVariables());

View File

@ -29,6 +29,7 @@ import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.LLMDescriptorInputBinding;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
@ -65,7 +66,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
LLMDescriptor chatDescriptor = LLMDescriptorInputBinding.resolve(configuration.getLlmDescriptor(), inputs, executionVariables);
LLMProvider chatProvider = SimulatedChatSupport.resolveProvider(llmProviders, chatDescriptor.provider());
String chatAuthorization = SimulatedChatSupport.resolveAuthorization(credentialResolver, chatProvider, authorizations,
ProviderCredential chatCredential = SimulatedChatSupport.resolveAuthorization(credentialResolver, chatProvider, authorizations,
executionVariables);
if (eventLogger != null) {
eventLogger.info(ExecutionEventType.LLM_REQUEST,
@ -74,7 +75,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
}
LLMProvider simulatorProvider = SimulatedChatSupport.resolveProvider(llmProviders, simulatorDescriptor.provider());
String simulatorAuthorization = SimulatedChatSupport.resolveAuthorization(credentialResolver, simulatorProvider,
ProviderCredential simulatorCredential = SimulatedChatSupport.resolveAuthorization(credentialResolver, simulatorProvider,
authorizations, executionVariables);
String resolvedGoal = ExecutionTemplateResolver.resolve(configuration.getGoalDescription(), inputs, executionVariables);
@ -82,7 +83,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
for (int turn = 1; turn <= MAX_SIMULATED_INTERACTIONS; turn++) {
String simulatedMessage = SimulatedChatSupport.generateSimulatorMessage(simulatorProvider, simulatorDescriptor,
simulatorAuthorization, resolvedGoal, inputs, history, turn, MAX_SIMULATED_INTERACTIONS, "ChatInteraction");
simulatorCredential, resolvedGoal, inputs, history, turn, MAX_SIMULATED_INTERACTIONS, "ChatInteraction");
history.add(SimulatedChatSupport.formatConversationLine(ChatMessage.Role.USER.name(), simulatedMessage));
List<ChatMessage> messages = history.stream().map(this::parseHistoryLine).collect(Collectors.toList());
@ -91,12 +92,12 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
messages.addFirst(new ChatMessage(ChatMessage.Role.SYSTEM, experimentalDirective));
}
String assistantResponse = chatProvider.chat(chatDescriptor.model(), List.copyOf(messages),
chatAuthorization, chatDescriptor.parameters());
chatCredential, chatDescriptor.parameters());
history.add(SimulatedChatSupport.formatConversationLine(ChatMessage.Role.ASSISTANT.name(), assistantResponse));
}
String finalResponse = SimulatedChatSupport.generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor,
simulatorAuthorization, resolvedGoal, inputs, history, "ChatInteraction");
simulatorCredential, resolvedGoal, inputs, history, "ChatInteraction");
return buildResult(configuration, finalResponse, List.copyOf(history));
}
@ -124,7 +125,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
"Calling chat model " + llmDescriptor.model(),
Map.of("provider", llmDescriptor.provider(), "model", llmDescriptor.model()));
}
String authorization = SimulatedChatSupport.resolveAuthorization(credentialResolver, llmProvider, authorizations,
ProviderCredential credential = SimulatedChatSupport.resolveAuthorization(credentialResolver, llmProvider, authorizations,
executionVariables);
if (interaction.containsKey(ChatInteractionBlockFactory.INTERACTION_FIELD)) {
@ -142,7 +143,7 @@ public class ChatInteractionExecutor implements BlockExecutor<ChatInteractionBlo
.collect(Collectors.toList());
messages.add(new ChatMessage(ChatMessage.Role.USER, resolvedMessage));
String assistantResponse = llmProvider.chat(llmDescriptor.model(), List.copyOf(messages), authorization,
String assistantResponse = llmProvider.chat(llmDescriptor.model(), List.copyOf(messages), credential,
llmDescriptor.parameters());
List<String> updatedHistory = new ArrayList<>(history);

View File

@ -27,6 +27,7 @@ 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.LLMDescriptorInputBinding;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
@ -84,12 +85,12 @@ public class ConditionalExecutor implements BlockExecutor<ConditionalBlockType>
ExecutionEventLogger eventLogger) {
LLMDescriptor llmDescriptor = LLMDescriptorInputBinding.resolve(config.getLlmDescriptor(), inputValues, executionVariables);
LLMProvider llmProvider = SimulatedChatSupport.resolveProvider(llmProviders, llmDescriptor.provider());
String authorization = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ProviderCredential credential = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
String prompt = buildLlmPrompt(config, inputValues, executionVariables);
ModelParameterReporting.reportUnsupported(llmProvider, llmDescriptor.parameters(), llmDescriptor.model(),
eventLogger);
String response = llmProvider.generate(llmDescriptor.model(), prompt, authorization,
String response = llmProvider.generate(llmDescriptor.model(), prompt, credential,
llmDescriptor.parameters());
try {
return parseBooleanResponse(response);

View File

@ -26,6 +26,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.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
@ -64,7 +65,7 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + simulatorDescriptor.provider());
}
String authorization = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ProviderCredential credential = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
String context = inputs.stream()
.map(input -> "%s= %s".formatted(input.getDescriptor().getName(), input.getValue()))
@ -87,7 +88,7 @@ public class HumanDecisionExecutor implements BlockExecutor<HumanDecisionBlockTy
ModelParameterReporting.reportUnsupported(llmProvider, simulatorDescriptor.parameters(),
simulatorDescriptor.model(), eventLogger);
String response = llmProvider.generate(simulatorDescriptor.model(), prompt, authorization,
String response = llmProvider.generate(simulatorDescriptor.model(), prompt, credential,
simulatorDescriptor.parameters());
if (!StringUtils.hasText(response)) {
throw new IllegalArgumentException("Simulated HumanDecision produced an empty response");

View File

@ -21,6 +21,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.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
@ -68,11 +69,11 @@ public class HumanInteractionExecutor implements BlockExecutor<HumanInteractionB
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + llmDescriptor.provider());
}
String authorization = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ProviderCredential credential = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ModelParameterReporting.reportUnsupported(llmProvider, llmDescriptor.parameters(), llmDescriptor.model(),
eventLogger);
String response = llmProvider.generate(llmDescriptor.model(), prompt, authorization,
String response = llmProvider.generate(llmDescriptor.model(), prompt, credential,
llmDescriptor.parameters());
return Map.of("output", response);

View File

@ -26,6 +26,7 @@ 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.LLMDescriptorInputBinding;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
import it.cnr.isti.workflow.manager.skills.SkillPromptService;
@ -77,11 +78,11 @@ public class LLMExecutor implements BlockExecutor<LLMBlockType> {
if (llmProvider == null) {
throw new IllegalArgumentException("Provider not found: " + llmDescriptor.provider());
}
String authorization = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ProviderCredential credential = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ModelParameterReporting.reportUnsupported(llmProvider, llmDescriptor.parameters(), llmDescriptor.model(),
eventLogger);
String response = llmProvider.generate(llmDescriptor.model(), prompt, authorization,
String response = llmProvider.generate(llmDescriptor.model(), prompt, credential,
llmDescriptor.parameters());
if (eventLogger != null) {

View File

@ -28,6 +28,7 @@ import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasRuntimeSupport;
import it.cnr.isti.workflow.manager.llms.ConfigurableInputBinding;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
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;
@ -74,7 +75,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
throw new IllegalArgumentException("Missing simulation descriptor for MCPAgentChat execution");
}
LLMProvider simulatorProvider = SimulatedChatSupport.resolveProvider(llmProviders, simulatorDescriptor.provider());
String simulatorAuthorization = SimulatedChatSupport.resolveAuthorization(credentialResolver, simulatorProvider,
ProviderCredential simulatorCredential = SimulatedChatSupport.resolveAuthorization(credentialResolver, simulatorProvider,
authorizations, executionVariables);
String resolvedGoal = ExecutionTemplateResolver.resolve(configuration.getGoalDescription(), inputs, executionVariables);
resolvedGoal = BiasRuntimeSupport.decoratePrompt(resolvedGoal, executionVariables);
@ -92,7 +93,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
try {
for (int turn = 1; turn <= MAX_SIMULATED_INTERACTIONS; turn++) {
String simulatedMessage = SimulatedChatSupport.generateSimulatorMessage(simulatorProvider, simulatorDescriptor,
simulatorAuthorization, resolvedGoal, inputs, history, turn, MAX_SIMULATED_INTERACTIONS, "MCPAgentChat");
simulatorCredential, resolvedGoal, inputs, history, turn, MAX_SIMULATED_INTERACTIONS, "MCPAgentChat");
history.add(SimulatedChatSupport.formatConversationLine("USER", simulatedMessage));
String assistantResponse = mcpAgentService.querySession(sessionId, simulatedMessage);
@ -100,7 +101,7 @@ public class MCPAgentChatExecutor implements BlockExecutor<MCPAgentChatBlockType
}
String finalResponse = SimulatedChatSupport.generateSimulatorFinalResponse(simulatorProvider, simulatorDescriptor,
simulatorAuthorization, resolvedGoal, inputs, history, "MCPAgentChat");
simulatorCredential, resolvedGoal, inputs, history, "MCPAgentChat");
return buildResult(configuration, finalResponse, List.copyOf(history), null);
} finally {
if (!managedSharedSession) {

View File

@ -14,6 +14,7 @@ import it.cnr.isti.workflow.manager.executions.ExecutionTemplateResolver;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
final class SimulatedChatSupport {
@ -32,7 +33,7 @@ final class SimulatedChatSupport {
.orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName));
}
static String resolveAuthorization(LLMCredentialResolver credentialResolver, LLMProvider provider,
static ProviderCredential resolveAuthorization(LLMCredentialResolver credentialResolver, LLMProvider provider,
Map<String, Object> authorizations, Map<String, Object> executionVariables) {
return credentialResolver.resolve(provider, authorizations, executionVariables);
}
@ -72,7 +73,7 @@ final class SimulatedChatSupport {
}
static String generateSimulatorMessage(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor,
String simulatorAuthorization, String goalDescription, List<Input> inputs, List<String> history, int turn,
ProviderCredential simulatorCredential, String goalDescription, List<Input> inputs, List<String> history, int turn,
int maxTurns, String blockLabel) {
String prompt = """
###SIMULATED_CHAT_MESSAGE###
@ -89,7 +90,7 @@ final class SimulatedChatSupport {
Return only the next user message for the conversation.
""".formatted(goalDescription, turn, maxTurns, formatInputs(inputs), formatHistory(history));
String response = simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization,
String response = simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorCredential,
simulatorDescriptor.parameters());
if (!StringUtils.hasText(response)) {
throw new IllegalArgumentException("Simulated " + blockLabel + " produced an empty message");
@ -98,7 +99,7 @@ final class SimulatedChatSupport {
}
static String generateSimulatorFinalResponse(LLMProvider simulatorProvider, LLMDescriptor simulatorDescriptor,
String simulatorAuthorization, String goalDescription, List<Input> inputs, List<String> history, String blockLabel) {
ProviderCredential simulatorCredential, String goalDescription, List<Input> inputs, List<String> history, String blockLabel) {
String prompt = """
###SIMULATED_CHAT_FINAL###
Goal:
@ -112,7 +113,7 @@ final class SimulatedChatSupport {
Return only the final response value that the simulated user would submit.
""".formatted(goalDescription, formatInputs(inputs), formatHistory(history));
String response = simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorAuthorization,
String response = simulatorProvider.generate(simulatorDescriptor.model(), prompt, simulatorCredential,
simulatorDescriptor.parameters());
if (!StringUtils.hasText(response)) {
throw new IllegalArgumentException("Simulated " + blockLabel + " produced an empty final response");

View File

@ -33,6 +33,7 @@ import it.cnr.isti.workflow.manager.ios.IOType;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.LLMDescriptorInputBinding;
import it.cnr.isti.workflow.manager.llms.LLMCredentialResolver;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
@Component
@ -112,12 +113,12 @@ public class SwitchExecutor implements BlockExecutor<SwitchBlockType> {
ExecutionEventLogger eventLogger) {
LLMDescriptor llmDescriptor = LLMDescriptorInputBinding.resolve(config.getLlmDescriptor(), inputValues, executionVariables);
LLMProvider llmProvider = SimulatedChatSupport.resolveProvider(llmProviders, llmDescriptor.provider());
String authorization = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
ProviderCredential credential = credentialResolver.resolve(llmProvider, authorizations, executionVariables);
String prompt = buildLlmPrompt(config, inputValues, executionVariables, allowedOutputs);
ModelParameterReporting.reportUnsupported(llmProvider, llmDescriptor.parameters(), llmDescriptor.model(),
eventLogger);
String response = llmProvider.generate(llmDescriptor.model(), prompt, authorization,
String response = llmProvider.generate(llmDescriptor.model(), prompt, credential,
llmDescriptor.parameters());
return parseSelectedOutput(response, allowedOutputs);
}

View File

@ -23,7 +23,13 @@ public class LLMCredentialResolver {
this.userSecretService = userSecretService;
}
public String resolve(LLMProvider provider, Map<String, Object> authorizations,
/**
* Returns a {@link ProviderCredential}, carrying the endpoint alongside the secret value for a
* provider whose {@code requiresEndpoint()} is true. Not yet called from every executor: see
* {@code docs/llm-providers-openai-remote-ollama-plan-2026-09-17.md} step 11 for which call
* sites still resolve through the plain secret value only, and why.
*/
public ProviderCredential resolve(LLMProvider provider, Map<String, Object> authorizations,
Map<String, Object> executionVariables) {
if (!provider.requiresAuthorization()) {
return null;
@ -38,7 +44,7 @@ public class LLMCredentialResolver {
throw new IllegalArgumentException(
"A user-owned execution is required to resolve credential for provider: " + provider.getName());
}
return userSecretService.resolveValue(owner, credentialId.trim(), provider.getName());
return userSecretService.resolveCredential(owner, credentialId.trim(), provider.getName());
}
private String asText(Object value) {

View File

@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
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;
/**
* Not previously covered on its own - only ever exercised indirectly through a full execution.
* This pins the branching {@code resolve} itself does (no authorization required, no credential
* id, no owner) before it becomes even harder to isolate from what calls it.
*/
class LLMCredentialResolverTest {
private final UserSecretService userSecretService = mock(UserSecretService.class);
private final LLMCredentialResolver resolver = new LLMCredentialResolver(userSecretService);
private LLMProvider provider(boolean requiresAuthorization) {
return new LLMProvider() {
@Override
public String getName() {
return "Test";
}
@Override
public List<String> getRegisteredModels() {
return List.of();
}
@Override
public String generate(String model, String prompt) {
throw new UnsupportedOperationException();
}
@Override
public boolean requiresAuthorization() {
return requiresAuthorization;
}
};
}
@Test
void returnsNullWithoutTouchingTheVaultWhenTheProviderNeedsNoAuthorization() {
ProviderCredential result = resolver.resolve(provider(false), Map.of(), Map.of());
assertNull(result);
verify(userSecretService, never()).resolveCredential(any(), any(), any());
}
@Test
void throwsWhenTheAuthorizationsMapHasNoEntryForTheProvidersKey() {
LLMProvider provider = provider(true);
assertThrows(IllegalArgumentException.class,
() -> resolver.resolve(provider, Map.of(), Map.of(ExecutionRuntimeContextSupport.EXECUTION_OWNER, "alice")));
}
@Test
void throwsWhenTheCredentialIdIsBlank() {
LLMProvider provider = provider(true);
Map<String, Object> authorizations = Map.of(provider.authorizationKey(), " ");
assertThrows(IllegalArgumentException.class,
() -> resolver.resolve(provider, authorizations,
Map.of(ExecutionRuntimeContextSupport.EXECUTION_OWNER, "alice")));
}
@Test
void throwsWhenThereIsNoOwnedExecutionContext() {
LLMProvider provider = provider(true);
Map<String, Object> authorizations = Map.of(provider.authorizationKey(), "secret-1");
assertThrows(IllegalArgumentException.class, () -> resolver.resolve(provider, authorizations, Map.of()));
}
@Test
void resolvesThroughUserSecretServiceAndReturnsItsCredentialAsIs() {
LLMProvider provider = provider(true);
Map<String, Object> authorizations = Map.of(provider.authorizationKey(), " secret-1 ");
Map<String, Object> executionVariables = Map.of(ExecutionRuntimeContextSupport.EXECUTION_OWNER, "alice");
ProviderCredential expected = new ProviderCredential("api-key", "https://gateway.example.com");
when(userSecretService.resolveCredential("alice", "secret-1", "Test")).thenReturn(expected);
ProviderCredential result = resolver.resolve(provider, authorizations, executionVariables);
assertEquals(expected, result);
// The id is trimmed before being used - a pasted value with surrounding whitespace must
// still resolve.
verify(userSecretService).resolveCredential(eq("alice"), eq("secret-1"), eq("Test"));
}
}