Let the simulator and bias judge dialogs supply a credential

The backend now accepts a credentialId for both the interaction simulator
and the bias judge, but nothing in the UI could provide one - a
credential-requiring provider chosen either way would always fail, short of
coincidentally reusing a credential from elsewhere in the same execution.

openLLMDescriptorSettings (the one dialog shared by both the simulator and
the bias judge picker) stays as it is, tests included. A new
openLLMDescriptorSettingsWithCredential wraps it: once a provider and model
are chosen, if LlmProviderService.listCapabilities() says that provider
requiresCredential, a second dialog lists the user's saved credentials for
it (ExecutionVaultCredentialsService.listForProvider). Two separate modals
rather than one field folded into the first, since NodeSettingsDialogService
only ever holds one dialog open at a time - stacking a "create credential"
flow inside this one was not attempted, matching the pre-existing, already
documented gap that there is nowhere in the app to manage credentials
outside of creating them.

simulateExecution() in task-execution-viewer.ts also now surfaces a
rejected simulation's error message instead of failing silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-17 14:51:16 +02:00
parent b13c2019eb
commit bb5621f559
9 changed files with 230 additions and 34 deletions

View File

@ -69,7 +69,7 @@ describe('BiasReportJudgeService', () => {
provider: 'InternalOllama',
model: 'gemma:7b',
parameters: { temperature: 0 }
});
}, undefined);
});
it('pre-fills no sampling, leaving the provider its own baseline', async () => {

View File

@ -5,10 +5,12 @@
import { inject, Injectable } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { BiasImpactReport } from '@models/bias-impact';
import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials';
import { LlmProviderService } from '@services/llm-provider/llm-provider';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { openLLMDescriptorSettings } from '@shared/llm-descriptor-settings/llm-descriptor-settings';
import { openLLMDescriptorSettingsWithCredential } from '@shared/llm-descriptor-settings/llm-descriptor-settings';
/**
* Asks a model to assess a comparison, from wherever that comparison is on screen.
@ -26,18 +28,23 @@ export class BiasReportJudgeService {
private readonly executions = inject(TaskExecutionsService);
private readonly settingsDialog = inject(NodeSettingsDialogService);
private readonly fieldRetriever = inject(FieldRetriever);
private readonly llmProviderService = inject(LlmProviderService);
private readonly executionVaultCredentials = inject(ExecutionVaultCredentialsService);
/**
* Resolves with the assessed report, or null when the model picker was dismissed or no provider
* is published at all. Rejects with the failure of the assessment itself.
* Resolves with the assessed report, or null when the model picker (or, for a provider that
* needs one, the credential picker after it) was dismissed, or no provider is published at all.
* Rejects with the failure of the assessment itself.
*/
async assess(reportId: string): Promise<BiasImpactReport | null> {
const judge = await openLLMDescriptorSettings(this.settingsDialog, this.fieldRetriever, {
title: 'Evaluate impact with LLM'
});
if (!judge) return null;
const chosen = await openLLMDescriptorSettingsWithCredential(
this.settingsDialog, this.fieldRetriever, this.llmProviderService, this.executionVaultCredentials, {
title: 'Evaluate impact with LLM'
});
if (!chosen) return null;
const queued = await lastValueFrom(this.executions.judgeBiasImpactReport(reportId, judge));
const queued = await lastValueFrom(
this.executions.judgeBiasImpactReport(reportId, chosen.descriptor, chosen.credentialId));
const finished = await lastValueFrom(this.executions.pollBiasImpactJob(queued.id));
if (finished.status !== 'COMPLETED' || !finished.report) {
throw new Error(finished.errorMessage || 'The LLM assessment did not complete.');

View File

@ -40,7 +40,7 @@ export abstract class TaskExecutionsCallServiceBase {
* Asks a model to assess a comparison that has already been computed. Asynchronous like the
* isolated experiment - one model call per compared pair - so it answers with a job to poll.
*/
abstract judgeBiasImpactReport(reportId: string, judge: LLMDescriptor): Observable<BiasImpactJob>;
abstract judgeBiasImpactReport(reportId: string, judge: LLMDescriptor, credentialId?: string): Observable<BiasImpactJob>;
abstract getBiasImpactJob(jobId: string): Observable<BiasImpactJob>;
abstract createBiasedRerun(executionId: string, request: BiasRerunRequest): Observable<TaskExecution>;
abstract compareBiasExecutions(
@ -52,7 +52,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract getBiasImpactReport(reportId: string): Observable<BiasImpactReport>;
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution>;
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string): Observable<TaskExecution>;
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;
abstract resumeTaskExecution(executionId: string): Observable<TaskExecution>;
abstract prepareStringInput(

View File

@ -653,7 +653,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(job);
}
override judgeBiasImpactReport(reportId: string, judge: LLMDescriptor): Observable<BiasImpactJob> {
override judgeBiasImpactReport(reportId: string, judge: LLMDescriptor, _credentialId?: string): Observable<BiasImpactJob> {
const report = this.biasReports.find((item) => item.id === reportId);
if (!report) throw new Error(`Bias impact report not found: ${reportId}`);
if (!judge?.provider?.trim() || !judge?.model?.trim()) {
@ -866,7 +866,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor, _credentialId?: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
if (execution.simulationAvailable !== true) {
throw new Error('Simulation is not available for this execution.');

View File

@ -137,9 +137,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.post<unknown>(url, request).pipe(map((raw) => this.biasImpactJobFromApi(raw)));
}
override judgeBiasImpactReport(reportId: string, judge: LLMDescriptor): Observable<BiasImpactJob> {
override judgeBiasImpactReport(reportId: string, judge: LLMDescriptor, credentialId?: string): Observable<BiasImpactJob> {
const url = `${environment.apiUrl}/executions/bias-impact-reports/${encodeURIComponent(reportId)}/judge`;
return this.http.post<unknown>(url, { judge }).pipe(map((raw) => this.biasImpactJobFromApi(raw)));
return this.http.post<unknown>(url, { judge, credentialId }).pipe(map((raw) => this.biasImpactJobFromApi(raw)));
}
override getBiasImpactJob(jobId: string): Observable<BiasImpactJob> {
@ -187,8 +187,11 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
);
}
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }).pipe(
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string): Observable<TaskExecution> {
return this.http.put<unknown>(
`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`,
{ simulator, credentialId }
).pipe(
map((raw) => this.mapExecution(raw))
);
}

View File

@ -181,8 +181,8 @@ export class TaskExecutionsService {
);
}
judgeBiasImpactReport(reportId: string, judge: LLMDescriptor): Observable<BiasImpactJob> {
return this.taskExecutionsCallService.judgeBiasImpactReport(reportId, judge).pipe(
judgeBiasImpactReport(reportId: string, judge: LLMDescriptor, credentialId?: string): Observable<BiasImpactJob> {
return this.taskExecutionsCallService.judgeBiasImpactReport(reportId, judge, credentialId).pipe(
catchError((error) => throwError(() => this.toBiasOperationError(error)))
);
}
@ -269,7 +269,7 @@ export class TaskExecutionsService {
);
}
simulateExecution(executionId: string, simulator: LLMDescriptor) {
simulateExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.simulationAvailable !== true) {
return throwError(() => new Error('Simulation is not available for this execution.'));
@ -279,7 +279,7 @@ export class TaskExecutionsService {
}
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.simulateTaskExecution(executionId, simulator),
this.taskExecutionsCallService.simulateTaskExecution(executionId, simulator, credentialId),
'Simulate execution failed'
);
}

View File

@ -2,11 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-provider';
import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials';
import { LlmProviderService } from '@services/llm-provider/llm-provider';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { openLLMDescriptorSettings } from './llm-descriptor-settings';
import { openLLMDescriptorSettings, openLLMDescriptorSettingsWithCredential } from './llm-descriptor-settings';
describe('openLLMDescriptorSettings', () => {
const models: Record<string, string[]> = {
@ -109,3 +112,95 @@ describe('openLLMDescriptorSettings', () => {
expect(await openLLMDescriptorSettings(service, retriever(), { title: 'Simulation Settings' })).toBeNull();
});
});
describe('openLLMDescriptorSettingsWithCredential', () => {
function retriever(providers = ['InternalOllama', 'CredentialProvider']): FieldRetriever {
return {
retrieveValues: vi.fn((_type: string, key: string) =>
of(key === 'providers' ? providers : ['some-model']))
} as unknown as FieldRetriever;
}
function providerService(capabilities: LlmProviderCapability[]): LlmProviderService {
return { listCapabilities: vi.fn().mockReturnValue(of(capabilities)) } as unknown as LlmProviderService;
}
function vaultCredentials(credentials: ExecutionVaultCredential[]): ExecutionVaultCredentialsService {
return { listForProvider: vi.fn().mockReturnValue(of(credentials)) } as unknown as ExecutionVaultCredentialsService;
}
const CAPABILITIES: LlmProviderCapability[] = [
{ name: 'InternalOllama', requiresCredential: false, requiresEndpoint: false },
{ name: 'CredentialProvider', requiresCredential: true, requiresEndpoint: false }
];
it('answers with just the descriptor when the chosen provider needs no credential', async () => {
const open = vi.fn().mockResolvedValue({ provider: 'InternalOllama', model: 'some-model' });
const settingsDialog = { open } as unknown as NodeSettingsDialogService;
const result = await openLLMDescriptorSettingsWithCredential(
settingsDialog, retriever(), providerService(CAPABILITIES), vaultCredentials([]), { title: 'x' });
expect(result).toEqual({ descriptor: { provider: 'InternalOllama', model: 'some-model' } });
// Nothing to pick, so only the model dialog opened - never a second one for a credential.
expect(open).toHaveBeenCalledTimes(1);
});
it('opens a credential picker and carries the choice alongside the descriptor', async () => {
const open = vi.fn()
.mockResolvedValueOnce({ provider: 'CredentialProvider', model: 'some-model' })
.mockResolvedValueOnce({ credentialId: 'secret-1' });
const settingsDialog = { open } as unknown as NodeSettingsDialogService;
const credentials = vaultCredentials([{ id: 'secret-1', label: 'My key', provider: 'CredentialProvider' }]);
const result = await openLLMDescriptorSettingsWithCredential(
settingsDialog, retriever(), providerService(CAPABILITIES), credentials, { title: 'x' });
expect(result).toEqual({
descriptor: { provider: 'CredentialProvider', model: 'some-model' },
credentialId: 'secret-1'
});
expect(open).toHaveBeenCalledTimes(2);
const credentialFields: Array<{ options?: Array<{ label: string; value: string }> }> = open.mock.calls[1][0].fields;
expect(credentialFields[0].options).toEqual([{ label: 'My key', value: 'secret-1' }]);
});
it('answers null when the credential picker is dismissed', async () => {
const open = vi.fn()
.mockResolvedValueOnce({ provider: 'CredentialProvider', model: 'some-model' })
.mockResolvedValueOnce(null);
const settingsDialog = { open } as unknown as NodeSettingsDialogService;
const result = await openLLMDescriptorSettingsWithCredential(
settingsDialog, retriever(), providerService(CAPABILITIES), vaultCredentials([]), { title: 'x' });
expect(result).toBeNull();
});
it('says so in the tip when the provider has no saved credential yet', async () => {
const open = vi.fn().mockResolvedValueOnce({ provider: 'CredentialProvider', model: 'some-model' })
.mockResolvedValueOnce(null);
const settingsDialog = { open } as unknown as NodeSettingsDialogService;
await openLLMDescriptorSettingsWithCredential(
settingsDialog, retriever(), providerService(CAPABILITIES), vaultCredentials([]), { title: 'x' });
const credentialFields: Array<{ options?: unknown[]; tip?: string }> = open.mock.calls[1][0].fields;
expect(credentialFields[0].options).toEqual([]);
expect(credentialFields[0].tip).toContain('No saved credential');
});
it('treats a provider catalog that could not be loaded as needing no credential', async () => {
const open = vi.fn().mockResolvedValue({ provider: 'CredentialProvider', model: 'some-model' });
const settingsDialog = { open } as unknown as NodeSettingsDialogService;
const brokenProviderService = {
listCapabilities: vi.fn().mockReturnValue(throwError(() => new Error('offline')))
} as unknown as LlmProviderService;
const result = await openLLMDescriptorSettingsWithCredential(
settingsDialog, retriever(), brokenProviderService, vaultCredentials([]), { title: 'x' });
expect(result).toEqual({ descriptor: { provider: 'CredentialProvider', model: 'some-model' } });
expect(open).toHaveBeenCalledTimes(1);
});
});

View File

@ -4,6 +4,8 @@
import { firstValueFrom } from 'rxjs';
import { LLMDescriptor } from '@models/flow';
import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials';
import { LlmProviderService } from '@services/llm-provider/llm-provider';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { readSimulatorParameters } from '@shared/task-execution-viewer/execution-viewer.utils';
@ -178,3 +180,85 @@ async function loadOptions(
const values = await firstValueFrom(fieldRetriever.retrieveValues('LLM', key, context, retrieverUrl));
return values.map((value) => ({ label: value, value }));
}
export type LLMDescriptorWithCredential = {
descriptor: LLMDescriptor;
/** Present only when the chosen provider needed one, and one was picked. */
credentialId?: string;
};
/**
* {@link openLLMDescriptorSettings}, followed by a credential picker when the chosen provider
* needs one.
*
* <p>The interaction simulator and the bias judge are asked for their model outside of any node's
* configuration, so - unlike a flow's own steps - there is no `requiredAuthorizations` entry
* computed up front to gate them on a saved credential. Without this, a credential-requiring
* provider chosen here could only ever work by coincidence, if the execution already happened to
* carry a saved credential for that same provider from one of its own steps.
*
* <p>A second, separate dialog rather than a field folded into the first: the two ask genuinely
* different questions - which model, then which of *your* saved keys for it - and keeping them
* apart means the model dialog above is untouched by this, tests included.
*
* <p>Resolves to null when either dialog is dismissed, or when the chosen provider needs a
* credential and none is picked - the caller already treats a null result as "nothing to do".
*/
export async function openLLMDescriptorSettingsWithCredential(
settingsDialog: NodeSettingsDialogService,
fieldRetriever: FieldRetriever,
llmProviderService: LlmProviderService,
executionVaultCredentials: ExecutionVaultCredentialsService,
request: LLMDescriptorSettingsRequest
): Promise<LLMDescriptorWithCredential | null> {
const descriptor = await openLLMDescriptorSettings(settingsDialog, fieldRetriever, request);
if (!descriptor) return null;
const needsCredential = await providerRequiresCredential(llmProviderService, descriptor.provider);
if (!needsCredential) return { descriptor };
const credentialId = await pickCredential(settingsDialog, executionVaultCredentials, descriptor.provider);
return credentialId ? { descriptor, credentialId } : null;
}
async function providerRequiresCredential(llmProviderService: LlmProviderService, provider: string): Promise<boolean> {
try {
const capabilities = await firstValueFrom(llmProviderService.listCapabilities());
const wanted = provider.trim().toLowerCase();
return capabilities.some((capability) => capability.name.trim().toLowerCase() === wanted && capability.requiresCredential);
} catch {
// The same conservative default used everywhere else a capability cannot be resolved: nothing
// blocks the run, and a provider that really did need a credential still refuses it, at the
// server, with a clear reason - it just does so a step later than it could have here.
return false;
}
}
async function pickCredential(
settingsDialog: NodeSettingsDialogService,
executionVaultCredentials: ExecutionVaultCredentialsService,
provider: string
): Promise<string | null> {
const credentials = await firstValueFrom(executionVaultCredentials.listForProvider(provider));
const options = credentials.map((credential) => ({ label: credential.label, value: credential.id }));
const result = await settingsDialog.open({
title: `Credential for ${provider}`,
fields: [{
key: 'credentialId',
label: 'Credential',
type: 'select',
required: true,
autofocus: true,
options,
tip: options.length
? undefined
: `No saved credential for ${provider} yet. Add one for this provider, then reopen this dialog.`
}],
initial: { credentialId: options[0]?.value ?? '' }
});
if (!result) return null;
const credentialId = String(result['credentialId'] ?? '').trim();
return credentialId || null;
}

View File

@ -47,7 +47,10 @@ import {
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { openLLMDescriptorSettings } from '@shared/llm-descriptor-settings/llm-descriptor-settings';
import {
LLMDescriptorWithCredential,
openLLMDescriptorSettingsWithCredential
} from '@shared/llm-descriptor-settings/llm-descriptor-settings';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { FlowsService } from '@services/flows/flows';
@ -1179,9 +1182,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const executionId = this.execution()?.id;
if (!executionId || !this.canSimulateExecution()) return;
let simulator: LLMDescriptor | null;
let chosen: LLMDescriptorWithCredential | null;
try {
simulator = await this.openSimulationSettings();
chosen = await this.openSimulationSettings();
} catch (error) {
// No provider to choose from, or the list could not be loaded. Silence here reads as a dead
// button, which is what the picker is opened by.
@ -1192,12 +1195,15 @@ export class TaskExecutionViewerComponent implements OnDestroy {
);
return;
}
if (!simulator) return;
if (!chosen) return;
this.simulateInProgress.set(true);
this.taskExecutionsService.simulateExecution(executionId, simulator).subscribe({
this.taskExecutionsService.simulateExecution(executionId, chosen.descriptor, chosen.credentialId).subscribe({
next: () => this.simulateInProgress.set(false),
error: () => this.simulateInProgress.set(false)
error: (error: unknown) => {
this.simulateInProgress.set(false);
this.notifications.show(this.executionErrorMessage(error), 'error', 6000);
}
});
}
@ -1510,14 +1516,15 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
private openSimulationSettings(): Promise<LLMDescriptor | null> {
private openSimulationSettings(): Promise<LLMDescriptorWithCredential | null> {
// A rerun carries the simulator of the run it repeats, and that is what the dialog starts from:
// comparing two runs answered by different simulators compares the simulators too.
const inherited = this.inheritedSimulator();
return openLLMDescriptorSettings(this.settingsDialog, this.fieldRetriever, {
title: inherited ? 'Simulation Settings - repeating a simulated run' : 'Simulation Settings',
initialDescriptor: inherited
});
return openLLMDescriptorSettingsWithCredential(
this.settingsDialog, this.fieldRetriever, this.llmProviderService, this.executionVaultCredentials, {
title: inherited ? 'Simulation Settings - repeating a simulated run' : 'Simulation Settings',
initialDescriptor: inherited
});
}
private fetchExecutionLogs(executionId: string) {