From a0fdfcda3517b67a6537dba496e124d6c1d457f8 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 17 Sep 2026 12:30:13 +0200 Subject: [PATCH] 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 --- src/app/models/assistant.ts | 4 ++ src/app/models/llm-provider.ts | 2 + .../llm-provider/llm-provider-call.fake.ts | 6 +-- .../llm-provider/llm-provider-call.spec.ts | 7 +-- .../llm-provider/llm-provider-call.ts | 3 +- src/app/services/vault/vault-call.base.ts | 1 + .../execution-viewer.utils.spec.ts | 30 ++++++++++++- .../execution-viewer.utils.ts | 12 +++-- .../task-execution-viewer.ts | 45 ++++++++++++++----- 9 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/app/models/assistant.ts b/src/app/models/assistant.ts index d486ce1..1a07b50 100644 --- a/src/app/models/assistant.ts +++ b/src/app/models/assistant.ts @@ -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 = { diff --git a/src/app/models/llm-provider.ts b/src/app/models/llm-provider.ts index a1635b5..a7b7e8a 100644 --- a/src/app/models/llm-provider.ts +++ b/src/app/models/llm-provider.ts @@ -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 = { diff --git a/src/app/services/llm-provider/llm-provider-call.fake.ts b/src/app/services/llm-provider/llm-provider-call.fake.ts index c63fe10..bcc7f89 100644 --- a/src/app/services/llm-provider/llm-provider-call.fake.ts +++ b/src/app/services/llm-provider/llm-provider-call.fake.ts @@ -9,9 +9,9 @@ import { LlmProviderCallServiceBase } from './llm-provider-call.base'; export class LlmProviderCallServiceFake extends LlmProviderCallServiceBase { override listCapabilities(): Observable { 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 } ]); } } diff --git a/src/app/services/llm-provider/llm-provider-call.spec.ts b/src/app/services/llm-provider/llm-provider-call.spec.ts index a70c1a0..97d7e54 100644 --- a/src/app/services/llm-provider/llm-provider-call.spec.ts +++ b/src/app/services/llm-provider/llm-provider-call.spec.ts @@ -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 } ]); }); }); diff --git a/src/app/services/llm-provider/llm-provider-call.ts b/src/app/services/llm-provider/llm-provider-call.ts index 7b4df03..6885d7f 100644 --- a/src/app/services/llm-provider/llm-provider-call.ts +++ b/src/app/services/llm-provider/llm-provider-call.ts @@ -20,7 +20,8 @@ export class LlmProviderCallService extends LlmProviderCallServiceBase { .filter((item): item is Record => !!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); }) diff --git a/src/app/services/vault/vault-call.base.ts b/src/app/services/vault/vault-call.base.ts index 5add6fa..857d5e2 100644 --- a/src/app/services/vault/vault-call.base.ts +++ b/src/app/services/vault/vault-call.base.ts @@ -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, diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index b98176a..7f7b8ad 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -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], diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index a81f45f..394e04f 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -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; } /** diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 61787a3..8ab9769 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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);