Show an endpoint field on the execution's "Add credential" dialog

The backend catalog (/llm/providers) now publishes a requiresEndpoint
flag per provider. LlmProviderCapability grows the matching field, and
task-execution-viewer.ts's credential-requirement dialog shows an
"Endpoint URL" field only when the provider being satisfied needs one -
every existing provider still gets the same three fields it always had.

VaultSecret, VaultSecretCreateRequest and VaultSecretUpdateRequest grow
an optional endpoint too, so it round-trips through createSecret and back
out of the credential list unchanged for a provider that has none.

The gate in execution-viewer.utils.ts (buildAuthorizationGate) reads the
same flag onto each VaultAuthorizationEntry, generalising the single
resolveRequiresCredential lookup it already had into
resolveProviderCapability, shared between requiresCredential and the new
requiresEndpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-17 12:30:13 +02:00
parent 5787a6fe44
commit a0fdfcda35
9 changed files with 85 additions and 25 deletions

View File

@ -69,6 +69,8 @@ export type VaultSecret = {
id: string;
label: string;
provider: string;
/** Set only for a provider whose base URL is not known in advance, e.g. an OpenAI-compatible gateway. */
endpoint?: string;
description?: string;
active: boolean;
lastUsedAt?: string;
@ -80,6 +82,7 @@ export type VaultSecretCreateRequest = {
provider: string;
description?: string;
value: string;
endpoint?: string;
};
export type VaultSecretUpdateRequest = {
@ -87,6 +90,7 @@ export type VaultSecretUpdateRequest = {
description?: string;
active?: boolean;
value?: string;
endpoint?: string;
};
export type AssistantSessionRequest = {

View File

@ -5,6 +5,8 @@
export type LlmProviderCapability = {
name: string;
requiresCredential: boolean;
/** Whether picking this provider means the saved credential must also carry a base URL. */
requiresEndpoint: boolean;
};
export type ExecutionVaultCredential = {

View File

@ -9,9 +9,9 @@ import { LlmProviderCallServiceBase } from './llm-provider-call.base';
export class LlmProviderCallServiceFake extends LlmProviderCallServiceBase {
override listCapabilities(): Observable<LlmProviderCapability[]> {
return of([
{ name: 'InternalOllama', requiresCredential: false },
{ name: 'testProvider', requiresCredential: true },
{ name: 'Gemini', requiresCredential: true }
{ name: 'InternalOllama', requiresCredential: false, requiresEndpoint: false },
{ name: 'testProvider', requiresCredential: true, requiresEndpoint: false },
{ name: 'Gemini', requiresCredential: true, requiresEndpoint: false }
]);
}
}

View File

@ -27,11 +27,12 @@ describe('LlmProviderCallService', () => {
const result = firstValueFrom(service.listCapabilities());
httpMock.expectOne(`${environment.apiUrl}/llm/providers`).flush([
{ name: 'InternalOllama', requiresCredential: false },
{ name: 'OpenAI', requiresCredential: true }
{ name: 'OpenAI', requiresCredential: true, requiresEndpoint: true }
]);
await expect(result).resolves.toEqual([
{ name: 'InternalOllama', requiresCredential: false },
{ name: 'OpenAI', requiresCredential: true }
// Absent on the wire defaults to false, so an older backend response stays interpretable.
{ name: 'InternalOllama', requiresCredential: false, requiresEndpoint: false },
{ name: 'OpenAI', requiresCredential: true, requiresEndpoint: true }
]);
});
});

View File

@ -20,7 +20,8 @@ export class LlmProviderCallService extends LlmProviderCallServiceBase {
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
.map((item) => ({
name: String(item['name'] ?? '').trim(),
requiresCredential: item['requiresCredential'] === true
requiresCredential: item['requiresCredential'] === true,
requiresEndpoint: item['requiresEndpoint'] === true
}))
.filter((item) => item.name.length > 0);
})

View File

@ -27,6 +27,7 @@ export function mapVaultSecret(raw: unknown): VaultSecret {
id: String(value['id'] ?? value['secretId'] ?? ''),
label: String(value['label'] ?? ''),
provider: String(value['provider'] ?? ''),
endpoint: typeof value['endpoint'] === 'string' && value['endpoint'].length > 0 ? value['endpoint'] : undefined,
description: typeof value['description'] === 'string' ? value['description'] : undefined,
active: value['active'] !== false && value['enabled'] !== false,
lastUsedAt: typeof value['lastUsedAt'] === 'string' ? value['lastUsedAt'] : undefined,

View File

@ -88,8 +88,9 @@ describe('authorization gate', () => {
};
const capabilities = [
{ name: 'InternalOllama', requiresCredential: false },
{ name: 'Gemini', requiresCredential: true }
{ name: 'InternalOllama', requiresCredential: false, requiresEndpoint: false },
{ name: 'Gemini', requiresCredential: true, requiresEndpoint: false },
{ name: 'OpenAICompatible', requiresCredential: true, requiresEndpoint: true }
];
const readyState = { capabilities, loading: false, failed: false };
@ -136,9 +137,34 @@ describe('authorization gate', () => {
expect(gate.runtime).toEqual([]);
expect(gate.vault.map((entry) => entry.provider)).toEqual(['Gemini']);
expect(gate.vault[0].requiresCredential).toBeNull();
expect(gate.vault[0].requiresEndpoint).toBeNull();
expect(gate.missingProviders).toEqual(['Gemini']);
});
it('reports requiresEndpoint from the catalog, false for a provider that does not need one', () => {
const gate = buildAuthorizationGate(execution(), readyState);
expect(gate.vault[0].requiresEndpoint).toBe(false);
});
it('reports requiresEndpoint true for a provider whose credential must carry a base URL', () => {
const openAiCompatibleRequirement = {
key: 'LLMProvider::OpenAICompatible::authorization',
provider: 'OpenAICompatible',
fieldName: 'authorization',
description: 'Select a saved credential for OpenAICompatible.',
requiredBySteps: ['step-1']
};
const withEndpointProvider = execution({
requiredAuthorizations: [openAiCompatibleRequirement],
missingAuthorizationKeys: [openAiCompatibleRequirement.key]
});
const gate = buildAuthorizationGate(withEndpointProvider, readyState);
expect(gate.vault[0].requiresEndpoint).toBe(true);
});
it('routes authorizations that are not provider credentials to the literal-value panel', () => {
const withHeader = execution({
requiredAuthorizations: [geminiRequirement, headerRequirement],

View File

@ -439,6 +439,8 @@ export type VaultAuthorizationEntry = {
provider: string;
/** `null` while the provider catalog is unavailable, so the UI cannot claim either way. */
requiresCredential: boolean | null;
/** Same rule: `null` while the catalog is unavailable, true only for a handful of providers. */
requiresEndpoint: boolean | null;
};
export type AuthorizationGate = {
@ -498,7 +500,8 @@ export function buildAuthorizationGate(
const entry: VaultAuthorizationEntry = {
requirement,
provider,
requiresCredential: resolveRequiresCredential(provider, capabilityState)
requiresCredential: resolveProviderCapability(provider, capabilityState, (capability) => capability.requiresCredential),
requiresEndpoint: resolveProviderCapability(provider, capabilityState, (capability) => capability.requiresEndpoint)
};
(missing ? vault : satisfiedVault).push(entry);
continue;
@ -518,16 +521,17 @@ export function buildAuthorizationGate(
};
}
function resolveRequiresCredential(
function resolveProviderCapability(
provider: string,
capabilityState: AuthorizationCapabilityState
capabilityState: AuthorizationCapabilityState,
select: (capability: LlmProviderCapability) => boolean
): boolean | null {
if (capabilityState.loading || capabilityState.failed) return null;
const wanted = provider.trim().toLowerCase();
const match = capabilityState.capabilities.find((capability) =>
capability.name.trim().toLowerCase() === wanted
);
return match ? match.requiresCredential : null;
return match ? select(match) : null;
}
/**

View File

@ -1038,20 +1038,31 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const fields: NodeSettingField[] = [
{ key: 'provider', label: 'Provider', type: 'display' },
{ key: 'label', label: 'Label', type: 'text', required: true, autofocus: true },
{ key: 'description', label: 'Description (optional)', type: 'text' },
{
key: 'value',
label: 'API key',
type: 'password',
required: true,
tip: 'The value will not be shown after saving.'
}
{ key: 'description', label: 'Description (optional)', type: 'text' }
];
// Only a handful of providers need one - see LLMProvider.requiresEndpoint() - so the field is
// shown only for those, rather than asking every user for a URL most providers already know.
if (this.providerRequiresEndpoint(provider)) {
fields.push({
key: 'endpoint',
label: 'Endpoint URL',
type: 'text',
required: true,
tip: 'The base URL to call, e.g. https://api.example.com/v1'
});
}
fields.push({
key: 'value',
label: 'API key',
type: 'password',
required: true,
tip: 'The value will not be shown after saving.'
});
const result = await this.settingsDialog.open({
title: `Add ${provider} credential`,
fields,
initial: { provider, label: '', description: '', value: '' }
initial: { provider, label: '', description: '', endpoint: '', value: '' }
});
if (!result) return;
@ -1059,18 +1070,28 @@ export class TaskExecutionViewerComponent implements OnDestroy {
provider,
String(result['label'] ?? '').trim(),
String(result['description'] ?? '').trim(),
String(result['value'] ?? '')
String(result['value'] ?? ''),
String(result['endpoint'] ?? '').trim()
);
}
private saveExecutionCredential(provider: string, label: string, description: string, value: string) {
private providerRequiresEndpoint(provider: string): boolean {
const wanted = provider.trim().toLowerCase();
return this.llmProviderCapabilities().find((capability) =>
capability.name.trim().toLowerCase() === wanted
)?.requiresEndpoint ?? false;
}
private saveExecutionCredential(provider: string, label: string, description: string, value: string,
endpoint: string) {
if (!provider || !label || !value.trim() || this.credentialFormSaving()) return;
this.credentialFormSaving.set(true);
this.vaultService.createSecret({
provider,
label,
description: description || undefined,
value
value,
endpoint: endpoint || undefined
}).pipe(take(1)).subscribe({
next: (credential) => {
this.credentialFormSaving.set(false);