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:
parent
5787a6fe44
commit
a0fdfcda35
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue