Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions platform-api/internal/handler/llm_template_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ func TestLLMTemplateHTTP_BlankGroupIDQuery(t *testing.T) {
}
}

// ---- PATCH enable/disable by handle (built-in only) -----------------------
// ---- PATCH enable/disable by handle (built-in and custom) -----------------

func TestLLMTemplateHTTP_ToggleByHandle(t *testing.T) {
r, _, cleanup := setupLLMTemplateEnv(t)
Expand All @@ -350,10 +350,16 @@ func TestLLMTemplateHTTP_ToggleByHandle(t *testing.T) {
t.Errorf("re-enable built-in: expected 200, got %d", w.Code)
}

// Custom template cannot be toggled -> 403.
// Custom templates are toggleable too.
handle, _ := createFamily(t, r, "Custom Toggle")
if w := doJSON(t, r, http.MethodPatch, tmplBase+"/"+handle, `{"enabled":false}`, true); w.Code != http.StatusForbidden {
t.Errorf("toggle custom: expected 403, got %d: %s", w.Code, w.Body.String())
w = doJSON(t, r, http.MethodPatch, tmplBase+"/"+handle, `{"enabled":false}`, true)
if w.Code != http.StatusOK {
t.Errorf("disable custom: expected 200, got %d: %s", w.Code, w.Body.String())
} else if bodyMap(t, w)["enabled"] != false {
t.Errorf("expected enabled=false after disabling custom template")
}
if w := doJSON(t, r, http.MethodPatch, tmplBase+"/"+handle, `{"enabled":true}`, true); w.Code != http.StatusOK {
t.Errorf("re-enable custom: expected 200, got %d: %s", w.Code, w.Body.String())
}

// Unknown handle -> 404.
Expand Down
8 changes: 0 additions & 8 deletions platform-api/internal/service/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -696,14 +696,6 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, groupID, version
if target == nil {
return nil, apperror.LLMProviderTemplateNotFound.New()
}
// Enable/disable is reserved for built-in ('wso2') templates only. Custom
// templates are managed via update/delete and cannot be toggled.
if target.ManagedBy != constants.PolicyManagedByWSO2 {
return nil, apperror.LLMProviderTemplateNotToggleable.New()
}
if err := ensureOriginMutable(target.Origin); err != nil {
return nil, err
}
if !enabled {
inUse, err := s.repo.CountProvidersUsingTemplate(groupID, orgUUID, v)
if err != nil {
Expand Down
33 changes: 26 additions & 7 deletions platform-api/internal/service/llm_provider_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,22 +679,41 @@ func TestLLMProviderTemplateServiceSetVersionEnabled_EnableIgnoresUsage(t *testi
}
}

func TestLLMProviderTemplateServiceSetVersionEnabled_RejectsCustomTemplate(t *testing.T) {
func TestLLMProviderTemplateServiceSetVersionEnabled_AllowsCustomTemplate(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{
getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) {
return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "organization", Enabled: false}, nil
},
}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())

resp, err := svc.SetVersionEnabled("org-1", "openai", "v2.0", false)
if err != nil {
t.Fatalf("expected custom template to be toggleable, got: %v", err)
}
if !repo.setEnabledCalled || repo.setEnabledEnabled {
t.Fatalf("expected SetEnabled to be called with enabled=false, got called=%v enabled=%v", repo.setEnabledCalled, repo.setEnabledEnabled)
}
if resp == nil || resp.Enabled == nil || *resp.Enabled {
t.Fatalf("expected response to reflect disabled state, got: %#v", resp)
}
}

func TestLLMProviderTemplateServiceSetVersionEnabled_CustomTemplateDisableBlocksWhenInUse(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{
countProvidersUsingTemplateResult: 1,
getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) {
return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "organization"}, nil
},
}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())

