diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.html new file mode 100644 index 000000000000..17cd78c4f37e --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.html @@ -0,0 +1,39 @@ +
+ + {{ 'apps.ai.additional-properties.label' | dm }} + + + @if (properties().length > 0) { +
+ @for (property of properties().controls; track $index; let i = $index) { +
+ + + +
+ } +
+ } + + +
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.ts new file mode 100644 index 000000000000..d13e776f7e61 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-additional-properties/dot-ai-additional-properties.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; + +import { DotMessagePipe } from '@dotcms/ui'; + +export type DotAiAdditionalPropertyGroup = FormGroup<{ + key: FormControl; + value: FormControl; +}>; + +/** + * A free-form key/value escape hatch for provider-specific settings the dynamic form doesn't + * model. The backend may not read a given key today — this only builds the payload; nothing + * here validates it against the provider's actual API. + */ +@Component({ + selector: 'dot-ai-additional-properties', + templateUrl: './dot-ai-additional-properties.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, ButtonModule, InputTextModule, DotMessagePipe] +}) +export class DotAiAdditionalPropertiesComponent { + readonly properties = input.required>(); + + addProperty(): void { + this.properties().push( + new FormGroup({ + key: new FormControl('', { nonNullable: true }), + value: new FormControl('', { nonNullable: true }) + }) + ); + } + + removeProperty(index: number): void { + this.properties().removeAt(index); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.html new file mode 100644 index 000000000000..2ed003fd1428 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.html @@ -0,0 +1,102 @@ +
+ +
+ +
+
+ {{ meta().title | dm }} + +
+ {{ meta().description | dm }} +
+ +
+ + @if (enabled()) { + +
+ + {{ 'apps.ai.provider.label' | dm }} + +
+ @for (provider of orderedProviders(); track provider.provider) { + + } +
+
+ + + @if (visibleFields().length > 0) { +
+ @for (field of visibleFields(); track field.name) { + + } +
+ } + + + +
+ @if (advancedFields().length > 0) { +
+ @for (field of advancedFields(); track field.name) { + + } +
+ } + +
+
+ +
+ + + @if (testResult(); as result) { + + + {{ result.message | dm }} + + } +
+ } +
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.spec.ts new file mode 100644 index 000000000000..a84a9afd9214 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.spec.ts @@ -0,0 +1,363 @@ +import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { DotAiService, DotMessageService } from '@dotcms/data-access'; +import { + DotAiCapability, + DotAiProviderFieldType, + DotAiProviderMetadata +} from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotAiCapabilityCardComponent } from './dot-ai-capability-card.component'; + +import { DotAiCapabilityMeta } from '../../dot-ai-config.constants'; + +describe('DotAiCapabilityCardComponent', () => { + let spectator: Spectator; + + const chatMeta: DotAiCapabilityMeta = { + capability: DotAiCapability.CHAT, + sectionKey: 'chat', + title: 'apps.ai.capability.chat.title', + description: 'apps.ai.capability.chat.description', + icon: 'pi pi-comments' + }; + + const openAiProvider: DotAiProviderMetadata = { + provider: 'openai', + supportedCapabilities: [DotAiCapability.CHAT], + fields: { + [DotAiCapability.CHAT]: [ + { name: 'apiKey', type: DotAiProviderFieldType.SECRET, required: true, hint: '' }, + { name: 'model', type: DotAiProviderFieldType.STRING, required: true, hint: '' } + ] + } + }; + + const googleAiProvider: DotAiProviderMetadata = { + provider: 'google_ai', + supportedCapabilities: [DotAiCapability.CHAT], + fields: { + [DotAiCapability.CHAT]: [ + { name: 'apiKey', type: DotAiProviderFieldType.SECRET, required: true, hint: '' }, + { name: 'model', type: DotAiProviderFieldType.STRING, required: true, hint: '' } + ] + } + }; + + const createComponent = createComponentFactory({ + component: DotAiCapabilityCardComponent, + providers: [ + mockProvider(DotAiService), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ], + schemas: [NO_ERRORS_SCHEMA], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + meta: chatMeta, + providers: [openAiProvider, googleAiProvider] + } + }); + }); + + describe('selectProvider', () => { + it('does not carry over apiKey/model when switching to a different provider', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + + spectator.component.fieldsGroup().patchValue({ + apiKey: 'sk-openai-secret', + model: 'gpt-4o' + }); + + spectator.component.selectProvider(googleAiProvider); + + const values = spectator.component.fieldsGroup().value; + expect(values['apiKey']).toBeNull(); + expect(values['model']).toBeNull(); + }); + + it('rebuilds the fields group for the newly selected provider', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + spectator.component.selectProvider(googleAiProvider); + + expect(spectator.component.providerId()).toBe('google_ai'); + }); + + it('does nothing when re-selecting the already-active provider', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + spectator.component.fieldsGroup().patchValue({ apiKey: 'sk-openai-secret' }); + + spectator.component.selectProvider(openAiProvider); + + expect(spectator.component.fieldsGroup().value['apiKey']).toBe('sk-openai-secret'); + }); + + it('clears additional properties carried over from the previous provider', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + spectator.component.additionalProperties.push( + new FormGroup({ + key: new FormControl('customFlag', { nonNullable: true }), + value: new FormControl('true', { nonNullable: true }) + }) + ); + + spectator.component.selectProvider(googleAiProvider); + + expect(spectator.component.additionalProperties.length).toBe(0); + }); + }); + + describe('buildPayloadSection', () => { + it('drops an additional-property row whose key collides with a real field of the current provider', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + spectator.component.fieldsGroup().patchValue({ + apiKey: 'sk-openai-secret', + model: 'gpt-4o' + }); + spectator.component.additionalProperties.push( + new FormGroup({ + key: new FormControl('model', { nonNullable: true }), + value: new FormControl('stale-model-override', { nonNullable: true }) + }) + ); + + const section = spectator.component.buildPayloadSection(); + + expect(section?.['model']).toBe('gpt-4o'); + }); + + it('still includes an additional-property row that does not collide with a real field', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + spectator.component.fieldsGroup().patchValue({ + apiKey: 'sk-openai-secret', + model: 'gpt-4o' + }); + spectator.component.additionalProperties.push( + new FormGroup({ + key: new FormControl('customFlag', { nonNullable: true }), + value: new FormControl('true', { nonNullable: true }) + }) + ); + + const section = spectator.component.buildPayloadSection(); + + expect(section?.['customFlag']).toBe('true'); + }); + }); + + describe('visibleFields / advancedFields', () => { + it('shows a required field above the Advanced panel', () => { + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(openAiProvider); + + const visibleNames = spectator.component.visibleFields().map((f) => f.name); + expect(visibleNames).toContain('apiKey'); + expect(visibleNames).toContain('model'); + expect(spectator.component.advancedFields()).toEqual([]); + }); + + it('promotes an optional SECRET field above the Advanced panel', () => { + const providerWithOptionalSecret: DotAiProviderMetadata = { + provider: 'vertex_ai', + supportedCapabilities: [DotAiCapability.CHAT], + fields: { + [DotAiCapability.CHAT]: [ + { + name: 'model', + type: DotAiProviderFieldType.STRING, + required: true, + hint: '' + }, + { + name: 'credentialsJson', + type: DotAiProviderFieldType.SECRET, + required: false, + hint: '' + }, + { + name: 'temperature', + type: DotAiProviderFieldType.NUMBER, + required: false, + hint: '' + } + ] + } + }; + spectator.setInput('providers', [providerWithOptionalSecret]); + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(providerWithOptionalSecret); + + const visibleNames = spectator.component.visibleFields().map((f) => f.name); + const advancedNames = spectator.component.advancedFields().map((f) => f.name); + + expect(visibleNames).toContain('credentialsJson'); + expect(advancedNames).toContain('temperature'); + expect(advancedNames).not.toContain('credentialsJson'); + }); + }); + + describe('hydrateFields (additional properties round-trip)', () => { + it('hydrates a non-string saved value without corrupting it via String()', () => { + const hydrated = createComponent({ + props: { + meta: chatMeta, + providers: [openAiProvider], + initialValue: { + provider: 'openai', + apiKey: 'sk-openai-secret', + model: 'gpt-4o', + listenerIndexer: { enabled: true, batchSize: 10 } + } + } + }); + hydrated.detectChanges(); + + const propertyGroup = hydrated.component.additionalProperties.at(0); + expect(propertyGroup.value.key).toBe('listenerIndexer'); + expect(propertyGroup.value.value).not.toBe('[object Object]'); + expect(JSON.parse(propertyGroup.value.value)).toEqual({ + enabled: true, + batchSize: 10 + }); + }); + + it('round-trips that hydrated object back out through buildPayloadSection', () => { + const hydrated = createComponent({ + props: { + meta: chatMeta, + providers: [openAiProvider], + initialValue: { + provider: 'openai', + apiKey: 'sk-openai-secret', + model: 'gpt-4o', + listenerIndexer: { enabled: true, batchSize: 10 } + } + } + }); + hydrated.detectChanges(); + + const section = hydrated.component.buildPayloadSection(); + + expect(section?.['listenerIndexer']).toEqual({ enabled: true, batchSize: 10 }); + }); + }); + + describe('requiredUnless cross-field validation (Azure model/deploymentName pattern)', () => { + const azureLikeProvider: DotAiProviderMetadata = { + provider: 'azure_openai', + supportedCapabilities: [DotAiCapability.CHAT], + fields: { + [DotAiCapability.CHAT]: [ + { + name: 'apiKey', + type: DotAiProviderFieldType.SECRET, + required: true, + hint: '' + }, + { + name: 'model', + type: DotAiProviderFieldType.STRING, + required: false, + hint: 'Required if deploymentName is not set', + requiredUnless: 'deploymentName' + }, + { + name: 'deploymentName', + type: DotAiProviderFieldType.STRING, + required: false, + hint: 'Required if model is not set', + requiredUnless: 'model' + } + ] + } + }; + + beforeEach(() => { + spectator.setInput('providers', [azureLikeProvider]); + spectator.detectChanges(); + spectator.component.onToggleEnabled(true); + spectator.component.selectProvider(azureLikeProvider); + spectator.component.fieldsGroup().patchValue({ apiKey: 'sk-azure-key' }); + }); + + it('is invalid when both model and deploymentName are empty', () => { + expect(spectator.component.isValid()).toBe(false); + }); + + it('becomes valid when only model is filled', () => { + spectator.component.fieldsGroup().patchValue({ model: 'gpt-4o' }); + + expect(spectator.component.isValid()).toBe(true); + }); + + it('becomes valid when only deploymentName is filled', () => { + spectator.component.fieldsGroup().patchValue({ deploymentName: 'my-deployment' }); + + expect(spectator.component.isValid()).toBe(true); + }); + + it('re-validates model when deploymentName changes after model was left empty', () => { + const modelControl = spectator.component.fieldsGroup().get('model'); + // Touch model while both are empty — it fails, as expected. + modelControl?.updateValueAndValidity(); + expect(modelControl?.valid).toBe(false); + + // Filling the sibling (deploymentName) must clear model's error too, even though + // model's own value never changed. + spectator.component.fieldsGroup().patchValue({ deploymentName: 'my-deployment' }); + + expect(modelControl?.valid).toBe(true); + }); + + it('goes back to invalid if the only filled field is cleared again', () => { + spectator.component.fieldsGroup().patchValue({ model: 'gpt-4o' }); + expect(spectator.component.isValid()).toBe(true); + + spectator.component.fieldsGroup().patchValue({ model: '' }); + + expect(spectator.component.isValid()).toBe(false); + }); + + it('is valid on initial load from a saved config that only set the sibling field', () => { + // Regression test: a control's initial status is computed in its own constructor, + // before Angular wires its `parent` — so hydrating a saved Azure config that only + // persisted `deploymentName` must not render `model` as falsely invalid on load. + const hydrated = createComponent({ + props: { + meta: chatMeta, + providers: [azureLikeProvider], + initialValue: { + provider: 'azure_openai', + apiKey: 'sk-azure-key', + deploymentName: 'my-deployment' + } + } + }); + hydrated.detectChanges(); + + expect(hydrated.component.isValid()).toBe(true); + expect(hydrated.component.fieldsGroup().get('model')?.valid).toBe(true); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.ts new file mode 100644 index 000000000000..efab9b6cec07 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-capability-card/dot-ai-capability-card.component.ts @@ -0,0 +1,372 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + computed, + inject, + input, + output, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + FormArray, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidatorFn, + Validators +} from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { PanelModule } from 'primeng/panel'; +import { PasswordModule } from 'primeng/password'; +import { TagModule } from 'primeng/tag'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotAiService, DotMessageService } from '@dotcms/data-access'; +import { + DotAiProviderField, + DotAiProviderMetadata, + DotAiTestConnectionResult +} from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { + CAPABILITY_LABELS, + DotAiCapabilityMeta, + PROVIDER_DISPLAY_NAMES, + PROVIDER_ORDER, + isFieldAlwaysVisible, + parseIfJson, + requiredUnlessValidator, + stringifyForField +} from '../../dot-ai-config.constants'; +import { + DotAiAdditionalPropertiesComponent, + DotAiAdditionalPropertyGroup +} from '../dot-ai-additional-properties/dot-ai-additional-properties.component'; +import { DotAiDynamicFieldComponent } from '../dot-ai-dynamic-field/dot-ai-dynamic-field.component'; + +/** Raw shape of one `chat`/`embeddings`/`image` section inside the `providerConfig` JSON. */ +export type DotAiCapabilitySectionValue = Record & { provider?: string }; + +@Component({ + selector: 'dot-ai-capability-card', + templateUrl: './dot-ai-capability-card.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + ReactiveFormsModule, + ButtonModule, + InputTextModule, + PasswordModule, + PanelModule, + TagModule, + ToggleSwitchModule, + TooltipModule, + DotMessagePipe, + DotAiDynamicFieldComponent, + DotAiAdditionalPropertiesComponent + ] +}) +export class DotAiCapabilityCardComponent implements OnInit { + private readonly dotAiService = inject(DotAiService); + private readonly dotMessageService = inject(DotMessageService); + private readonly destroyRef = inject(DestroyRef); + + readonly meta = input.required(); + readonly providers = input.required(); + readonly initialValue = input(null); + readonly siteId = input(undefined); + + readonly changed = output(); + + readonly enabled = signal(false); + readonly providerId = signal(null); + readonly fieldsGroup = signal(new FormGroup({})); + readonly additionalProperties = new FormArray([]); + + readonly capabilityLabel = computed(() => + this.dotMessageService.get(CAPABILITY_LABELS[this.meta().capability]) + ); + + readonly orderedProviders = computed(() => { + const list = [...this.providers()]; + list.sort((a, b) => providerSortIndex(a.provider) - providerSortIndex(b.provider)); + + return list; + }); + + readonly currentProviderMeta = computed( + () => this.providers().find((p) => p.provider === this.providerId()) ?? null + ); + + /** Fields shown above the "Advanced" panel — required, plus optional fields worth surfacing + * by default (credentials, identity fields). See {@link isFieldAlwaysVisible}. */ + readonly visibleFields = computed(() => + this.fieldsForCurrentProvider().filter((f) => isFieldAlwaysVisible(f)) + ); + + /** Truly-optional tuning fields (e.g. temperature, timeout) tucked under "Advanced". */ + readonly advancedFields = computed(() => + this.fieldsForCurrentProvider().filter((f) => !isFieldAlwaysVisible(f)) + ); + + readonly badgeLabel = computed(() => { + if (!this.enabled() || !this.providerId()) { + return 'apps.ai.badge.not-configured'; + } + + return displayName(this.providerId() as string); + }); + + readonly badgeSeverity = computed(() => + this.enabled() && this.providerId() ? 'success' : 'secondary' + ); + + readonly testing = signal(false); + readonly testResult = signal(null); + + ngOnInit(): void { + this.additionalProperties.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changed.emit()); + + const initial = this.initialValue(); + const fallbackProvider = this.defaultProviderId(); + + if (initial?.provider) { + this.enabled.set(true); + this.providerId.set(String(initial.provider)); + this.hydrateFields(initial); + } else { + this.enabled.set(false); + this.providerId.set(fallbackProvider); + this.rebuildFieldsGroup(fallbackProvider, {}); + } + } + + onToggleEnabled(value: boolean): void { + this.enabled.set(value); + this.testResult.set(null); + this.changed.emit(); + } + + isProviderSupported(provider: DotAiProviderMetadata): boolean { + return provider.supportedCapabilities.includes(this.meta().capability); + } + + providerCaption(provider: DotAiProviderMetadata): string { + if (!this.isProviderSupported(provider)) { + return this.dotMessageService.get( + 'apps.ai.provider.capability.unsupported', + this.capabilityLabel() + ); + } + + return provider.supportedCapabilities + .map((c) => this.dotMessageService.get(CAPABILITY_LABELS[c])) + .join(' · '); + } + + displayNameFor(providerId: string): string { + return displayName(providerId); + } + + selectProvider(provider: DotAiProviderMetadata): void { + if (!this.isProviderSupported(provider) || provider.provider === this.providerId()) { + return; + } + + this.providerId.set(provider.provider); + this.testResult.set(null); + this.rebuildFieldsGroup(provider.provider, {}); + this.additionalProperties.clear(); + this.changed.emit(); + } + + testConnection(): void { + if (!this.isValid()) { + this.markAllTouched(); + this.testResult.set({ + success: false, + message: 'apps.ai.validation.required-fields-test' + }); + + return; + } + + const section = this.buildPayloadSection(); + if (!section) { + return; + } + + this.testing.set(true); + this.testResult.set(null); + this.dotAiService + .testConnection(this.meta().sectionKey, section, this.siteId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.testing.set(false); + this.testResult.set(result); + }, + error: (err) => { + this.testing.set(false); + this.testResult.set({ + success: false, + message: + (err as { error?: { error?: string }; message?: string })?.error + ?.error ?? + (err as { message?: string })?.message ?? + 'apps.ai.error.test-connection' + }); + } + }); + } + + /** + * Returns `null` when the capability is disabled (omitted from the saved payload so the + * backend treats it as unconfigured), otherwise the assembled section value. Additional + * properties are merged in last but never override a real field of the current provider — + * an extra-property row sharing a real field's name is silently dropped. + */ + buildPayloadSection(): DotAiCapabilitySectionValue | null { + if (!this.enabled() || !this.providerId()) { + return null; + } + + const section: DotAiCapabilitySectionValue = { provider: this.providerId() as string }; + + Object.entries(this.fieldsGroup().value as Record).forEach( + ([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + section[key] = value; + } + } + ); + + const knownNames = new Set(this.fieldsForCurrentProvider().map((f) => f.name)); + this.additionalProperties.controls.forEach((group) => { + const key = group.value.key?.trim(); + if (key && !knownNames.has(key)) { + section[key] = parseIfJson(group.value.value ?? ''); + } + }); + + return section; + } + + isValid(): boolean { + return !this.enabled() || this.fieldsGroup().valid; + } + + markAllTouched(): void { + this.fieldsGroup().markAllAsTouched(); + } + + private fieldsForCurrentProvider(): DotAiProviderField[] { + return this.currentProviderMeta()?.fields[this.meta().capability] ?? []; + } + + private defaultProviderId(): string | null { + const supported = this.orderedProviders().filter((p) => this.isProviderSupported(p)); + + return supported[0]?.provider ?? this.orderedProviders()[0]?.provider ?? null; + } + + private hydrateFields(initial: DotAiCapabilitySectionValue): void { + const fields = this.fieldsForCurrentProvider(); + const knownNames = new Set(fields.map((f) => f.name)); + + this.rebuildFieldsGroup(this.providerId(), initial); + + Object.entries(initial).forEach(([key, value]) => { + if (key === 'provider' || knownNames.has(key)) { + return; + } + this.additionalProperties.push( + new FormGroup({ + key: new FormControl(key, { nonNullable: true }), + value: new FormControl(stringifyForField(value), { + nonNullable: true + }) + }) + ); + }); + } + + private rebuildFieldsGroup( + providerId: string | null, + presetValues: Record + ): void { + const providerMeta = this.providers().find((p) => p.provider === providerId); + const fields = providerMeta?.fields[this.meta().capability] ?? []; + + const group = new FormGroup({}); + fields.forEach((field) => { + const preset = presetValues[field.name]; + group.addControl(field.name, new FormControl(preset ?? null, fieldValidators(field))); + }); + + const requiredUnlessFields = fields.filter((field) => field.requiredUnless); + + // A control's initial status is computed in its own constructor, before Angular has + // wired it into this group — so `requiredUnlessValidator`'s sibling lookup (via + // `control.parent`) sees no parent yet and assumes the sibling is empty. Force one + // recheck now that every control (and its parent link) exists, so a hydrated config + // where only the sibling was saved doesn't render this field as falsely invalid on load. + // The onlySelf recheck above never bubbles to `group` itself, so its own `status` (and + // thus `isValid()`, which reads `fieldsGroup().valid`) would otherwise stay stale until + // something else happens to touch it — recompute the group explicitly rather than + // relying on an incidental re-render to do it. + if (requiredUnlessFields.length > 0) { + requiredUnlessFields.forEach((field) => { + group.get(field.name)?.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + }); + group.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + } + + // From here on, a field with `requiredUnless` must re-validate whenever that sibling + // changes — not just when its own value changes. + requiredUnlessFields.forEach((field) => { + const ownControl = group.get(field.name); + const siblingControl = group.get(field.requiredUnless as string); + siblingControl?.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => ownControl?.updateValueAndValidity({ emitEvent: false })); + }); + + group.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changed.emit()); + this.fieldsGroup.set(group); + } +} + +function fieldValidators(field: DotAiProviderField): ValidatorFn[] { + if (field.required) { + return [Validators.required]; + } + + if (field.requiredUnless) { + return [requiredUnlessValidator(field.requiredUnless)]; + } + + return []; +} + +function providerSortIndex(providerId: string): number { + const index = PROVIDER_ORDER.indexOf(providerId); + + return index === -1 ? PROVIDER_ORDER.length : index; +} + +function displayName(providerId: string): string { + return PROVIDER_DISPLAY_NAMES[providerId] ?? providerId; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.html new file mode 100644 index 000000000000..b203d9e29bd0 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.html @@ -0,0 +1,52 @@ +
+ + + @switch (field().type) { + @case (DotAiProviderFieldType.NUMBER) { + + } + @case (DotAiProviderFieldType.SECRET) { + @if (isMaskedSecret()) { + + } @else { + + } + } + @default { + + } + } + + @if (field().hint) { + {{ field().hint }} + } +
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.spec.ts new file mode 100644 index 000000000000..62161b7f623a --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.spec.ts @@ -0,0 +1,76 @@ +import { createComponentFactory, Spectator } from '@openng/spectator/jest'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { DotAiProviderField, DotAiProviderFieldType } from '@dotcms/dotcms-models'; + +import { DotAiDynamicFieldComponent, humanizeFieldName } from './dot-ai-dynamic-field.component'; + +import { MASKED_SECRET_VALUE } from '../../dot-ai-config.constants'; + +describe('DotAiDynamicFieldComponent', () => { + let spectator: Spectator; + + const secretField: DotAiProviderField = { + name: 'apiKey', + type: DotAiProviderFieldType.SECRET, + required: true, + hint: '' + }; + + const createComponent = createComponentFactory({ + component: DotAiDynamicFieldComponent, + schemas: [NO_ERRORS_SCHEMA], + detectChanges: false + }); + + describe('isMaskedSecret', () => { + it('is true when the control holds the saved-secret placeholder', () => { + const formGroup = new FormGroup({ apiKey: new FormControl(MASKED_SECRET_VALUE) }); + spectator = createComponent({ props: { field: secretField, formGroup } }); + spectator.detectChanges(); + + expect(spectator.component.isMaskedSecret()).toBe(true); + }); + + it('is false as soon as the placeholder is edited away, without waiting for a field/formGroup identity change', () => { + const formGroup = new FormGroup({ apiKey: new FormControl(MASKED_SECRET_VALUE) }); + spectator = createComponent({ props: { field: secretField, formGroup } }); + spectator.detectChanges(); + + formGroup.get('apiKey')?.setValue('sk-newly-typed-secret'); + + expect(spectator.component.isMaskedSecret()).toBe(false); + }); + + it('is false for a freshly-created secret field with no saved value', () => { + const formGroup = new FormGroup({ apiKey: new FormControl(null) }); + spectator = createComponent({ props: { field: secretField, formGroup } }); + spectator.detectChanges(); + + expect(spectator.component.isMaskedSecret()).toBe(false); + }); + + it('is always false for a non-SECRET field, even if its value matches the placeholder text', () => { + const textField: DotAiProviderField = { + name: 'endpoint', + type: DotAiProviderFieldType.STRING, + required: false, + hint: '' + }; + const formGroup = new FormGroup({ endpoint: new FormControl(MASKED_SECRET_VALUE) }); + spectator = createComponent({ props: { field: textField, formGroup } }); + spectator.detectChanges(); + + expect(spectator.component.isMaskedSecret()).toBe(false); + }); + }); + + describe('humanizeFieldName', () => { + it('splits camelCase and capitalizes the first letter', () => { + expect(humanizeFieldName('apiKey')).toBe('Api key'); + expect(humanizeFieldName('maxRetries')).toBe('Max retries'); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.ts new file mode 100644 index 000000000000..dd1ae8299d35 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-dynamic-field/dot-ai-dynamic-field.component.ts @@ -0,0 +1,82 @@ +import { ChangeDetectionStrategy, Component, computed, effect, input, signal } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +import { PasswordModule } from 'primeng/password'; + +import { DotAiProviderField, DotAiProviderFieldType } from '@dotcms/dotcms-models'; + +import { MASKED_SECRET_VALUE } from '../../dot-ai-config.constants'; + +/** + * Renders a single dynamic dotAI provider field (text, number or secret) inside a parent + * `FormGroup`, based purely on the field metadata returned by `GET /v1/ai/providers` — no + * per-provider knowledge lives here, so a new backend provider's fields render automatically. + * + * A `SECRET` field renders as a masked password input with a reveal toggle when it has no saved + * value yet. Once a value is already saved, the real secret never reaches the browser — the + * backend sends {@link MASKED_SECRET_VALUE} instead — so it renders as a plain text input showing + * that placeholder, with no toggle (there's nothing behind it to reveal). As soon as the user + * edits that placeholder away, the control no longer holds a "saved" value — it switches back to + * a masked password input so the new secret being typed isn't shown in clear text — see + * `isMaskedSecret`. + */ +@Component({ + selector: 'dot-ai-dynamic-field', + templateUrl: './dot-ai-dynamic-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, InputTextModule, InputNumberModule, PasswordModule] +}) +export class DotAiDynamicFieldComponent { + readonly field = input.required(); + readonly formGroup = input.required(); + + readonly DotAiProviderFieldType = DotAiProviderFieldType; + + readonly label = computed(() => humanizeFieldName(this.field().name)); + + readonly isInvalid = computed(() => { + const control = this.formGroup().get(this.field().name); + + return !!control && control.invalid && (control.touched || control.dirty); + }); + + /** Tracks the live control value so `isMaskedSecret` reacts to every keystroke, not just to + * `field()`/`formGroup()` changing identity (field metadata change or a provider switch). */ + private readonly currentValue = signal(undefined); + + constructor() { + effect((onCleanup) => { + const control = this.formGroup().get(this.field().name); + this.currentValue.set(control?.value); + + const subscription = control?.valueChanges.subscribe((value) => + this.currentValue.set(value) + ); + onCleanup(() => subscription?.unsubscribe()); + }); + } + + /** True only while the control still holds the backend's saved-secret placeholder. Editing + * it away (even mid-keystroke) flips this to `false`, switching to a masked password input + * so the new secret being typed is never shown in clear text. */ + readonly isMaskedSecret = computed(() => { + const field = this.field(); + if (field.type !== DotAiProviderFieldType.SECRET) { + return false; + } + + return this.currentValue() === MASKED_SECRET_VALUE; + }); +} + +/** + * Turns a camelCase provider field name into a human-readable label, e.g. + * `maxRetries` -> `Max retries`, `apiKey` -> `Api key`. + */ +export function humanizeFieldName(name: string): string { + const spaced = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.html new file mode 100644 index 000000000000..5f6eabc2f243 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.html @@ -0,0 +1,120 @@ +
+
+ +
+ + {{ 'apps.ai.settings.title' | dm }} + + + {{ 'apps.ai.settings.description' | dm }} + +
+
+ +
+
+ + + + {{ 'apps.ai.settings.role-prompt.hint' | dm }} + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ @for (settingsField of advancedFields; track settingsField.key) { + @if (settingsField.type === 'checkbox') { +
+ + +
+ } @else { +
+ + @if (settingsField.type === 'number') { + + } @else { + + } + @if (settingsField.hint) { + {{ settingsField.hint | dm }} + } +
+ } + } +
+ +
+
+
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.ts new file mode 100644 index 000000000000..2107a9c6008a --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/components/dot-ai-settings-card/dot-ai-settings-card.component.ts @@ -0,0 +1,145 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + inject, + input, + output +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { CheckboxModule } from 'primeng/checkbox'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +import { PanelModule } from 'primeng/panel'; +import { SelectModule } from 'primeng/select'; +import { TextareaModule } from 'primeng/textarea'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { + IMAGE_SIZE_OPTIONS, + SETTINGS_ADVANCED_FIELDS, + SETTINGS_COMMON_FIELDS, + parseIfJson, + stringifyForField +} from '../../dot-ai-config.constants'; +import { + DotAiAdditionalPropertiesComponent, + DotAiAdditionalPropertyGroup +} from '../dot-ai-additional-properties/dot-ai-additional-properties.component'; + +export type DotAiSettingsValue = Record; + +/** + * Shared prompt/behavior settings applied across every capability (role prompt, text/image + * prompts, image size, and the embeddings/indexing advanced knobs from `com.dotcms.ai.app.AppKeys`). + */ +@Component({ + selector: 'dot-ai-settings-card', + templateUrl: './dot-ai-settings-card.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ReactiveFormsModule, + InputTextModule, + InputNumberModule, + TextareaModule, + SelectModule, + CheckboxModule, + PanelModule, + DotMessagePipe, + DotAiAdditionalPropertiesComponent + ] +}) +export class DotAiSettingsCardComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + + readonly initialValue = input(null); + readonly changed = output(); + + readonly commonFields = SETTINGS_COMMON_FIELDS; + readonly advancedFields = SETTINGS_ADVANCED_FIELDS; + readonly imageSizeOptions = IMAGE_SIZE_OPTIONS; + + readonly form = new FormGroup({ + rolePrompt: new FormControl(null), + textPrompt: new FormControl(null), + imagePrompt: new FormControl(null), + imageSize: new FormControl(null) + }); + + readonly advancedForm = new FormGroup({}); + readonly additionalProperties = new FormArray([]); + + ngOnInit(): void { + this.advancedFields.forEach((field) => { + this.advancedForm.addControl( + field.key, + new FormControl(field.type === 'checkbox' ? (field.defaultValue ?? false) : null) + ); + }); + + const initial = this.initialValue(); + if (initial) { + this.form.patchValue(initial, { emitEvent: false }); + this.advancedForm.patchValue(initial, { emitEvent: false }); + + const knownKeys = new Set([ + ...this.commonFields.map((f) => f.key), + ...this.advancedFields.map((f) => f.key) + ]); + Object.entries(initial).forEach(([key, value]) => { + if (knownKeys.has(key)) { + return; + } + this.additionalProperties.push( + new FormGroup({ + key: new FormControl(key, { nonNullable: true }), + value: new FormControl(stringifyForField(value), { + nonNullable: true + }) + }) + ); + }); + } + + this.form.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changed.emit()); + this.advancedForm.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changed.emit()); + this.additionalProperties.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.changed.emit()); + } + + buildPayloadSection(): DotAiSettingsValue { + const section: DotAiSettingsValue = {}; + + Object.entries(this.form.value).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + section[key] = value; + } + }); + + Object.entries(this.advancedForm.value as Record).forEach( + ([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + section[key] = value; + } + } + ); + + this.additionalProperties.controls.forEach((group) => { + const key = group.value.key?.trim(); + if (key) { + section[key] = parseIfJson(group.value.value ?? ''); + } + }); + + return section; + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html index 18e9a80c6224..cd7a9c7eb34f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html @@ -1,38 +1,55 @@ -@if (app(); as app) { -
-
- -
- {{ app.sites?.[0]?.name }} -
- - -
-
+
+
+
+

+ {{ 'apps.ai.config.title' | dm }} +

+

+ {{ 'apps.ai.config.subtitle' | dm }} +

+ @if (siteName()) { +

+ {{ 'apps.ai.config.site' | dm: [siteName()] }} +

+ }
-
-
-
-
-

Provider Config

- -
-
-

Example JSON

-
{{ exampleJson }}
-
-
+ + @if (loading()) { +
+ {{ 'apps.ai.loading' | dm }}
-
+ } @else if (loadFailed()) { +
+ {{ 'apps.ai.error.load' | dm }} +
+ } @else { + @for (meta of capabilityMeta; track meta.capability) { + + } + + + }
-} +
+ +
+ @if (dirty()) { + {{ 'apps.ai.unsaved.changes' | dm }} + + } + +
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.spec.ts new file mode 100644 index 000000000000..bbbc8ebdc924 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.spec.ts @@ -0,0 +1,87 @@ +import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { + DotAiService, + DotMessageDisplayService, + DotMessageService, + DotRouterService +} from '@dotcms/data-access'; +import { DotAiProviderMetadata } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotAiConfigDetailComponent } from './dot-ai-config-detail.component'; + +describe('DotAiConfigDetailComponent', () => { + let spectator: Spectator; + + const providers: DotAiProviderMetadata[] = []; + + const createComponent = createComponentFactory({ + component: DotAiConfigDetailComponent, + providers: [ + mockProvider(DotAiService), + mockProvider(DotRouterService), + mockProvider(DotMessageDisplayService), + { provide: DotMessageService, useValue: new MockDotMessageService({}) }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { paramMap: { get: () => 'site-identifier' } }, + data: of({ data: null }) + } + } + ], + schemas: [NO_ERRORS_SCHEMA], + detectChanges: false + }); + + describe('when the initial load fails', () => { + beforeEach(() => { + spectator = createComponent(); + spectator.inject(DotAiService).getProviders.mockReturnValue(of(providers)); + spectator + .inject(DotAiService) + .getConfig.mockReturnValue(throwError(() => new Error('network error'))); + + spectator.component.ngOnInit(); + }); + + it('sets loadFailed instead of leaving the form open on empty/default data', () => { + expect(spectator.component.loadFailed()).toBe(true); + expect(spectator.component.loading()).toBe(false); + }); + + it('surfaces the load error to the user', () => { + expect(spectator.inject(DotMessageDisplayService).push).toHaveBeenCalled(); + }); + + it('refuses to save over a config that never actually loaded', () => { + spectator.component.save(); + + expect(spectator.inject(DotAiService).saveConfig).not.toHaveBeenCalled(); + }); + }); + + describe('when the initial load succeeds', () => { + beforeEach(() => { + spectator = createComponent(); + spectator.inject(DotAiService).getProviders.mockReturnValue(of(providers)); + spectator.inject(DotAiService).getConfig.mockReturnValue( + of({ + providerConfig: JSON.stringify({ settings: { textPrompt: 'hi' } }) + } as never) + ); + + spectator.component.ngOnInit(); + }); + + it('does not mark the load as failed', () => { + expect(spectator.component.loadFailed()).toBe(false); + expect(spectator.component.loading()).toBe(false); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts index 1be836f36531..aed582e485ec 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -1,17 +1,21 @@ +import { forkJoin } from 'rxjs'; + import { + ChangeDetectionStrategy, Component, DestroyRef, OnInit, + computed, + effect, inject, signal, - ChangeDetectionStrategy + viewChild, + viewChildren } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { ButtonModule } from 'primeng/button'; -import { TextareaModule } from 'primeng/textarea'; import { map } from 'rxjs/operators'; @@ -21,70 +25,78 @@ import { DotMessageService, DotRouterService } from '@dotcms/data-access'; -import { DotApp, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; +import { + DotApp, + DotAiProviderMetadata, + DotMessageSeverity, + DotMessageType +} from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; +import { isEqual } from '@dotcms/utils'; -import { DotAppsConfigurationHeaderComponent } from '../dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component'; - -const EXAMPLE_CONFIG = { - chat: { - provider: 'openai', - apiKey: 'sk-...', - model: 'gpt-4o', - maxTokens: 16384, - temperature: 1.0, - maxRetries: 3 - }, - embeddings: { - provider: 'openai', - apiKey: 'sk-...', - model: 'text-embedding-ada-002' - }, - image: { - provider: 'openai', - apiKey: 'sk-...', - model: 'gpt-image-1' - }, - settings: { - rolePrompt: 'You are dotCMSbot, an AI assistant to help content creators.', - textPrompt: 'Use Descriptive writing style.', - imagePrompt: 'Use 16:9 aspect ratio.', - imageSize: '1024x1024', - listenerIndexer: { default: 'blog,news,webPageContent' }, - completionRolePrompt: 'You are a helpful assistant with a descriptive writing style.', - completionTextPrompt: - 'Answer this question\n"$!{prompt}?"\n\nby using only the information in the following text:\n"""\n$!{supportingContent} \n"""\n', - embeddingsSearchThreshold: 0.25 - } -}; +import { + DotAiCapabilityCardComponent, + DotAiCapabilitySectionValue +} from './components/dot-ai-capability-card/dot-ai-capability-card.component'; +import { DotAiSettingsCardComponent } from './components/dot-ai-settings-card/dot-ai-settings-card.component'; +import { CAPABILITY_META } from './dot-ai-config.constants'; @Component({ selector: 'dot-ai-config-detail', templateUrl: './dot-ai-config-detail.component.html', - host: { class: 'flex h-full p-4 bg-gray-200 shadow-md' }, - changeDetection: ChangeDetectionStrategy.Eager, + host: { class: 'flex h-full w-full flex-col overflow-hidden bg-white' }, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - FormsModule, ButtonModule, - TextareaModule, - DotAppsConfigurationHeaderComponent, + DotAiCapabilityCardComponent, + DotAiSettingsCardComponent, DotMessagePipe ] }) export class DotAiConfigDetailComponent implements OnInit { - private route = inject(ActivatedRoute); - private dotAiService = inject(DotAiService); - private dotRouterService = inject(DotRouterService); - private dotMessageDisplayService = inject(DotMessageDisplayService); - private dotMessageService = inject(DotMessageService); - private destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly dotAiService = inject(DotAiService); + private readonly dotRouterService = inject(DotRouterService); + private readonly dotMessageDisplayService = inject(DotMessageDisplayService); + private readonly dotMessageService = inject(DotMessageService); + private readonly destroyRef = inject(DestroyRef); - private readonly siteId = this.route.snapshot.paramMap.get('id') ?? undefined; + readonly siteId = this.route.snapshot.paramMap.get('id') ?? undefined; readonly app = signal(null); - readonly configJson = signal(''); + readonly loading = signal(true); + readonly loadFailed = signal(false); readonly saving = signal(false); - readonly exampleJson = JSON.stringify(EXAMPLE_CONFIG, null, 2); + readonly dirty = signal(false); + + readonly capabilityMeta = CAPABILITY_META; + readonly initialSections = signal>({}); + readonly initialSettings = signal | null>(null); + readonly providers = signal([]); + + /** The site this configuration applies to — already resolved by the route (see the + * `dotAiConfigDetailResolver`), just never surfaced in the redesigned page. */ + readonly siteName = computed(() => this.app()?.sites?.[0]?.name ?? null); + + private readonly capabilityCards = viewChildren(DotAiCapabilityCardComponent); + private readonly settingsCard = viewChild(DotAiSettingsCardComponent); + + private savedPayload: Record | null = null; + private baselineCaptured = false; + + constructor() { + effect(() => { + const cards = this.capabilityCards(); + const settings = this.settingsCard(); + + if (this.baselineCaptured || this.loading() || cards.length === 0 || !settings) { + return; + } + + this.baselineCaptured = true; + this.savedPayload = this.buildCurrentPayload(); + }); + } ngOnInit(): void { this.route.data @@ -92,45 +104,70 @@ export class DotAiConfigDetailComponent implements OnInit { map((x) => x?.data), takeUntilDestroyed(this.destroyRef) ) - .subscribe((app: DotApp) => { - this.app.set(app); - }); + .subscribe((app: DotApp) => this.app.set(app)); - this.dotAiService - .getConfig(this.siteId) + forkJoin({ + providers: this.dotAiService.getProviders(), + config: this.dotAiService.getConfig(this.siteId) + }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: (config) => { + next: ({ providers, config }) => { + this.providers.set(providers); + + let parsed: Record = {}; if (config?.providerConfig) { try { - this.configJson.set( - JSON.stringify(JSON.parse(config.providerConfig), null, 2) - ); + parsed = JSON.parse(config.providerConfig); } catch { - this.configJson.set(config.providerConfig); + parsed = {}; } } + + this.initialSections.set({ + chat: (parsed['chat'] as DotAiCapabilitySectionValue) ?? null, + embeddings: (parsed['embeddings'] as DotAiCapabilitySectionValue) ?? null, + image: (parsed['image'] as DotAiCapabilitySectionValue) ?? null + }); + this.initialSettings.set( + (parsed['settings'] as Record) ?? null + ); + this.loading.set(false); }, error: (err) => { - const detail = - err?.error?.error ?? err?.message ?? 'Failed to load AI configuration'; - this.dotMessageDisplayService.push({ - life: 5000, - message: detail, - severity: DotMessageSeverity.ERROR, - type: DotMessageType.SIMPLE_MESSAGE - }); + this.loading.set(false); + this.loadFailed.set(true); + this.showError(err, this.dotMessageService.get('apps.ai.error.load')); } }); } - onSubmit(): void { - try { - JSON.parse(this.configJson()); - } catch { + onAnyChanged(): void { + if (!this.baselineCaptured) { + return; + } + + this.dirty.set(!isEqual(this.buildCurrentPayload(), this.savedPayload)); + } + + cancel(): void { + const key = this.app()?.key ?? 'dotAI'; + this.dotRouterService.goToAppsConfiguration(key); + } + + save(): void { + if (this.loadFailed()) { + return; + } + + const cards = this.capabilityCards(); + + const invalidCard = cards.find((card) => !card.isValid()); + if (invalidCard) { + invalidCard.markAllTouched(); this.dotMessageDisplayService.push({ life: 5000, - message: 'Invalid JSON — please check the provider configuration', + message: this.dotMessageService.get('apps.ai.validation.required-fields'), severity: DotMessageSeverity.ERROR, type: DotMessageType.SIMPLE_MESSAGE }); @@ -138,13 +175,17 @@ export class DotAiConfigDetailComponent implements OnInit { return; } + const payload = this.buildCurrentPayload(); + this.saving.set(true); this.dotAiService - .saveConfig(this.configJson(), this.siteId) + .saveConfig(JSON.stringify(payload), this.siteId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { this.saving.set(false); + this.savedPayload = payload; + this.dirty.set(false); this.dotMessageDisplayService.push({ life: 3000, message: this.dotMessageService.get('dot.common.message.saved'), @@ -154,20 +195,38 @@ export class DotAiConfigDetailComponent implements OnInit { }, error: (err) => { this.saving.set(false); - const detail = - err?.error?.error ?? err?.message ?? 'Failed to save AI configuration'; - this.dotMessageDisplayService.push({ - life: 5000, - message: detail, - severity: DotMessageSeverity.ERROR, - type: DotMessageType.SIMPLE_MESSAGE - }); + this.showError(err, this.dotMessageService.get('apps.ai.error.save')); } }); } - goToApps(): void { - const key = this.app()?.key ?? 'dotAI'; - this.dotRouterService.goToAppsConfiguration(key); + private buildCurrentPayload(): Record { + const payload: Record = {}; + this.capabilityCards().forEach((card) => { + const section = card.buildPayloadSection(); + if (section) { + payload[card.meta().sectionKey] = section; + } + }); + + const settings = this.settingsCard(); + if (settings) { + payload['settings'] = settings.buildPayloadSection(); + } + + return payload; + } + + private showError(err: unknown, fallback: string): void { + const detail = + (err as { error?: { error?: string }; message?: string })?.error?.error ?? + (err as { message?: string })?.message ?? + fallback; + this.dotMessageDisplayService.push({ + life: 5000, + message: detail, + severity: DotMessageSeverity.ERROR, + type: DotMessageType.SIMPLE_MESSAGE + }); } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.spec.ts new file mode 100644 index 000000000000..005d64b0f571 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.spec.ts @@ -0,0 +1,136 @@ +import { FormControl, FormGroup } from '@angular/forms'; + +import { DotAiProviderFieldType } from '@dotcms/dotcms-models'; + +import { isFieldAlwaysVisible, requiredUnlessValidator } from './dot-ai-config.constants'; + +describe('isFieldAlwaysVisible', () => { + it('returns true for a required field regardless of type', () => { + expect( + isFieldAlwaysVisible({ + name: 'temperature', + type: DotAiProviderFieldType.NUMBER, + required: true, + hint: '' + }) + ).toBe(true); + }); + + it('returns true for an optional SECRET field (e.g. Bedrock accessKeyId, Vertex credentialsJson)', () => { + expect( + isFieldAlwaysVisible({ + name: 'credentialsJson', + type: DotAiProviderFieldType.SECRET, + required: false, + hint: 'GCP service account JSON key; omit to use Application Default Credentials' + }) + ).toBe(true); + }); + + it('returns true for an optional field named "model" (e.g. Azure model/deploymentName pair)', () => { + expect( + isFieldAlwaysVisible({ + name: 'model', + type: DotAiProviderFieldType.STRING, + required: false, + hint: 'Required if deploymentName is not set' + }) + ).toBe(true); + }); + + it('returns true for an optional field named "deploymentName"', () => { + expect( + isFieldAlwaysVisible({ + name: 'deploymentName', + type: DotAiProviderFieldType.STRING, + required: false, + hint: 'Required if model is not set' + }) + ).toBe(true); + }); + + it('returns false for a true tuning field: optional, not a secret, not an identity name', () => { + expect( + isFieldAlwaysVisible({ + name: 'temperature', + type: DotAiProviderFieldType.NUMBER, + required: false, + hint: '' + }) + ).toBe(false); + }); + + it('returns false for optional endpoint/timeout fields', () => { + expect( + isFieldAlwaysVisible({ + name: 'endpoint', + type: DotAiProviderFieldType.STRING, + required: false, + hint: '' + }) + ).toBe(false); + + expect( + isFieldAlwaysVisible({ + name: 'timeout', + type: DotAiProviderFieldType.NUMBER, + required: false, + hint: '' + }) + ).toBe(false); + }); +}); + +describe('requiredUnlessValidator', () => { + // A control computes its initial status in its own constructor, before Angular assigns its + // `parent` — so the validator's sibling lookup sees no parent yet on the very first pass. + // Real usage (`dot-ai-capability-card.component.ts`) re-triggers validation once every + // control is wired into the group; this helper mirrors that so the test reflects how the + // validator is actually used, not a construction-order artifact. + function groupWith(modelValue: string | null, deploymentNameValue: string | null): FormGroup { + const group = new FormGroup({ + model: new FormControl(modelValue, requiredUnlessValidator('deploymentName')), + deploymentName: new FormControl(deploymentNameValue, requiredUnlessValidator('model')) + }); + group.get('model')?.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + group.get('deploymentName')?.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + + return group; + } + + it('is invalid when both the field and its sibling are empty', () => { + const group = groupWith(null, ''); + + expect(group.get('model')?.errors).toEqual({ + requiredUnless: { requires: 'deploymentName' } + }); + expect(group.get('deploymentName')?.errors).toEqual({ + requiredUnless: { requires: 'model' } + }); + }); + + it('is valid when only the field itself has a value', () => { + const group = groupWith('gpt-4o', null); + + expect(group.get('model')?.valid).toBe(true); + }); + + it('is valid when only the sibling has a value', () => { + const group = groupWith(null, 'my-deployment'); + + expect(group.get('model')?.valid).toBe(true); + }); + + it('is valid when both the field and its sibling have a value', () => { + const group = groupWith('gpt-4o', 'my-deployment'); + + expect(group.get('model')?.valid).toBe(true); + expect(group.get('deploymentName')?.valid).toBe(true); + }); + + it('treats a control with no parent as invalid when its own value is empty', () => { + const control = new FormControl(null, requiredUnlessValidator('deploymentName')); + + expect(control.errors).toEqual({ requiredUnless: { requires: 'deploymentName' } }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.ts new file mode 100644 index 000000000000..416dc11ffa61 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config.constants.ts @@ -0,0 +1,282 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +import { DotAiCapability, DotAiProviderField, DotAiProviderFieldType } from '@dotcms/dotcms-models'; + +/** + * The JSON key each capability occupies inside the `providerConfig` payload + * (`{ chat, embeddings, image, settings }`), as read by the backend's `AppConfig`. + */ +export type DotAiCapabilitySectionKey = 'chat' | 'embeddings' | 'image'; + +/** + * Sentinel value the backend substitutes for a credential field (`apiKey`, `secretAccessKey`, + * `accessKeyId`, `credentialsJson`) that's already saved — the real secret never reaches the + * browser. Mirrors `com.dotcms.ai.app.ProviderConfigMerger.MASKED`. + */ +export const MASKED_SECRET_VALUE = '*****'; + +export const CAPABILITY_SECTION_KEYS: Record = { + [DotAiCapability.CHAT]: 'chat', + [DotAiCapability.EMBEDDINGS]: 'embeddings', + [DotAiCapability.IMAGE]: 'image' +}; + +export interface DotAiCapabilityMeta { + capability: DotAiCapability; + sectionKey: DotAiCapabilitySectionKey; + title: string; + description: string; + icon: string; +} + +export const CAPABILITY_META: DotAiCapabilityMeta[] = [ + { + capability: DotAiCapability.CHAT, + sectionKey: 'chat', + title: 'apps.ai.capability.chat.title', + description: 'apps.ai.capability.chat.description', + icon: 'pi pi-comments' + }, + { + capability: DotAiCapability.EMBEDDINGS, + sectionKey: 'embeddings', + title: 'apps.ai.capability.embeddings.title', + description: 'apps.ai.capability.embeddings.description', + icon: 'pi pi-sitemap' + }, + { + capability: DotAiCapability.IMAGE, + sectionKey: 'image', + title: 'apps.ai.capability.image.title', + description: 'apps.ai.capability.image.description', + icon: 'pi pi-image' + } +]; + +/** Presentation-only display names — the provider list itself always comes from the backend. */ +export const PROVIDER_DISPLAY_NAMES: Record = { + openai: 'OpenAI', + azure_openai: 'Azure OpenAI', + google_ai: 'Google AI', + bedrock: 'Amazon Bedrock', + vertex_ai: 'Vertex AI', + anthropic: 'Anthropic', + openrouter: 'OpenRouter' +}; + +/** Fixed visual ordering for the currently-known providers; unknown providers sort last. */ +export const PROVIDER_ORDER = [ + 'openai', + 'azure_openai', + 'google_ai', + 'bedrock', + 'vertex_ai', + 'anthropic', + 'openrouter' +]; + +/** + * Field names that identify a provider/model, even when the backend marks them `optional` + * because a fallback exists (e.g. Azure's `model`/`deploymentName` — either one satisfies the + * requirement). Almost every user fills these in, so they're kept visible above the "Advanced" + * panel instead of being buried alongside true tuning knobs like `temperature` or `timeout`. + * Matched by field name only — not tied to any specific provider, so a future provider reusing + * this naming pattern gets the same treatment automatically. + */ +const ALWAYS_VISIBLE_OPTIONAL_FIELD_NAMES = new Set(['model', 'deploymentName']); + +/** + * Whether a provider field should render above the "Advanced" panel regardless of its `required` + * flag. Required fields always qualify; optional fields qualify when they're a credential + * (`SECRET` type — e.g. AWS's `accessKeyId`/`secretAccessKey`, Vertex AI's `credentialsJson`, + * both optional only because an AWS/ADC fallback exists) or an identity field (see + * {@link ALWAYS_VISIBLE_OPTIONAL_FIELD_NAMES}). This is a type/name-based rule, not a per-provider + * one, so it applies to future providers with no extra maintenance. + */ +export function isFieldAlwaysVisible(field: DotAiProviderField): boolean { + return ( + field.required || + field.type === DotAiProviderFieldType.SECRET || + ALWAYS_VISIBLE_OPTIONAL_FIELD_NAMES.has(field.name) + ); +} + +/** + * Cross-field validator for a field declared `optionalUnless` by the backend (e.g. Azure's + * `model`/`deploymentName` — either one satisfies the requirement). The control is invalid only + * when BOTH it and the named sibling control are empty; filling either one clears the error on + * both, since each field's own validator re-checks the other via {@link isEmptyValue}. + * + * Driven entirely by the `requiredUnless` field name from provider metadata — no per-provider + * logic here, so a future provider with the same either-or pattern needs no frontend changes. + */ +export function requiredUnlessValidator(siblingFieldName: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!isEmptyValue(control.value)) { + return null; + } + + const sibling = control.parent?.get(siblingFieldName); + + return sibling && !isEmptyValue(sibling.value) + ? null + : { requiredUnless: { requires: siblingFieldName } }; + }; +} + +function isEmptyValue(value: unknown): boolean { + return value === null || value === undefined || value === ''; +} + +/** + * Additional-property values round-trip through a plain text input, but some preserved values + * (e.g. Vertex AI's `credentialsJson`, or `listenerIndexer`) are objects/arrays in the stored + * JSON. Parses back when the text looks like JSON, otherwise keeps the raw string. + */ +export function parseIfJson(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return value; + } + + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +/** Serializes a hydrated value into an additional-property text control without corrupting + * non-string values (e.g. `String({a:1})` → `"[object Object]"`). Mirrors {@link parseIfJson}. */ +export function stringifyForField(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +/** Message keys for the lowercase, mid-sentence capability word (e.g. "no {0} support"). */ +export const CAPABILITY_LABELS: Record = { + [DotAiCapability.CHAT]: 'apps.ai.capability.chat.label', + [DotAiCapability.EMBEDDINGS]: 'apps.ai.capability.embeddings.label', + [DotAiCapability.IMAGE]: 'apps.ai.capability.image.label' +}; + +export const IMAGE_SIZE_OPTIONS = [ + { label: '256x256', value: '256x256' }, + { label: '512x512', value: '512x512' }, + { label: '1024x1024', value: '1024x1024' }, + { label: '1024x1792', value: '1024x1792' }, + { label: '1792x1024', value: '1792x1024' } +]; + +export type SettingsFieldType = 'text' | 'textarea' | 'number' | 'checkbox'; + +export interface DotAiSettingsField { + key: string; + label: string; + hint?: string; + type: SettingsFieldType; + /** Backend default from `com.dotcms.ai.app.AppKeys`, used to seed checkbox controls so an + * untouched checkbox doesn't silently save a wrong value when the key was never set. */ + defaultValue?: boolean; +} + +/** + * Always-visible shared settings — surfaced across every capability. Only `key` is consumed + * (to exclude these from the additional-properties list); `label`/`hint` mirror the message keys + * the fixed markup in `dot-ai-settings-card.component.html` renders directly for these fields. + */ +export const SETTINGS_COMMON_FIELDS: DotAiSettingsField[] = [ + { + key: 'rolePrompt', + label: 'apps.ai.settings.role-prompt.label', + hint: 'apps.ai.settings.role-prompt.hint', + type: 'textarea' + }, + { + key: 'textPrompt', + label: 'apps.ai.settings.text-prompt.label', + type: 'text' + }, + { + key: 'imagePrompt', + label: 'apps.ai.settings.image-prompt.label', + type: 'text' + }, + { + // Rendered by the fixed `` in the template, not by this list — listed here only + // so `knownKeys` recognizes it and excludes it from "Additional properties". Without this, + // a saved `settings.imageSize` hydrates into both the dropdown AND a duplicate additional- + // property row, and since additional properties are applied last on save, that stale row + // silently overwrites whatever the user just picked in the dropdown. + key: 'imageSize', + label: 'apps.ai.settings.image-size.label', + type: 'text' + } +]; + +/** Advanced embeddings/indexing settings, mirroring `com.dotcms.ai.app.AppKeys`. */ +export const SETTINGS_ADVANCED_FIELDS: DotAiSettingsField[] = [ + { + key: 'embeddingsSplitAtTokens', + label: 'apps.ai.settings.field.embeddingsSplitAtTokens.label', + hint: 'apps.ai.settings.field.embeddingsSplitAtTokens.hint', + type: 'number' + }, + { + key: 'embeddingsMinimumTextLength', + label: 'apps.ai.settings.field.embeddingsMinimumTextLength.label', + type: 'number' + }, + { + key: 'embeddingsMinimumFileSize', + label: 'apps.ai.settings.field.embeddingsMinimumFileSize.label', + type: 'number' + }, + { + key: 'embeddingsFileExtensions', + label: 'apps.ai.settings.field.embeddingsFileExtensions.label', + hint: 'apps.ai.settings.field.embeddingsFileExtensions.hint', + type: 'text' + }, + { + key: 'embeddingsSearchThreshold', + label: 'apps.ai.settings.field.embeddingsSearchThreshold.label', + type: 'number' + }, + { + key: 'embeddingsThreads', + label: 'apps.ai.settings.field.embeddingsThreads.label', + type: 'number' + }, + { + key: 'embeddingsThreadsMax', + label: 'apps.ai.settings.field.embeddingsThreadsMax.label', + type: 'number' + }, + { + key: 'embeddingsThreadsQueue', + label: 'apps.ai.settings.field.embeddingsThreadsQueue.label', + type: 'number' + }, + { + key: 'embeddingsCacheTtlSeconds', + label: 'apps.ai.settings.field.embeddingsCacheTtlSeconds.label', + type: 'number' + }, + { + key: 'embeddingsCacheSize', + label: 'apps.ai.settings.field.embeddingsCacheSize.label', + type: 'number' + }, + { + key: 'embeddingsDeleteOldOnUpdate', + label: 'apps.ai.settings.field.embeddingsDeleteOldOnUpdate.label', + type: 'checkbox', + defaultValue: true + }, + { + key: 'debugLogging', + label: 'apps.ai.settings.field.debugLogging.label', + type: 'checkbox', + defaultValue: false + } +]; diff --git a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts index 2d65124890b8..bc0db28c02da 100644 --- a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts +++ b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts @@ -11,9 +11,15 @@ import { DotAIImageContent, DotAIImageResponse, DotAiProviderConfig, + DotAiProviderMetadata, + DotAiTestConnectionResult, DEFAULT_IMAGE_SIZE } from '@dotcms/dotcms-models'; +interface ResponseEntityView { + entity: T; +} + export { DotAiProviderConfig }; export const AI_PLUGIN_KEY = { @@ -143,6 +149,44 @@ export class DotAiService { }); } + /** + * Lists capability and field metadata for every registered dotAI provider, so the + * configuration form can render dynamic provider/field UI without hardcoding provider + * knowledge. A new backend provider appears here automatically. + * + * @returns {Observable} provider metadata list. + */ + getProviders(): Observable { + return this.#http + .get>(`${API_ENDPOINT}/providers`) + .pipe(map((response) => response.entity)); + } + + /** + * Tests a provider configuration for one capability by asking the backend to build the + * provider client and issue a minimal real request against it. Masked credential fields + * (`"*****"`) in `config` are resolved server-side against the value already stored for + * `siteId` — the real secret never has to round-trip through the browser. + * + * @param {string} capability - the capability section to test: `chat`, `embeddings`, or `image`. + * @param {Record} config - the assembled provider config section to test. + * @param {string} [siteId] - site identifier (or `SYSTEM_HOST`) whose stored config resolves masked credentials. + * @returns {Observable} the test outcome. + */ + testConnection( + capability: string, + config: Record, + siteId?: string + ): Observable { + const params = siteId ? new HttpParams().set('siteId', siteId) : undefined; + + return this.#http + .post< + ResponseEntityView + >(`${API_ENDPOINT}/providers/test/${capability}`, JSON.stringify(config), { headers, params }) + .pipe(map((response) => response.entity)); + } + createAndPublishContentlet(aiResponse: DotAIImageResponse): Observable { const { response, tempFileName } = aiResponse; const contentlets: Partial[] = [ diff --git a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts index dc8b72bdc74e..bf780914119d 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts @@ -106,3 +106,59 @@ export interface DotAiError { param: string; type: string; } + +/** + * A dotAI capability a provider section can configure independently. + * Mirrors the backend `Capability` enum (com.dotcms.ai.client.langchain4j.Capability). + */ +export enum DotAiCapability { + CHAT = 'CHAT', + EMBEDDINGS = 'EMBEDDINGS', + IMAGE = 'IMAGE' +} + +/** + * Primitive type of a {@link DotAiProviderField}, used to pick the right input control. + * Mirrors the backend `ProviderFieldType` enum. + */ +export enum DotAiProviderFieldType { + STRING = 'STRING', + NUMBER = 'NUMBER', + SECRET = 'SECRET' +} + +/** + * Describes a single configurable provider property for one provider/capability combination. + * Mirrors the backend `ProviderField` record. + */ +export interface DotAiProviderField { + name: string; + type: DotAiProviderFieldType; + required: boolean; + hint: string; + /** + * When non-empty, the name of a sibling field whose presence satisfies this field's + * requirement (e.g. Azure's `model` is required unless `deploymentName` is set, and vice + * versa). Only meaningful when `required` is `false`. + */ + requiredUnless?: string; +} + +/** + * Aggregated capability/field metadata for one dotAI provider, as returned by + * `GET /v1/ai/providers`. Mirrors the backend `ProviderMetadata` record. + */ +export interface DotAiProviderMetadata { + provider: string; + supportedCapabilities: DotAiCapability[]; + fields: Partial>; +} + +/** + * Result of testing a provider configuration's connection for one capability, as returned by + * `POST /v1/ai/providers/test/{capability}`. + */ +export interface DotAiTestConnectionResult { + success: boolean; + message: string; +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AnthropicModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AnthropicModelProviderStrategy.java index 52f06e0c46af..e6eb67af4fb6 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AnthropicModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AnthropicModelProviderStrategy.java @@ -9,6 +9,8 @@ import dev.langchain4j.model.image.ImageModel; import java.time.Duration; +import java.util.List; +import java.util.Set; /** * {@link ModelProviderStrategy} implementation for Anthropic (Claude). @@ -30,6 +32,29 @@ public String providerName() { return "anthropic"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING, "Base URL override for proxies/gateways"), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER, "Not applied to streaming requests"), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case EMBEDDINGS -> throw new UnsupportedOperationException( + "Embeddings are not supported by Anthropic (no embeddings API)"); + case IMAGE -> throw new UnsupportedOperationException( + "Image generation is not supported by Anthropic (no image API)"); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AzureOpenAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AzureOpenAiModelProviderStrategy.java index 045e087c09cb..ccc0efdc8a1c 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AzureOpenAiModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/AzureOpenAiModelProviderStrategy.java @@ -12,6 +12,9 @@ import dev.langchain4j.model.openaiofficial.OpenAiOfficialImageModel; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import java.util.function.Consumer; class AzureOpenAiModelProviderStrategy implements ModelProviderStrategy { @@ -21,6 +24,44 @@ public String providerName() { return "azure_openai"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE); + } + + @Override + public List configFields(final Capability capability) { + final List common = List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("endpoint", ProviderFieldType.STRING), + ProviderField.optionalUnless("model", ProviderFieldType.STRING, "deploymentName", + "Required if deploymentName is not set"), + ProviderField.optionalUnless("deploymentName", ProviderFieldType.STRING, "model", + "Required if model is not set"), + ProviderField.optional("apiVersion", ProviderFieldType.STRING, "e.g. 2024-02-01")); + return switch (capability) { + case CHAT -> concat(common, + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case EMBEDDINGS -> concat(common, + ProviderField.optional("dimensions", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case IMAGE -> concat(common, + ProviderField.optional("size", ProviderFieldType.STRING, "e.g. 1024x1024"), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + }; + } + + private static List concat(final List common, final ProviderField... extra) { + final List all = new ArrayList<>(common); + all.addAll(List.of(extra)); + return List.copyOf(all); + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/BedrockModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/BedrockModelProviderStrategy.java index 072eae52bcff..defa5fda0814 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/BedrockModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/BedrockModelProviderStrategy.java @@ -23,7 +23,9 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClientBuilder; import java.time.Duration; +import java.util.List; import java.util.Optional; +import java.util.Set; /** * {@link ModelProviderStrategy} for Amazon Bedrock, backed by LangChain4J's Bedrock modules. @@ -86,6 +88,40 @@ public String providerName() { return "bedrock"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT, Capability.EMBEDDINGS); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("region", ProviderFieldType.STRING), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("accessKeyId", ProviderFieldType.SECRET, + "Set together with secretAccessKey, or omit both to use the AWS default credential chain"), + ProviderField.optional("secretAccessKey", ProviderFieldType.SECRET, + "Set together with accessKeyId, or omit both to use the AWS default credential chain"), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case EMBEDDINGS -> List.of( + ProviderField.required("region", ProviderFieldType.STRING), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("accessKeyId", ProviderFieldType.SECRET, + "Set together with secretAccessKey, or omit both to use the AWS default credential chain"), + ProviderField.optional("secretAccessKey", ProviderFieldType.SECRET, + "Set together with accessKeyId, or omit both to use the AWS default credential chain"), + ProviderField.optional("embeddingInputType", ProviderFieldType.STRING, + "Cohere only: search_document (default) or search_query"), + ProviderField.optional("dimensions", ProviderFieldType.NUMBER, "Amazon Titan only")); + case IMAGE -> throw new UnsupportedOperationException( + "Image generation is not supported for Bedrock provider via LangChain4J"); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/Capability.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/Capability.java new file mode 100644 index 000000000000..5d00221abdbb --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/Capability.java @@ -0,0 +1,12 @@ +package com.dotcms.ai.client.langchain4j; + +/** + * The AI capabilities a {@code providerConfig} section can configure independently: chat, + * embeddings, and image generation. Not every {@link ModelProviderStrategy} supports every + * capability — see {@link ModelProviderStrategy#supportedCapabilities()}. + */ +public enum Capability { + CHAT, + EMBEDDINGS, + IMAGE +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/GoogleAiGeminiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/GoogleAiGeminiModelProviderStrategy.java index 8ae9d5433b73..de3e9acf2a03 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/GoogleAiGeminiModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/GoogleAiGeminiModelProviderStrategy.java @@ -11,6 +11,8 @@ import dev.langchain4j.model.image.ImageModel; import java.time.Duration; +import java.util.List; +import java.util.Set; /** * {@link ModelProviderStrategy} implementation for Google AI (Gemini API / AI Studio). @@ -31,6 +33,39 @@ public String providerName() { return "google_ai"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER, "Not applied to streaming requests"), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case EMBEDDINGS -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING), + ProviderField.optional("dimensions", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case IMAGE -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING), + ProviderField.optional("size", ProviderFieldType.STRING, "e.g. 1K, 2K"), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java index a64f9f2bfcfc..c4f103eb1908 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java @@ -5,7 +5,10 @@ import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.image.ImageModel; +import java.util.EnumMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * Factory for creating LangChain4J model instances from a {@link ProviderConfig}. @@ -76,6 +79,26 @@ public static ImageModel buildImageModel(final ProviderConfig config) { return resolve(config, "image").buildImageModel(config, "image"); } + /** + * Aggregates capability and field metadata for every registered provider, so a caller (e.g. + * the dotAI provider configuration REST endpoint) can render a fully dynamic provider form + * without hardcoding per-provider knowledge. Adding a provider to {@link #STRATEGIES} + * automatically makes it appear here — no other change is required. + */ + public static List listProviderMetadata() { + return STRATEGIES.stream() + .map(LangChain4jModelFactory::toMetadata) + .collect(Collectors.toUnmodifiableList()); + } + + private static ProviderMetadata toMetadata(final ModelProviderStrategy strategy) { + final Map> fields = new EnumMap<>(Capability.class); + for (final Capability capability : strategy.supportedCapabilities()) { + fields.put(capability, strategy.configFields(capability)); + } + return new ProviderMetadata(strategy.providerName(), strategy.supportedCapabilities(), fields); + } + private static ModelProviderStrategy resolve(final ProviderConfig config, final String modelType) { if (config == null || config.provider() == null) { throw new IllegalArgumentException( diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java index 90bba11d8b39..8387cbd4d272 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java @@ -5,16 +5,22 @@ import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.image.ImageModel; +import java.util.List; +import java.util.Set; + /** * Strategy interface for LangChain4J model construction. * *

Each AI provider implements this interface and is registered in * {@link LangChain4jModelFactory#STRATEGIES}. Adding a new provider requires only: *

    - *
  1. Creating a new implementation of this interface
  2. + *
  3. Creating a new implementation of this interface, including + * {@link #supportedCapabilities()} and {@link #configFields(Capability)}
  4. *
  5. Adding it to the {@code STRATEGIES} list in {@link LangChain4jModelFactory}
  6. *
- * No other class needs to change. + * No other class needs to change — {@link LangChain4jModelFactory#listProviderMetadata()} picks up + * the new provider's capabilities and fields automatically, so a REST client can render its + * configuration form without any additional code. * *

The {@code modelType} parameter in each build method is the section name * ({@code "chat"}, {@code "embeddings"}, {@code "image"}) used solely for @@ -37,6 +43,18 @@ interface ModelProviderStrategy { ImageModel buildImageModel(ProviderConfig config, String modelType); + /** + * Returns the capabilities this provider can serve. A {@code build*Model} method for a + * capability not in this set throws {@link UnsupportedOperationException}. + */ + Set supportedCapabilities(); + + /** + * Returns the {@link ProviderConfig} fields this provider reads for the given capability, + * with which are required. Only called for capabilities in {@link #supportedCapabilities()}. + */ + List configFields(Capability capability); + /** * Shared validation helper available to all strategy implementations. */ diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java index 36dafb44805a..86fcce6f421d 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java @@ -11,6 +11,8 @@ import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import java.time.Duration; +import java.util.List; +import java.util.Set; import java.util.function.Consumer; class OpenAiModelProviderStrategy implements ModelProviderStrategy { @@ -20,6 +22,39 @@ public String providerName() { return "openai"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING, "Override the default OpenAI base URL"), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER, "Request timeout in seconds")); + case EMBEDDINGS -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING), + ProviderField.optional("dimensions", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case IMAGE -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.optional("endpoint", ProviderFieldType.STRING), + ProviderField.optional("size", ProviderFieldType.STRING, "e.g. 1024x1024"), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java index db0e4819943b..c948f0e51e39 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java @@ -10,6 +10,8 @@ import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import java.time.Duration; +import java.util.List; +import java.util.Set; /** * {@link ModelProviderStrategy} implementation for OpenRouter. @@ -34,6 +36,38 @@ public String providerName() { return "openrouter"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT, Capability.EMBEDDINGS); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING, "Namespaced model ID, e.g. openai/gpt-4o"), + ProviderField.optional("endpoint", ProviderFieldType.STRING, + "Defaults to " + DEFAULT_BASE_URL), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER, "Not applied to streaming requests"), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case EMBEDDINGS -> List.of( + ProviderField.required("apiKey", ProviderFieldType.SECRET), + ProviderField.required("model", ProviderFieldType.STRING, + "Namespaced model ID, e.g. openai/text-embedding-3-small"), + ProviderField.optional("endpoint", ProviderFieldType.STRING, + "Defaults to " + DEFAULT_BASE_URL), + ProviderField.optional("dimensions", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER), + ProviderField.optional("timeout", ProviderFieldType.NUMBER)); + case IMAGE -> throw new UnsupportedOperationException( + "OpenRouter image generation is not supported: the /api/v1/images endpoint " + + "is not OpenAI-shaped and cannot be driven by OpenAiImageModel."); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java new file mode 100644 index 000000000000..546d975fe74e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java @@ -0,0 +1,139 @@ +package com.dotcms.ai.client.langchain4j; + +import com.dotmarketing.util.Logger; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.image.ImageModel; + +import java.util.List; + +/** + * Verifies that a {@link ProviderConfig} actually works by building the LangChain4J model for the + * requested {@link Capability} and issuing one minimal, real request against the provider. + * + *

Building the model already validates required fields (see {@link ModelProviderStrategy}), + * so a missing {@code apiKey}/{@code model} fails fast with a clear message before any network + * call is made. Anything past that — bad credentials, unreachable endpoint, unknown model — only + * surfaces once the provider actually answers, hence the real call. + */ +public final class ProviderConnectionTester { + + private static final String TEST_PROMPT = "Reply with just the word: OK"; + private static final String TEST_EMBEDDING_INPUT = "dotCMS connection test"; + private static final String TEST_IMAGE_PROMPT = "a single red pixel on a white background"; + + /** + * Provider SDK exceptions (OpenAI, Bedrock, Google, etc.) often carry the full raw HTTP + * response body in {@link Exception#getMessage()} — sometimes several KB of JSON. Capping the + * length keeps the UI toast readable; the untruncated message is still recorded via + * {@link Logger#warn} in {@link #test} for anyone debugging the actual failure. + */ + private static final int MAX_MESSAGE_LENGTH = 200; + private static final String TRUNCATION_SUFFIX = "…"; + + /** + * Upper bound applied to the test call when the posted config doesn't set {@code timeout}. + * Every provider strategy honors {@code timeout} once set (see each strategy's {@code build*} + * methods) except Vertex AI, which ignores it outright regardless of source — so this default + * only ever narrows an otherwise-unbounded provider-SDK default, never overrides an explicit + * value the caller supplied. Without this, an unreachable or slow {@code endpoint} could hold + * the request thread open indefinitely, since the SDKs' own defaults vary by provider and + * aren't all finite. + */ + private static final int DEFAULT_TEST_TIMEOUT_SECONDS = 10; + + /** + * Same purpose as {@link #DEFAULT_TEST_TIMEOUT_SECONDS}, but for {@link Capability#IMAGE}: + * real image generation routinely takes well past 10s, so the chat/embeddings default would + * fail a perfectly healthy provider before it ever finishes rendering. + */ + private static final int DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS = 60; + + private ProviderConnectionTester() { + } + + /** + * Tests the given provider configuration for the given capability. + * + * @param capability which capability section to test (chat, embeddings, image) + * @param config the provider configuration to test — same shape as one {@code providerConfig} section + * @return a result carrying whether the call succeeded and a human-readable message + */ + public static TestConnectionResult test(final Capability capability, final ProviderConfig config) { + final ProviderConfig effectiveConfig = withDefaultTimeoutIfUnset(config, capability); + try { + final String detail = switch (capability) { + case CHAT -> testChat(effectiveConfig); + case EMBEDDINGS -> testEmbeddings(effectiveConfig); + case IMAGE -> testImage(effectiveConfig); + }; + return new TestConnectionResult(true, detail); + } catch (final Exception e) { + Logger.warn(ProviderConnectionTester.class, + "dotAI provider connection test failed for provider=" + + config.provider() + ", capability=" + capability + ": " + e.getMessage()); + return new TestConnectionResult(false, friendlyMessage(e)); + } + } + + /** + * Returns {@code config} unchanged when it already sets a {@code timeout}, otherwise a copy + * with a default applied — {@link #DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS} for + * {@link Capability#IMAGE}, {@link #DEFAULT_TEST_TIMEOUT_SECONDS} for every other capability + * — scoped to this connection-test path only, so normal save/use of the configuration is + * unaffected. + */ + static ProviderConfig withDefaultTimeoutIfUnset(final ProviderConfig config, final Capability capability) { + if (config.timeout() != null) { + return config; + } + + final int defaultSeconds = capability == Capability.IMAGE + ? DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS + : DEFAULT_TEST_TIMEOUT_SECONDS; + return ImmutableProviderConfig.copyOf(config).withTimeout(defaultSeconds); + } + + private static String testChat(final ProviderConfig config) { + final ChatModel model = LangChain4jModelFactory.buildChatModel(config); + model.chat(ChatRequest.builder() + .messages(List.of(UserMessage.from(TEST_PROMPT))) + .build()); + return "Connection successful."; + } + + private static String testEmbeddings(final ProviderConfig config) { + final EmbeddingModel model = LangChain4jModelFactory.buildEmbeddingModel(config); + model.embed(TextSegment.from(TEST_EMBEDDING_INPUT)).content(); + return "Connection successful."; + } + + private static String testImage(final ProviderConfig config) { + final ImageModel model = LangChain4jModelFactory.buildImageModel(config); + model.generate(TEST_IMAGE_PROMPT).content(); + return "Connection successful. A test image was generated."; + } + + /** + * Reduces a provider exception to something short enough to show in a UI toast: collapses + * whitespace/newlines into single spaces and caps the length, appending + * {@value #TRUNCATION_SUFFIX} when text was cut. Falls back to the exception's simple class + * name when there's no message at all. Provider-agnostic on purpose — it doesn't parse any + * SDK's specific error shape, so it needs no per-provider maintenance. + */ + static String friendlyMessage(final Exception e) { + final String message = e.getMessage(); + if (message == null || message.isBlank()) { + return e.getClass().getSimpleName(); + } + + final String collapsed = message.trim().replaceAll("\\s+", " "); + return collapsed.length() > MAX_MESSAGE_LENGTH + ? collapsed.substring(0, MAX_MESSAGE_LENGTH).stripTrailing() + TRUNCATION_SUFFIX + : collapsed; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java new file mode 100644 index 000000000000..1e45556ec965 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java @@ -0,0 +1,68 @@ +package com.dotcms.ai.client.langchain4j; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Describes a single configurable {@link ProviderConfig} property for one provider/capability + * combination, so a client can render the right input without hardcoding per-provider knowledge. + * + * @param name the {@link ProviderConfig} property name, e.g. {@code apiKey}, {@code maxRetries} + * @param type the field's primitive type + * @param required whether this field must always be set for the given provider/capability + * @param hint short human-readable guidance (e.g. cross-field dependencies); empty if none + * @param requiredUnless when non-empty, the name of a sibling field whose presence satisfies this + * field's requirement (e.g. Azure's {@code model} is required unless + * {@code deploymentName} is set, and vice versa). Only meaningful when + * {@code required} is {@code false} — a client should treat this field as + * required unless the named sibling has a value. Empty when there's no such + * either-or relationship. + */ +public record ProviderField( + @JsonProperty("name") String name, + @JsonProperty("type") ProviderFieldType type, + @JsonProperty("required") boolean required, + @JsonProperty("hint") String hint, + @JsonProperty("requiredUnless") String requiredUnless) { + + public ProviderField { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("ProviderField name is required"); + } + if (type == null) { + throw new IllegalArgumentException("ProviderField type is required"); + } + hint = hint == null ? "" : hint; + requiredUnless = requiredUnless == null ? "" : requiredUnless; + } + + public static ProviderField required(final String name, final ProviderFieldType type) { + return new ProviderField(name, type, true, "", ""); + } + + public static ProviderField required(final String name, final ProviderFieldType type, final String hint) { + return new ProviderField(name, type, true, hint, ""); + } + + public static ProviderField optional(final String name, final ProviderFieldType type) { + return new ProviderField(name, type, false, "", ""); + } + + public static ProviderField optional(final String name, final ProviderFieldType type, final String hint) { + return new ProviderField(name, type, false, hint, ""); + } + + /** + * An optional field that's effectively required unless the named sibling field is set (e.g. + * Azure's {@code model}/{@code deploymentName} pair — either one is enough). + * + * @param name the field name + * @param type the field's primitive type + * @param requiredUnless the sibling field name whose presence satisfies this requirement + * @param hint short human-readable guidance + */ + public static ProviderField optionalUnless(final String name, final ProviderFieldType type, + final String requiredUnless, final String hint) { + return new ProviderField(name, type, false, hint, requiredUnless); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java new file mode 100644 index 000000000000..ee7ca584fbd7 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java @@ -0,0 +1,11 @@ +package com.dotcms.ai.client.langchain4j; + +/** + * The primitive type of a {@link ProviderField}, used by a form-rendering client to pick the + * right input control without hardcoding per-field knowledge. + */ +public enum ProviderFieldType { + STRING, + NUMBER, + SECRET +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java new file mode 100644 index 000000000000..d48476cc3c34 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java @@ -0,0 +1,34 @@ +package com.dotcms.ai.client.langchain4j; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Aggregated, provider-agnostic description of one {@link ModelProviderStrategy}: which + * capabilities it supports and which {@link ProviderField}s each supported capability needs. + * Built by {@link LangChain4jModelFactory#listProviderMetadata()} so a client (e.g. the dotAI + * provider configuration REST endpoint) can render the full provider configuration form without + * hardcoding per-provider knowledge. Adding a provider to + * {@link LangChain4jModelFactory#STRATEGIES} makes it appear here automatically. + * + * @param provider the provider identifier, e.g. {@code openai}, {@code azure_openai} + * @param supportedCapabilities capabilities this provider can serve + * @param fields config fields per supported capability + */ +public record ProviderMetadata( + @JsonProperty("provider") String provider, + @JsonProperty("supportedCapabilities") Set supportedCapabilities, + @JsonProperty("fields") Map> fields) { + + public ProviderMetadata { + if (provider == null || provider.isBlank()) { + throw new IllegalArgumentException("ProviderMetadata provider is required"); + } + supportedCapabilities = supportedCapabilities == null ? Set.of() : Set.copyOf(supportedCapabilities); + fields = fields == null ? Map.of() : Map.copyOf(fields); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java new file mode 100644 index 000000000000..24e9af41e3da --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java @@ -0,0 +1,14 @@ +package com.dotcms.ai.client.langchain4j; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Outcome of a {@link ProviderConnectionTester} run for one provider/capability combination. + * + * @param success whether the provider accepted the request + * @param message human-readable detail — a confirmation on success, the provider/validation error on failure + */ +public record TestConnectionResult( + @JsonProperty("success") boolean success, + @JsonProperty("message") String message) { +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java index 927284b3a329..d952f1d9f1bb 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java @@ -15,6 +15,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; /** * {@link ModelProviderStrategy} implementation for Google Vertex AI. @@ -38,6 +40,31 @@ public String providerName() { return "vertex_ai"; } + @Override + public Set supportedCapabilities() { + return Set.of(Capability.CHAT); + } + + @Override + public List configFields(final Capability capability) { + return switch (capability) { + case CHAT -> List.of( + ProviderField.required("model", ProviderFieldType.STRING), + ProviderField.required("projectId", ProviderFieldType.STRING), + ProviderField.required("location", ProviderFieldType.STRING), + ProviderField.optional("credentialsJson", ProviderFieldType.SECRET, + "GCP service account JSON key; omit to use Application Default Credentials"), + ProviderField.optional("temperature", ProviderFieldType.NUMBER), + ProviderField.optional("maxTokens", ProviderFieldType.NUMBER), + ProviderField.optional("maxRetries", ProviderFieldType.NUMBER, + "Ignored when credentialsJson is set")); + case EMBEDDINGS -> throw new UnsupportedOperationException( + "Embeddings are not supported for Vertex AI provider via LangChain4J"); + case IMAGE -> throw new UnsupportedOperationException( + "Image generation is not supported for Vertex AI provider via LangChain4J"); + }; + } + @Override public ChatModel buildChatModel(final ProviderConfig config, final String modelType) { validate(config, modelType); diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java new file mode 100644 index 000000000000..492827d8a318 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java @@ -0,0 +1,83 @@ +package com.dotcms.ai.rest; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import org.apache.commons.lang3.StringUtils; + +import javax.servlet.http.HttpServletRequest; + +/** + * Resolves the target {@link Host} for a dotAI REST request from an optional {@code siteId} + * query parameter, falling back to the host derived from the HTTP request when the parameter is + * absent or unresolvable. Shared by every dotAI endpoint that reads or tests a per-site + * {@code providerConfig} ({@link CompletionsResource}, {@link AiProviderResource}). + */ +final class AiHostResolver { + + private AiHostResolver() { + } + + /** + * Resolves a host from {@code siteId} and falls back to the HTTP host on failure. + * Throws {@link DotSecurityException} when the user lacks permission for the requested site. + * Falls back to the HTTP-derived host when {@code siteId} is blank or not found. + */ + static Host resolveHost(final String siteId, + final HttpServletRequest request, + final User user) throws DotSecurityException { + if (StringUtils.isNotBlank(siteId)) { + try { + final Host found = findHost(siteId, user); + if (found != null) { + return found; + } + } catch (final DotSecurityException e) { + throw e; + } catch (final Exception e) { + Logger.warn(AiHostResolver.class, + "Could not resolve siteId '" + sanitize(siteId) + "', falling back to current host: " + e.getMessage()); + } + } + return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + } + + /** + * Resolves a host from {@code siteId} strictly — no fallback. + * Falls back to the HTTP-derived host when siteId is blank. + * Returns {@code null} when the site is not found. + * Throws {@link DotSecurityException} when the user lacks permission. + * Use for write operations where silently targeting the wrong site is unacceptable. + */ + static Host resolveHostStrict(final String siteId, + final HttpServletRequest request, + final User user) throws DotSecurityException { + if (StringUtils.isBlank(siteId)) { + return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + } + try { + return findHost(siteId, user); + } catch (final DotSecurityException e) { + throw e; + } catch (final Exception e) { + Logger.warn(AiHostResolver.class, "Could not resolve siteId '" + sanitize(siteId) + "': " + e.getMessage()); + return null; + } + } + + static String sanitize(final String value) { + return value == null ? "null" : value.replaceAll("[\r\n\t]", "_"); + } + + private static Host findHost(final String siteId, final User user) throws Exception { + if ("SYSTEM_HOST".equalsIgnoreCase(siteId)) { + return APILocator.systemHost(); + } + final Host found = APILocator.getHostAPI().find(siteId, user, false); + return (found != null && StringUtils.isNotBlank(found.getIdentifier()) && !found.isArchived()) ? found : null; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java new file mode 100644 index 000000000000..f561ebb95186 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java @@ -0,0 +1,269 @@ +package com.dotcms.ai.rest; + +import com.dotcms.ai.AiKeys; +import com.dotcms.ai.app.AppConfig; +import com.dotcms.ai.app.ConfigService; +import com.dotcms.ai.app.ProviderConfigMerger; +import com.dotcms.ai.client.langchain4j.Capability; +import com.dotcms.ai.client.langchain4j.LangChain4jModelFactory; +import com.dotcms.ai.client.langchain4j.ProviderConfig; +import com.dotcms.ai.client.langchain4j.ProviderConnectionTester; +import com.dotcms.ai.client.langchain4j.TestConnectionResult; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.beans.Host; +import com.dotmarketing.exception.DotSecurityException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.commons.lang3.StringUtils; +import org.glassfish.jersey.server.JSONP; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Exposes dotAI provider configuration metadata: which providers are available, which + * capabilities (chat, embeddings, image) each supports, and which {@code providerConfig} fields + * each capability needs. Backed entirely by {@link LangChain4jModelFactory#listProviderMetadata()} + * — adding a new provider there makes it appear here automatically, with no REST-layer change. + */ +@Path("/v1/ai/providers") +@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +public class AiProviderResource { + + private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); + + /** + * Lists capability and field metadata for every registered dotAI provider. + * + * @param request the HttpServletRequest object. + * @param response the HttpServletResponse object. + * @return a Response wrapping the list of provider metadata. + */ + @Operation( + operationId = "listAiProviders", + summary = "List dotAI provider configuration metadata", + description = "Returns, for every registered dotAI provider, the capabilities it " + + "supports (chat/embeddings/image) and the providerConfig fields each " + + "supported capability requires or accepts." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Provider metadata retrieved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityAiProviderListView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")) + }) + @GET + @JSONP + @NoCache + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + public final Response listProviders(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + + new WebResource.InitBuilder(request, response).requiredBackendUser(true).init(); + return Response.ok(new ResponseEntityAiProviderListView( + LangChain4jModelFactory.listProviderMetadata())).build(); + } + + /** + * Tests whether a provider configuration actually works: builds the model for the requested + * capability and issues one minimal, real request against the provider (a short chat reply, + * a one-line embedding, or — for image — the generation of a single test image). + * + *

The posted config section may carry masked credential fields (e.g. {@code "apiKey": + * "*****"}), left untouched by a client that only redisplays the previously-saved config. Any + * such masked field is resolved against the real value already stored for {@code siteId} + * before testing — mirroring how {@code PUT /v1/ai/completions/config} preserves unmasked + * credentials on save — so the real secret never has to round-trip through the browser. + * Resolution only happens when the posted {@code provider} and {@code endpoint} match what's + * actually stored, so a caller can't pair a masked credential with a different provider or an + * attacker-controlled {@code endpoint} to exfiltrate the real secret to it. Requires CMS admin, + * matching {@code PUT /v1/ai/completions/config}. + * + * @param request the HttpServletRequest object. + * @param response the HttpServletResponse object. + * @param capability which capability section to test — {@code chat}, {@code embeddings}, or {@code image}. + * @param siteId optional site identifier (or {@code SYSTEM_HOST}) whose stored config resolves masked + * credentials; falls back to the site derived from the HTTP Host header. + * @param body the provider config section to test, e.g. {@code {"provider":"openai","apiKey":"...","model":"gpt-4o"}}. + * @return a Response wrapping the test result: {@code success} plus a human-readable {@code message}. + */ + @Operation( + operationId = "testAiProviderConnection", + summary = "Test a dotAI provider connection", + description = "Builds the provider client for the given capability from the posted " + + "configuration and issues one minimal real request against the provider " + + "(a short chat reply, a one-line embedding, or a single test image). " + + "Masked credential fields (\"*****\") in the posted config are resolved " + + "against the real value already stored for siteId before testing. " + + "Returns success=false with a message on any validation or provider error " + + "rather than an HTTP error status, so the caller can always render the result." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Test executed — check the success field for the outcome", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityAiTestConnectionView.class))), + @ApiResponse(responseCode = "400", + description = "Unknown capability or malformed request body", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - requires CMS admin, or access denied to site", + content = @Content(mediaType = "application/json")) + }) + @POST + @JSONP + @NoCache + @Path("/test/{capability}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public final Response testConnection(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("capability") final String capability, + @QueryParam("siteId") final String siteId, + @RequestBody(description = "Provider config section to test", + content = @Content(schema = @Schema(implementation = Map.class))) + final String body) { + + final User user = new WebResource.InitBuilder(request, response).requiredBackendUser(true).init().getUser(); + + if (!user.isAdmin()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of(AiKeys.ERROR, "Only CMS admins can test the AI provider connection")) + .build(); + } + + final Capability parsedCapability; + try { + parsedCapability = Capability.valueOf(capability.toUpperCase(Locale.ROOT)); + } catch (final IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Unknown capability: " + AiHostResolver.sanitize(capability))) + .build(); + } + + if (StringUtils.isBlank(body)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Request body is required")) + .build(); + } + + final String resolvedBody; + try { + final Host host = AiHostResolver.resolveHostStrict(siteId, request, user); + if (host == null) { + final String msg = StringUtils.isNotBlank(siteId) + ? "Site not found: " + AiHostResolver.sanitize(siteId) + : "Could not resolve current site from request"; + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, msg)) + .build(); + } + final AppConfig storedConfig = ConfigService.INSTANCE.config(host); + resolvedBody = resolveMaskedCredentials(body, storedConfig.getProviderConfig(), + capability.toLowerCase(Locale.ROOT)); + } catch (final DotSecurityException e) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId))) + .build(); + } + + if (ProviderConfigMerger.containsMaskedCredential(resolvedBody)) { + return Response.ok(new ResponseEntityAiTestConnectionView(new TestConnectionResult(false, + "One or more credential fields still hold a placeholder value — " + + "re-enter them, or save the configuration first, then test again."))) + .build(); + } + + final ProviderConfig config; + try { + config = MAPPER.readValue(resolvedBody, ProviderConfig.class); + } catch (final Exception e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Invalid provider configuration: " + e.getMessage())) + .build(); + } + + final TestConnectionResult result = ProviderConnectionTester.test(parsedCapability, config); + return Response.ok(new ResponseEntityAiTestConnectionView(result)).build(); + } + + /** + * Resolves any {@code "*****"} masked credential field in {@code body} against the real value + * from the currently-stored {@code providerConfig}'s {@code sectionKey} section (e.g. {@code + * chat}, {@code embeddings}, {@code image}). Returns {@code body} unchanged — masked fields + * and all — when there's nothing masked, nothing stored yet, the stored section can't be + * parsed, or {@link #targetsStoredDestination} rejects the posted {@code provider}/{@code + * endpoint} as not matching what's stored; the caller then surfaces the still-masked + * credential as a "re-enter it" failure rather than silently using it. + */ + static String resolveMaskedCredentials(final String body, + final String storedProviderConfigJson, + final String sectionKey) { + if (StringUtils.isBlank(storedProviderConfigJson) || !ProviderConfigMerger.containsMasked(body)) { + return body; + } + try { + final JsonNode incoming = MAPPER.readTree(body); + final JsonNode storedSection = MAPPER.readTree(storedProviderConfigJson).get(sectionKey); + if (storedSection == null || !storedSection.isObject() + || !targetsStoredDestination(incoming, storedSection)) { + return body; + } + return ProviderConfigMerger.merge(body, storedSection.toString()); + } catch (final Exception e) { + return body; + } + } + + /** + * Guards against pairing a masked credential (obtainable by anyone who can {@code GET} the + * redacted config) with a different {@code provider} or a caller-controlled {@code endpoint} + * — which would otherwise resolve to the real stored secret and send it to a destination the + * caller chose rather than the one it was actually saved for. Only {@code provider} and {@code + * endpoint} are checked: every other field (model, temperature, timeout, region, etc.) doesn't + * change where the request — and the secret riding along with it — is sent, so those can + * differ freely between the posted body and the stored config. + */ + static boolean targetsStoredDestination(final JsonNode incoming, final JsonNode stored) { + return textEquals(incoming.get("provider"), stored.get("provider")) + && textEquals(incoming.get("endpoint"), stored.get("endpoint")); + } + + static boolean textEquals(final JsonNode a, final JsonNode b) { + final String left = a != null && !a.isNull() ? a.asText() : null; + final String right = b != null && !b.isNull() ? b.asText() : null; + + return Objects.equals(left, right); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index feb53422ec53..f36da6942d0e 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -184,10 +184,10 @@ public final Response getConfig(@Context final HttpServletRequest request, .getUser(); final Host host; try { - host = resolveHost(siteId, request, user); + host = AiHostResolver.resolveHost(siteId, request, user); } catch (final DotSecurityException e) { return Response.status(Response.Status.FORBIDDEN) - .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + sanitize(siteId))) + .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId))) .build(); } final AppConfig appConfig = ConfigService.INSTANCE.config(host); @@ -255,10 +255,10 @@ public Response saveConfig(@Context final HttpServletRequest request, } try { - final Host host = resolveHostStrict(siteId, request, user); + final Host host = AiHostResolver.resolveHostStrict(siteId, request, user); if (host == null) { final String msg = StringUtils.isNotBlank(siteId) - ? "Site not found: " + sanitize(siteId) + ? "Site not found: " + AiHostResolver.sanitize(siteId) : "Could not resolve current site from request"; return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of(AiKeys.ERROR, msg)) @@ -309,7 +309,7 @@ AppKeys.PROVIDER_CONFIG.key, redactCredentials(merged), } catch (final DotSecurityException e) { return Response.status(Response.Status.FORBIDDEN) - .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + sanitize(siteId))) + .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId))) .build(); } catch (final Exception e) { Logger.error(CompletionsResource.class, "Failed to save AI config: " + e.getMessage(), e); @@ -347,65 +347,6 @@ private static void redactNode(final JsonNode node) { } } - /** - * Resolves a host from {@code siteId} and falls back to the HTTP host on failure. - * Throws {@link DotSecurityException} when the user lacks permission for the requested site. - * Falls back to the HTTP-derived host when {@code siteId} is blank or not found. - */ - private static Host resolveHost(final String siteId, - final HttpServletRequest request, - final User user) throws DotSecurityException { - if (StringUtils.isNotBlank(siteId)) { - try { - final Host found = findHost(siteId, user); - if (found != null) { - return found; - } - } catch (final DotSecurityException e) { - throw e; - } catch (final Exception e) { - Logger.warn(CompletionsResource.class, - "Could not resolve siteId '" + sanitize(siteId) + "', falling back to current host: " + e.getMessage()); - } - } - return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); - } - - /** - * Resolves a host from {@code siteId} strictly — no fallback. - * Falls back to the HTTP-derived host when siteId is blank. - * Returns {@code null} when the site is not found. - * Throws {@link DotSecurityException} when the user lacks permission. - * Use for write operations where silently targeting the wrong site is unacceptable. - */ - private static Host resolveHostStrict(final String siteId, - final HttpServletRequest request, - final User user) throws DotSecurityException { - if (StringUtils.isBlank(siteId)) { - return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); - } - try { - return findHost(siteId, user); - } catch (final DotSecurityException e) { - throw e; - } catch (final Exception e) { - Logger.warn(CompletionsResource.class, "Could not resolve siteId '" + sanitize(siteId) + "': " + e.getMessage()); - return null; - } - } - - private static String sanitize(final String value) { - return value == null ? "null" : value.replaceAll("[\r\n\t]", "_"); - } - - private static Host findHost(final String siteId, final User user) throws Exception { - if ("SYSTEM_HOST".equalsIgnoreCase(siteId)) { - return APILocator.systemHost(); - } - final Host found = APILocator.getHostAPI().find(siteId, user, false); - return (found != null && StringUtils.isNotBlank(found.getIdentifier()) && !found.isArchived()) ? found : null; - } - private static Response badRequestResponse() { return Response.status(Response.Status.BAD_REQUEST).entity(Map.of(AiKeys.ERROR, "query required")).build(); } diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java new file mode 100644 index 000000000000..15f9f86c367e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java @@ -0,0 +1,15 @@ +package com.dotcms.ai.rest; + +import com.dotcms.ai.client.langchain4j.ProviderMetadata; +import com.dotcms.rest.ResponseEntityView; + +import java.util.List; + +/** + * Entity View wrapping the dotAI provider configuration metadata list response. + */ +public class ResponseEntityAiProviderListView extends ResponseEntityView> { + public ResponseEntityAiProviderListView(final List entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java new file mode 100644 index 000000000000..9a0daf8cbfb8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java @@ -0,0 +1,13 @@ +package com.dotcms.ai.rest; + +import com.dotcms.ai.client.langchain4j.TestConnectionResult; +import com.dotcms.rest.ResponseEntityView; + +/** + * Entity View wrapping the dotAI provider connection test result response. + */ +public class ResponseEntityAiTestConnectionView extends ResponseEntityView { + public ResponseEntityAiTestConnectionView(final TestConnectionResult entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index bbba45b0d4b5..378345760913 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5608,6 +5608,62 @@ apps.param.set.from.env=Set from the environment apps.content-analytics.generated.string.placeholder=Generated string will appear here... apps.content-analytics.generated.string.confirm.replace.header=Confirm Replacement apps.content-analytics.generated.string.confirm.replace.message=The current value will be replaced: "{0}". Are you sure you want to generate a new string? +apps.ai.config.title=dotAI Configuration +apps.ai.config.subtitle=Configure the AI providers used for chat, embeddings and image generation. Each capability is configured independently, so you can mix providers. +apps.ai.config.site=Site: {0} +apps.ai.loading=Loading... +apps.ai.unsaved.changes=Unsaved changes +apps.ai.button.cancel=Cancel +apps.ai.button.save=Save Configuration +apps.ai.error.load=Failed to load AI configuration +apps.ai.error.save=Failed to save AI configuration +apps.ai.validation.required-fields=Please fill in the required fields before saving. +apps.ai.capability.chat.title=Chat +apps.ai.capability.chat.description=Text generation for AI Blocks, workflows and the $ai viewtool. +apps.ai.capability.embeddings.title=Embeddings +apps.ai.capability.embeddings.description=Vector indexing for semantic search over your content. +apps.ai.capability.image.title=Image Generation +apps.ai.capability.image.description=Generated imagery for content items and Block Editor. +apps.ai.capability.chat.label=chat +apps.ai.capability.embeddings.label=embeddings +apps.ai.capability.image.label=images +apps.ai.badge.not-configured=Not Configured +apps.ai.provider.label=Provider +apps.ai.provider.capability.unsupported=No {0} support +apps.ai.advanced.optional.fields.header=Advanced {0} optional field(s) +apps.ai.button.test-connection=Test Connection +apps.ai.validation.required-fields-test=Please fill in the required fields before testing. +apps.ai.error.test-connection=Failed to test the connection. +apps.ai.settings.title=Settings +apps.ai.settings.description=Prompts and behavior applied across all capabilities. +apps.ai.settings.role-prompt.label=Role prompt +apps.ai.settings.role-prompt.placeholder=You are dotCMSbot... +apps.ai.settings.role-prompt.hint=Describes the role the AI plays for content authors. +apps.ai.settings.text-prompt.label=Text prompt +apps.ai.settings.text-prompt.placeholder=Use Descriptive writing style. +apps.ai.settings.image-prompt.label=Image prompt +apps.ai.settings.image-prompt.placeholder=Use 16:9 aspect ratio. +apps.ai.settings.image-size.label=Image size +apps.ai.settings.advanced.header=Advanced {0} settings +apps.ai.settings.field.embeddingsSplitAtTokens.label=Split into (tokens) +apps.ai.settings.field.embeddingsSplitAtTokens.hint=Token count used to chunk content before indexing. +apps.ai.settings.field.embeddingsMinimumTextLength.label=Minimum text length to index +apps.ai.settings.field.embeddingsMinimumFileSize.label=Minimum file size (bytes) +apps.ai.settings.field.embeddingsFileExtensions.label=File extensions +apps.ai.settings.field.embeddingsFileExtensions.hint=Comma-separated, e.g. pdf,doc,docx,txt,html +apps.ai.settings.field.embeddingsSearchThreshold.label=Search threshold +apps.ai.settings.field.embeddingsThreads.label=Threads +apps.ai.settings.field.embeddingsThreadsMax.label=Max threads +apps.ai.settings.field.embeddingsThreadsQueue.label=Thread queue size +apps.ai.settings.field.embeddingsCacheTtlSeconds.label=Cache TTL (s) +apps.ai.settings.field.embeddingsCacheSize.label=Cache size +apps.ai.settings.field.embeddingsDeleteOldOnUpdate.label=Delete old embeddings on content update +apps.ai.settings.field.debugLogging.label=Enable verbose debug logging +apps.ai.additional-properties.label=Additional properties +apps.ai.additional-properties.key.placeholder=Property name +apps.ai.additional-properties.value.placeholder=Value +apps.ai.additional-properties.remove.aria-label=Remove property +apps.ai.additional-properties.add.button=Add Property DotAsset=DotAsset VersionPath=Version Path IdPath=Id Path diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 83535f224d08..4f72d07600c6 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -3022,6 +3022,74 @@ paths: description: default response tags: - AI + /v1/ai/providers: + get: + description: "Returns, for every registered dotAI provider, the capabilities\ + \ it supports (chat/embeddings/image) and the providerConfig fields each supported\ + \ capability requires or accepts." + operationId: listAiProviders + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityAiProviderListView" + description: Provider metadata retrieved successfully + "401": + content: + application/json: {} + description: Unauthorized - authentication required + summary: List dotAI provider configuration metadata + tags: + - AI + /v1/ai/providers/test/{capability}: + post: + description: "Builds the provider client for the given capability from the posted\ + \ configuration and issues one minimal real request against the provider (a\ + \ short chat reply, a one-line embedding, or a single test image). Masked\ + \ credential fields (\"*****\") in the posted config are resolved against\ + \ the real value already stored for siteId before testing. Returns success=false\ + \ with a message on any validation or provider error rather than an HTTP error\ + \ status, so the caller can always render the result." + operationId: testAiProviderConnection + parameters: + - in: path + name: capability + required: true + schema: + type: string + - in: query + name: siteId + schema: + type: string + requestBody: + content: + application/json: + schema: + type: string + description: Provider config section to test + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityAiTestConnectionView" + description: Test executed — check the success field for the outcome + "400": + content: + application/json: {} + description: Unknown capability or malformed request body + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: "Forbidden - requires CMS admin, or access denied to site" + summary: Test a dotAI provider connection + tags: + - AI /v1/ai/search: get: operationId: searchByGet @@ -31331,6 +31399,43 @@ components: type: object additionalProperties: type: object + ProviderField: + type: object + properties: + hint: + type: string + name: + type: string + required: + type: boolean + requiredUnless: + type: string + type: + type: string + enum: + - STRING + - NUMBER + - SECRET + ProviderMetadata: + type: object + properties: + fields: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/ProviderField" + provider: + type: string + supportedCapabilities: + type: array + items: + type: string + enum: + - CHAT + - EMBEDDINGS + - IMAGE + uniqueItems: true PublishingEndPoint: type: object properties: @@ -32061,6 +32166,54 @@ components: type: array items: type: string + ResponseEntityAiProviderListView: + type: object + properties: + entity: + type: array + items: + $ref: "#/components/schemas/ProviderMetadata" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string + ResponseEntityAiTestConnectionView: + type: object + properties: + entity: + $ref: "#/components/schemas/TestConnectionResult" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityApiTokenWithJwtView: type: object properties: @@ -37649,6 +37802,13 @@ components: type: string working: type: boolean + TestConnectionResult: + type: object + properties: + message: + type: string + success: + type: boolean TextAreaField: type: object allOf: diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java new file mode 100644 index 000000000000..124b9414eb70 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java @@ -0,0 +1,188 @@ +package com.dotcms.ai.client.langchain4j; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Regression tests for {@link ProviderConnectionTester#friendlyMessage} (message truncation) and + * {@link ProviderConnectionTester#withDefaultTimeoutIfUnset} (bounding an otherwise-unbounded test + * call when the posted config omits {@code timeout}). + */ +public class ProviderConnectionTesterTest { + + /** + * Given an exception with a short message, + * When friendlyMessage is called, + * Then the message is returned unchanged. + */ + @Test + public void test_friendlyMessage_shortMessage_returnsUnchanged() { + final String message = "Invalid API key provided"; + + assertEquals(message, ProviderConnectionTester.friendlyMessage(new RuntimeException(message))); + } + + /** + * Given an exception with a null message, + * When friendlyMessage is called, + * Then the exception's simple class name is returned. + */ + @Test + public void test_friendlyMessage_nullMessage_returnsClassName() { + assertEquals("RuntimeException", ProviderConnectionTester.friendlyMessage(new RuntimeException())); + } + + /** + * Given an exception with a blank (whitespace-only) message, + * When friendlyMessage is called, + * Then the exception's simple class name is returned. + */ + @Test + public void test_friendlyMessage_blankMessage_returnsClassName() { + assertEquals("IllegalStateException", + ProviderConnectionTester.friendlyMessage(new IllegalStateException(" "))); + } + + /** + * Given an exception whose message is longer than the cap, + * When friendlyMessage is called, + * Then the result is truncated to the cap length plus the ellipsis suffix. + */ + @Test + public void test_friendlyMessage_longMessage_truncatedWithEllipsis() { + final String longMessage = "x".repeat(500); + + final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(longMessage)); + + assertTrue(result.endsWith("…")); + // 200 chars kept + 1 ellipsis char + assertEquals(201, result.length()); + } + + /** + * Given an exception message at exactly the cap length, + * When friendlyMessage is called, + * Then it is returned unchanged, with no truncation applied. + */ + @Test + public void test_friendlyMessage_exactlyAtCap_returnsUnchanged() { + final String message = "x".repeat(200); + + assertEquals(message, ProviderConnectionTester.friendlyMessage(new RuntimeException(message))); + } + + /** + * Given a message one character over the cap, + * When friendlyMessage is called, + * Then it is truncated. + */ + @Test + public void test_friendlyMessage_oneOverCap_truncated() { + final String message = "x".repeat(201); + + final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(message)); + + assertFalse(result.equals(message)); + assertTrue(result.endsWith("…")); + } + + /** + * Given an exception message containing newlines and repeated whitespace (typical of a + * pretty-printed JSON error body), + * When friendlyMessage is called, + * Then the whitespace is collapsed into single spaces. + */ + @Test + public void test_friendlyMessage_multilineMessage_whitespaceCollapsed() { + final String message = "Error occurred:\n\n {\n \"code\": 401,\n \"message\": \"bad key\"\n }"; + + final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(message)); + + assertFalse(result.contains("\n")); + assertTrue(result.contains("Error occurred: { \"code\": 401, \"message\": \"bad key\" }")); + } + + /** + * Given an exception message with leading/trailing whitespace, + * When friendlyMessage is called, + * Then the result is trimmed. + */ + @Test + public void test_friendlyMessage_leadingTrailingWhitespace_trimmed() { + final String message = " Invalid credentials "; + + assertEquals("Invalid credentials", ProviderConnectionTester.friendlyMessage(new RuntimeException(message))); + } + + // ------------------------------------------------------------------------- + // withDefaultTimeoutIfUnset + // ------------------------------------------------------------------------- + + /** + * Given a config with no timeout set, + * When withDefaultTimeoutIfUnset is called, + * Then the default test timeout is applied. + */ + @Test + public void test_withDefaultTimeoutIfUnset_noTimeoutSet_appliesDefault() { + final ProviderConfig config = ImmutableProviderConfig.builder() + .provider("openai").apiKey("test-key").model("gpt-4o").build(); + + final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.CHAT); + + assertEquals(Integer.valueOf(10), result.timeout()); + } + + /** + * Given an IMAGE-capability config with no timeout set, + * When withDefaultTimeoutIfUnset is called, + * Then the longer image-specific default is applied instead of the chat/embeddings default — + * real image generation routinely takes well past 10s. + */ + @Test + public void test_withDefaultTimeoutIfUnset_imageCapability_appliesLongerDefault() { + final ProviderConfig config = ImmutableProviderConfig.builder() + .provider("openai").apiKey("test-key").model("dall-e-3").build(); + + final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.IMAGE); + + assertEquals(Integer.valueOf(60), result.timeout()); + } + + /** + * Given a config that already sets a timeout, + * When withDefaultTimeoutIfUnset is called, + * Then the caller's timeout is preserved unchanged — even for IMAGE. + */ + @Test + public void test_withDefaultTimeoutIfUnset_timeoutAlreadySet_preservesCallerValue() { + final ProviderConfig config = ImmutableProviderConfig.builder() + .provider("openai").apiKey("test-key").model("gpt-4o").timeout(45).build(); + + final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.IMAGE); + + assertEquals(Integer.valueOf(45), result.timeout()); + } + + /** + * Given a config with no timeout set, + * When withDefaultTimeoutIfUnset is called, + * Then every other field is preserved unchanged — only timeout is added. + */ + @Test + public void test_withDefaultTimeoutIfUnset_preservesOtherFields() { + final ProviderConfig config = ImmutableProviderConfig.builder() + .provider("openai").apiKey("test-key").model("gpt-4o").temperature(0.5).build(); + + final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.CHAT); + + assertEquals("openai", result.provider()); + assertEquals("test-key", result.apiKey()); + assertEquals("gpt-4o", result.model()); + assertEquals(Double.valueOf(0.5), result.temperature()); + } + +} diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java new file mode 100644 index 000000000000..45e5fcb48eab --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java @@ -0,0 +1,243 @@ +package com.dotcms.ai.client.langchain4j; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link LangChain4jModelFactory#listProviderMetadata()} and the per-strategy + * {@code supportedCapabilities()}/{@code configFields()} declarations it aggregates. + * + *

The declared fields/capabilities restate knowledge already enforced imperatively in each + * strategy's {@code validate()}/builder logic, so nothing in the compiler keeps the two in sync. + * The {@code assertMissingRequiredFieldBreaksChatBuild} checks below cross-check that by + * construction: for a field the metadata declares required, building without it must actually fail. + */ +public class ProviderMetadataTest { + + @Test + public void test_listProviderMetadata_returnsAllSevenProviders() { + final List metadata = LangChain4jModelFactory.listProviderMetadata(); + final Set providers = metadata.stream() + .map(ProviderMetadata::provider) + .collect(Collectors.toSet()); + assertEquals(7, metadata.size()); + assertEquals(Set.of("openai", "azure_openai", "bedrock", "vertex_ai", "anthropic", "openrouter", "google_ai"), + providers); + } + + @Test + public void test_listProviderMetadata_capabilitiesMatchKnownSupport() { + final Map byProvider = indexByProvider(); + assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE), + byProvider.get("openai").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE), + byProvider.get("azure_openai").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS), + byProvider.get("bedrock").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT), byProvider.get("vertex_ai").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT), byProvider.get("anthropic").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS), + byProvider.get("openrouter").supportedCapabilities()); + assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE), + byProvider.get("google_ai").supportedCapabilities()); + } + + @Test + public void test_listProviderMetadata_fieldsKeyedOnlyBySupportedCapabilities() { + for (final ProviderMetadata metadata : LangChain4jModelFactory.listProviderMetadata()) { + assertEquals(metadata.supportedCapabilities(), metadata.fields().keySet()); + for (final Capability capability : metadata.supportedCapabilities()) { + assertTrue("provider " + metadata.provider() + " declares no fields for " + capability, + !metadata.fields().get(capability).isEmpty()); + } + } + } + + @Test + public void test_configFields_calledForUnsupportedCapability_throws() { + assertThrows(UnsupportedOperationException.class, () -> strategyFor("bedrock").configFields(Capability.IMAGE)); + assertThrows(UnsupportedOperationException.class, () -> strategyFor("vertex_ai").configFields(Capability.EMBEDDINGS)); + assertThrows(UnsupportedOperationException.class, () -> strategyFor("vertex_ai").configFields(Capability.IMAGE)); + assertThrows(UnsupportedOperationException.class, () -> strategyFor("anthropic").configFields(Capability.EMBEDDINGS)); + assertThrows(UnsupportedOperationException.class, () -> strategyFor("anthropic").configFields(Capability.IMAGE)); + assertThrows(UnsupportedOperationException.class, () -> strategyFor("openrouter").configFields(Capability.IMAGE)); + } + + // ── Declared-required-field cross-checks (chat capability, common to all 7 providers) ────── + + @Test + public void test_openai_chatRequiredFields_missingApiKey_throws() { + assertMissingRequiredFieldBreaksChatBuild("openai", + ImmutableProviderConfig.builder().provider("openai").model("gpt-4o-mini"), "apiKey"); + } + + @Test + public void test_openai_chatRequiredFields_missingModel_throws() { + assertMissingRequiredFieldBreaksChatBuild("openai", + ImmutableProviderConfig.builder().provider("openai").apiKey("test-key"), "model"); + } + + @Test + public void test_azureOpenAi_chatRequiredFields_missingApiKey_throws() { + assertMissingRequiredFieldBreaksChatBuild("azure_openai", + ImmutableProviderConfig.builder().provider("azure_openai").model("gpt-4o") + .endpoint("https://my-company.openai.azure.com/"), + "apiKey"); + } + + @Test + public void test_azureOpenAi_chatRequiredFields_missingEndpoint_throws() { + assertMissingRequiredFieldBreaksChatBuild("azure_openai", + ImmutableProviderConfig.builder().provider("azure_openai").model("gpt-4o").apiKey("test-key"), + "endpoint"); + } + + @Test + public void test_azureOpenAi_modelAndDeploymentName_declaredOptionalWithCrossHints() { + final ProviderMetadata metadata = indexByProvider().get("azure_openai"); + final List chatFields = metadata.fields().get(Capability.CHAT); + final ProviderField model = fieldNamed(chatFields, "model"); + final ProviderField deploymentName = fieldNamed(chatFields, "deploymentName"); + assertTrue(!model.required()); + assertTrue(!deploymentName.required()); + assertTrue(model.hint().contains("deploymentName")); + assertTrue(deploymentName.hint().contains("model")); + } + + /** + * A client (e.g. the config UI) needs {@code requiredUnless} — not just the hint text — to + * enforce the either-or relationship without parsing hint wording. Confirms both directions + * of the Azure model/deploymentName pair declare their sibling by field name. + */ + @Test + public void test_azureOpenAi_modelAndDeploymentName_declareRequiredUnlessSibling() { + final ProviderMetadata metadata = indexByProvider().get("azure_openai"); + final List chatFields = metadata.fields().get(Capability.CHAT); + final ProviderField model = fieldNamed(chatFields, "model"); + final ProviderField deploymentName = fieldNamed(chatFields, "deploymentName"); + assertEquals("deploymentName", model.requiredUnless()); + assertEquals("model", deploymentName.requiredUnless()); + } + + /** + * A field with no either-or relationship (e.g. a plain required field) must declare an empty + * {@code requiredUnless}, not null — so clients can treat it as always-falsy without a + * null-check. + */ + @Test + public void test_openai_apiKey_hasNoRequiredUnless() { + final ProviderMetadata metadata = indexByProvider().get("openai"); + final ProviderField apiKey = fieldNamed(metadata.fields().get(Capability.CHAT), "apiKey"); + assertEquals("", apiKey.requiredUnless()); + } + + @Test + public void test_bedrock_chatRequiredFields_missingRegion_throws() { + assertMissingRequiredFieldBreaksChatBuild("bedrock", + ImmutableProviderConfig.builder().provider("bedrock").model("anthropic.claude-3-5-sonnet-20241022-v2:0"), + "region"); + } + + @Test + public void test_bedrock_chatRequiredFields_missingModel_throws() { + assertMissingRequiredFieldBreaksChatBuild("bedrock", + ImmutableProviderConfig.builder().provider("bedrock").region("us-east-1"), "model"); + } + + @Test + public void test_vertexAi_chatRequiredFields_missingProjectId_throws() { + assertMissingRequiredFieldBreaksChatBuild("vertex_ai", + ImmutableProviderConfig.builder().provider("vertex_ai").model("gemini-1.5-pro").location("us-central1"), + "projectId"); + } + + @Test + public void test_vertexAi_chatRequiredFields_missingLocation_throws() { + assertMissingRequiredFieldBreaksChatBuild("vertex_ai", + ImmutableProviderConfig.builder().provider("vertex_ai").model("gemini-1.5-pro").projectId("my-gcp-project"), + "location"); + } + + @Test + public void test_anthropic_chatRequiredFields_missingApiKey_throws() { + assertMissingRequiredFieldBreaksChatBuild("anthropic", + ImmutableProviderConfig.builder().provider("anthropic").model("claude-sonnet-4-6"), "apiKey"); + } + + @Test + public void test_anthropic_chatRequiredFields_missingModel_throws() { + assertMissingRequiredFieldBreaksChatBuild("anthropic", + ImmutableProviderConfig.builder().provider("anthropic").apiKey("test-key"), "model"); + } + + @Test + public void test_openRouter_chatRequiredFields_missingModel_throws() { + assertMissingRequiredFieldBreaksChatBuild("openrouter", + ImmutableProviderConfig.builder().provider("openrouter").apiKey("test-key"), "model"); + } + + @Test + public void test_openRouter_chatRequiredFields_missingApiKey_throws() { + assertMissingRequiredFieldBreaksChatBuild("openrouter", + ImmutableProviderConfig.builder().provider("openrouter").model("openai/gpt-4o"), "apiKey"); + } + + @Test + public void test_googleAi_chatRequiredFields_missingApiKey_throws() { + assertMissingRequiredFieldBreaksChatBuild("google_ai", + ImmutableProviderConfig.builder().provider("google_ai").model("gemini-2.0-flash"), "apiKey"); + } + + @Test + public void test_googleAi_chatRequiredFields_missingModel_throws() { + assertMissingRequiredFieldBreaksChatBuild("google_ai", + ImmutableProviderConfig.builder().provider("google_ai").apiKey("test-key"), "model"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Asserts that {@code provider}'s metadata declares {@code missingFieldName} as required for + * CHAT, and that the given (deliberately incomplete) builder — missing exactly that field — + * indeed fails to build a chat model. Ties the declarative metadata to the imperative + * validation every strategy already performs. + */ + private static void assertMissingRequiredFieldBreaksChatBuild(final String provider, + final ImmutableProviderConfig.Builder incompleteBuilder, + final String missingFieldName) { + final ProviderMetadata metadata = indexByProvider().get(provider); + final ProviderField field = fieldNamed(metadata.fields().get(Capability.CHAT), missingFieldName); + assertTrue(provider + "." + missingFieldName + " must be declared required for CHAT in ProviderMetadata", + field.required()); + assertThrows(IllegalArgumentException.class, + () -> LangChain4jModelFactory.buildChatModel(incompleteBuilder.build())); + } + + private static ProviderField fieldNamed(final List fields, final String name) { + return fields.stream() + .filter(f -> f.name().equals(name)) + .findFirst() + .orElseThrow(() -> new AssertionError("no field named '" + name + "' in " + fields)); + } + + private static ModelProviderStrategy strategyFor(final String provider) { + return LangChain4jModelFactory.STRATEGIES.stream() + .filter(s -> s.providerName().equals(provider)) + .findFirst() + .orElseThrow(); + } + + private static Map indexByProvider() { + return LangChain4jModelFactory.listProviderMetadata().stream() + .collect(Collectors.toMap(ProviderMetadata::provider, m -> m)); + } + +} diff --git a/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java b/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java new file mode 100644 index 000000000000..98dec60759ce --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java @@ -0,0 +1,302 @@ +package com.dotcms.ai.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Regression tests for the masked-credential exfiltration/SSRF guard in + * {@link AiProviderResource#testConnection}: a masked credential (e.g. {@code "apiKey": + * "*****"}) must only resolve to the real stored secret when the posted {@code provider} and + * {@code endpoint} match what's actually stored — otherwise a caller could pair a masked field + * (obtainable from any {@code GET}) with an attacker-controlled {@code endpoint} and have the + * server send the real secret there. + */ +public class AiProviderResourceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // ------------------------------------------------------------------------- + // resolveMaskedCredentials + // ------------------------------------------------------------------------- + + /** + * Given a blank stored providerConfig, + * When resolveMaskedCredentials is called, + * Then the posted body is returned unchanged. + */ + @Test + public void test_resolveMaskedCredentials_blankStored_returnsBodyUnchanged() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}"; + + assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, "", "chat")); + assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, null, "chat")); + } + + /** + * Given a posted body with no masked sentinel, + * When resolveMaskedCredentials is called, + * Then the posted body is returned unchanged, even though a stored config exists. + */ + @Test + public void test_resolveMaskedCredentials_noMaskedValue_returnsBodyUnchanged() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"sk-real\"}"; + final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-stored\"}}"; + + assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat")); + } + + /** + * Given a masked apiKey and a posted provider/endpoint that match the stored section, + * When resolveMaskedCredentials is called, + * Then the real stored apiKey is restored. + */ + @Test + public void test_resolveMaskedCredentials_matchingProviderAndEndpoint_restoresCredential() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\",\"model\":\"gpt-4o\"}"; + final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\",\"model\":\"gpt-3.5\"}}"; + + final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat"); + + assertTrue(result.contains("sk-real-key")); + assertTrue(result.contains("gpt-4o")); // non-guarded field from the posted body still wins + assertFalse(result.contains("*****")); + } + + /** + * Given a masked apiKey posted with a DIFFERENT provider than what's stored for that + * capability (e.g. the UI carried over a masked value across a provider switch), + * When resolveMaskedCredentials is called, + * Then the sentinel is left in place rather than resolving to the wrong provider's secret. + */ + @Test + public void test_resolveMaskedCredentials_providerMismatch_leavesSentinelInPlace() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}"; + final String stored = "{\"chat\":{\"provider\":\"vertex_ai\",\"apiKey\":\"sk-real-key\"}}"; + + final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat"); + + assertTrue(result.contains("*****")); + assertFalse(result.contains("sk-real-key")); + } + + /** + * Given a masked apiKey posted alongside an endpoint that differs from the stored endpoint + * (the exfiltration/SSRF attempt: pair a masked credential with an attacker-controlled host), + * When resolveMaskedCredentials is called, + * Then the sentinel is left in place rather than sending the real secret to the new endpoint. + */ + @Test + public void test_resolveMaskedCredentials_endpointMismatch_leavesSentinelInPlace() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"," + + "\"endpoint\":\"https://attacker.example/v1\"}"; + final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"," + + "\"endpoint\":\"https://api.openai.com/v1\"}}"; + + final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat"); + + assertTrue(result.contains("*****")); + assertFalse(result.contains("sk-real-key")); + } + + /** + * Given a masked apiKey where neither the posted body nor the stored config sets an endpoint + * (the common case — no custom endpoint override), + * When resolveMaskedCredentials is called, + * Then the credential still resolves, since a missing endpoint on both sides is a match. + */ + @Test + public void test_resolveMaskedCredentials_neitherHasEndpoint_stillResolves() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}"; + final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}"; + + final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat"); + + assertTrue(result.contains("sk-real-key")); + } + + /** + * Given a stored providerConfig with no section for the requested capability, + * When resolveMaskedCredentials is called, + * Then the posted body is returned unchanged. + */ + @Test + public void test_resolveMaskedCredentials_missingSection_returnsBodyUnchanged() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}"; + final String stored = "{\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}"; + + final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat"); + + assertTrue(result.contains("*****")); + } + + /** + * Given a stored section that isn't a JSON object (defensively malformed data), + * When resolveMaskedCredentials is called, + * Then the posted body is returned unchanged. + */ + @Test + public void test_resolveMaskedCredentials_sectionNotAnObject_returnsBodyUnchanged() { + final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}"; + final String stored = "{\"chat\":\"not-an-object\"}"; + + assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat")); + } + + /** + * Given a posted body that isn't valid JSON, + * When resolveMaskedCredentials is called, + * Then the posted body is returned unchanged rather than throwing. + */ + @Test + public void test_resolveMaskedCredentials_invalidBody_returnsBodyUnchanged() { + final String body = "not-valid-json-*****"; + final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}"; + + assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat")); + } + + // ------------------------------------------------------------------------- + // targetsStoredDestination + // ------------------------------------------------------------------------- + + /** + * Given incoming and stored nodes with the same provider and endpoint, + * When targetsStoredDestination is called, + * Then it returns true. + */ + @Test + public void test_targetsStoredDestination_matchingProviderAndEndpoint_returnsTrue() throws Exception { + final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}"); + final JsonNode stored = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}"); + + assertTrue(AiProviderResource.targetsStoredDestination(incoming, stored)); + } + + /** + * Given incoming and stored nodes with different providers, + * When targetsStoredDestination is called, + * Then it returns false. + */ + @Test + public void test_targetsStoredDestination_differentProvider_returnsFalse() throws Exception { + final JsonNode incoming = node("{\"provider\":\"openai\"}"); + final JsonNode stored = node("{\"provider\":\"vertex_ai\"}"); + + assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored)); + } + + /** + * Given incoming and stored nodes with the same provider but different endpoints, + * When targetsStoredDestination is called, + * Then it returns false. + */ + @Test + public void test_targetsStoredDestination_differentEndpoint_returnsFalse() throws Exception { + final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://attacker.example\"}"); + final JsonNode stored = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}"); + + assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored)); + } + + /** + * Given incoming and stored nodes where neither sets an endpoint, + * When targetsStoredDestination is called, + * Then it returns true, since a missing endpoint on both sides matches. + */ + @Test + public void test_targetsStoredDestination_neitherHasEndpoint_returnsTrue() throws Exception { + final JsonNode incoming = node("{\"provider\":\"openai\"}"); + final JsonNode stored = node("{\"provider\":\"openai\"}"); + + assertTrue(AiProviderResource.targetsStoredDestination(incoming, stored)); + } + + /** + * Given an incoming node that sets an endpoint while the stored node has none, + * When targetsStoredDestination is called, + * Then it returns false, since that's exactly the "point the secret at a new host" case. + */ + @Test + public void test_targetsStoredDestination_onlyIncomingHasEndpoint_returnsFalse() throws Exception { + final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://attacker.example\"}"); + final JsonNode stored = node("{\"provider\":\"openai\"}"); + + assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored)); + } + + // ------------------------------------------------------------------------- + // textEquals + // ------------------------------------------------------------------------- + + /** + * Given two nodes with equal text values, + * When textEquals is called, + * Then it returns true. + */ + @Test + public void test_textEquals_equalValues_returnsTrue() throws Exception { + final JsonNode a = node("{\"v\":\"openai\"}").get("v"); + final JsonNode b = node("{\"v\":\"openai\"}").get("v"); + + assertTrue(AiProviderResource.textEquals(a, b)); + } + + /** + * Given two nodes with different text values, + * When textEquals is called, + * Then it returns false. + */ + @Test + public void test_textEquals_differentValues_returnsFalse() throws Exception { + final JsonNode a = node("{\"v\":\"openai\"}").get("v"); + final JsonNode b = node("{\"v\":\"vertex_ai\"}").get("v"); + + assertFalse(AiProviderResource.textEquals(a, b)); + } + + /** + * Given both nodes are null (field absent on both sides), + * When textEquals is called, + * Then it returns true. + */ + @Test + public void test_textEquals_bothNull_returnsTrue() { + assertTrue(AiProviderResource.textEquals(null, null)); + } + + /** + * Given one node is null (field absent) and the other holds a value, + * When textEquals is called, + * Then it returns false. + */ + @Test + public void test_textEquals_oneNull_returnsFalse() throws Exception { + final JsonNode b = node("{\"v\":\"openai\"}").get("v"); + + assertFalse(AiProviderResource.textEquals(null, b)); + assertFalse(AiProviderResource.textEquals(b, null)); + } + + /** + * Given both nodes are the JSON null literal (field present but explicitly null), + * When textEquals is called, + * Then it returns true, since a JSON null is treated the same as an absent field. + */ + @Test + public void test_textEquals_bothJsonNullLiteral_returnsTrue() throws Exception { + final JsonNode a = node("{\"v\":null}").get("v"); + final JsonNode b = node("{\"v\":null}").get("v"); + + assertTrue(AiProviderResource.textEquals(a, b)); + } + + private static JsonNode node(final String json) throws Exception { + return MAPPER.readTree(json); + } + +}