// Enable/disable is reserved for built-in ('wso2') templates; a custom
// ('organization') template must be rejected and never touch SetEnabled.
_, err := svc.SetVersionEnabled("org-1", "openai", "v2.0", false)
if !apperror.LLMProviderTemplateNotToggleable.Is(err) {
t.Fatalf("expected ErrLLMProviderTemplateNotToggleable, got: %v", err)
if !apperror.LLMProviderTemplateInUse.Is(err) {
t.Fatalf("expected ErrLLMProviderTemplateInUse for in-use custom template, got: %v", err)
}
if repo.setEnabledCalled || repo.countProvidersUsingTemplateCalled {
t.Fatalf("did not expect SetEnabled or usage check for a non-toggleable custom template")
if repo.setEnabledCalled {
t.Fatalf("did not expect SetEnabled to be called while version is in use")
}
}

Expand Down
12 changes: 10 additions & 2 deletions portals/ai-workspace/src/apis/providerTemplateApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,18 @@ export async function createProviderTemplate(
* console.log(response); // { count: 1, list: [...], pagination: {...} }
* ```
*/
export async function getProviderTemplates(baseUrl: string): Promise<ProviderTemplatesResponse> {
export async function getProviderTemplates(
baseUrl: string,
latestOnly: boolean = true
): Promise<ProviderTemplatesResponse> {
try {
// latestOnly=true restricts the response to the is_latest version per
// family; false returns every version so callers can group/compare.
const query = latestOnly
? `?query=${encodeURIComponent('latest:true')}`
: '';
const response = await get<ProviderTemplatesResponse>(
`/llm-provider-templates?query=${encodeURIComponent('latest:true')}`,
`/llm-provider-templates${query}`,
undefined,
baseUrl
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,19 +841,19 @@ export default function ProviderTemplateOverview() {
/>
</Button>
)}
{isReadOnly && (
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" color="text.primary">
{isEnabled ? 'Enabled' : 'Disabled'}
</Typography>
<Switch
checked={isEnabled}
disabled={isTogglingEnabled}
onChange={(e) => void handleToggleEnabled(e.target.checked)}
inputProps={{ 'aria-label': 'Enable or disable this version' }}
/>
</Stack>
)}
{/* Enable/disable applies to every template — built-in, custom, and
gateway-originated — since it only affects control-plane listing. */}
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" color="text.primary">
{isEnabled ? 'Enabled' : 'Disabled'}
</Typography>
<Switch
checked={isEnabled}
disabled={isTogglingEnabled}
onChange={(e) => void handleToggleEnabled(e.target.checked)}
inputProps={{ 'aria-label': 'Enable or disable this version' }}
/>
</Stack>
{/* Custom templates can be deleted entirely (all versions). */}
{canDelete && (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* under the License.
*/

import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Expand All @@ -41,8 +41,14 @@ import { useProviderTemplates } from '../../../../contexts/llmProvider/providerT
import { useAppShell } from '../../../../contexts/AppShellContext';
import { buildOrgPath } from '../../../../utils/projectRouting';
import ErrorAlert from '../../../../Components/common/ErrorAlert';
import { truncateProviderDisplayName } from '../../../../utils/providerTemplateDisplay';
import {
familyHandle,
truncateProviderDisplayName,
} from '../../../../utils/providerTemplateDisplay';
import type { ProviderTemplate } from '../../../../utils/types';
import * as providerTemplateApis from '../../../../apis/providerTemplateApis';
import { PLATFORM_API_BASE_URL } from '../../../../config.env';
import { logger } from '../../../../utils/logger';
import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg';
import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp';
import AzureLogo from '../../../../assets/brands/Azure.png';
Expand Down Expand Up @@ -94,13 +100,82 @@ export default function ProviderTemplatesList({
useProviderTemplates();

const [searchQuery, setSearchQuery] = useState('');
const [allVersions, setAllVersions] = useState<ProviderTemplate[] | null>(
null
);

// The shared context fetches latest-only (one is_latest row per family).
// This listing must consider every version's enabled flag, so fetch all
// versions here. Re-fetched whenever the context refreshes (create/delete/
// toggle) so the cards stay in sync.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const response = await providerTemplateApis.getProviderTemplates(
PLATFORM_API_BASE_URL,
false
);
if (!cancelled) setAllVersions(response.list ?? []);
} catch (fetchError) {
logger.error('Failed to fetch all template versions:', fetchError);
if (!cancelled) setAllVersions(null); // fall back to latest-only list
}
})();
return () => {
cancelled = true;
};
}, [templatesResponse]);

// One card per family: the highest ENABLED version; when every version in
// the family is disabled, the highest version overall (rendered with the
// existing dimmed "Disabled" styling). Grouping is per (ownership, family)
// because creating a version from a built-in clones into the same groupId
// with managedBy 'organization' — a family-only key would make the built-in
// card vanish from the Built-in section.
const familyCards = useMemo(() => {
const source = allVersions ?? templatesResponse.list;
const parseVersion = (value?: string): [number, number] => {
const match = /^v(\d+)\.(\d+)$/.exec((value ?? '').trim());
return match ? [Number(match[1]), Number(match[2])] : [-1, -1];
};
const isHigher = (a: ProviderTemplate, b: ProviderTemplate): boolean => {
const [aMajor, aMinor] = parseVersion(a.version);
const [bMajor, bMinor] = parseVersion(b.version);
return aMajor > bMajor || (aMajor === bMajor && aMinor > bMinor);
};
const enabledPick = new Map<string, ProviderTemplate>();
const anyPick = new Map<string, ProviderTemplate>();
for (const template of source) {
const ownership =
(template.managedBy ?? template.provider) === 'wso2'
? 'wso2'
: 'custom';
const key = `${ownership}:${
template.groupId ?? familyHandle((template.id ?? '').toLowerCase())
}`;
const currentAny = anyPick.get(key);
if (!currentAny || isHigher(template, currentAny)) {
anyPick.set(key, template);
}
if (template.enabled !== false) {
const currentEnabled = enabledPick.get(key);
if (!currentEnabled || isHigher(template, currentEnabled)) {
enabledPick.set(key, template);
}
}
}
return Array.from(anyPick.entries()).map(
([key, fallback]) => enabledPick.get(key) ?? fallback
);
}, [allVersions, templatesResponse.list]);

const templates = useMemo(
() =>
templatesResponse.list.filter(
familyCards.filter(
(template) => (template.managedBy ?? template.provider) !== 'wso2'
),
[templatesResponse.list]
[familyCards]
);

const templatesBase = buildOrgPath(
Expand Down Expand Up @@ -132,10 +207,10 @@ export default function ProviderTemplatesList({

const builtInTemplates = useMemo(
() =>
templatesResponse.list.filter(
familyCards.filter(
(template) => (template.managedBy ?? template.provider) === 'wso2'
),
[templatesResponse.list]
[familyCards]
);
const filteredBuiltIn = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
Expand Down
Loading