Skip to content

Commit 995663e

Browse files
Merge pull request #2750 from Tharanidk/enable-disable
Add enable/disable functionality for custom templates
2 parents e120696 + c195855 commit 995663e

6 files changed

Lines changed: 140 additions & 40 deletions

File tree

platform-api/internal/handler/llm_template_integration_test.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ func TestLLMTemplateHTTP_BlankGroupIDQuery(t *testing.T) {
332332
}
333333
}
334334

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

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

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

359365
// Unknown handle -> 404.

platform-api/internal/service/llm.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -696,14 +696,6 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, groupID, version
696696
if target == nil {
697697
return nil, apperror.LLMProviderTemplateNotFound.New()
698698
}
699-
// Enable/disable is reserved for built-in ('wso2') templates only. Custom
700-
// templates are managed via update/delete and cannot be toggled.
701-
if target.ManagedBy != constants.PolicyManagedByWSO2 {
702-
return nil, apperror.LLMProviderTemplateNotToggleable.New()
703-
}
704-
if err := ensureOriginMutable(target.Origin); err != nil {
705-
return nil, err
706-
}
707699
if !enabled {
708700
inUse, err := s.repo.CountProvidersUsingTemplate(groupID, orgUUID, v)
709701
if err != nil {

platform-api/internal/service/llm_provider_template_test.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -679,22 +679,41 @@ func TestLLMProviderTemplateServiceSetVersionEnabled_EnableIgnoresUsage(t *testi
679679
}
680680
}
681681

682-
func TestLLMProviderTemplateServiceSetVersionEnabled_RejectsCustomTemplate(t *testing.T) {
682+
func TestLLMProviderTemplateServiceSetVersionEnabled_AllowsCustomTemplate(t *testing.T) {
683683
repo := &mockLLMProviderTemplateCRUDRepo{
684+
getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) {
685+
return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "organization", Enabled: false}, nil
686+
},
687+
}
688+
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())
689+
690+
resp, err := svc.SetVersionEnabled("org-1", "openai", "v2.0", false)
691+
if err != nil {
692+
t.Fatalf("expected custom template to be toggleable, got: %v", err)
693+
}
694+
if !repo.setEnabledCalled || repo.setEnabledEnabled {
695+
t.Fatalf("expected SetEnabled to be called with enabled=false, got called=%v enabled=%v", repo.setEnabledCalled, repo.setEnabledEnabled)
696+
}
697+
if resp == nil || resp.Enabled == nil || *resp.Enabled {
698+
t.Fatalf("expected response to reflect disabled state, got: %#v", resp)
699+
}
700+
}
701+
702+
func TestLLMProviderTemplateServiceSetVersionEnabled_CustomTemplateDisableBlocksWhenInUse(t *testing.T) {
703+
repo := &mockLLMProviderTemplateCRUDRepo{
704+
countProvidersUsingTemplateResult: 1,
684705
getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) {
685706
return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "organization"}, nil
686707
},
687708
}
688709
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())
689710

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

portals/ai-workspace/src/apis/providerTemplateApis.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,18 @@ export async function createProviderTemplate(
8888
* console.log(response); // { count: 1, list: [...], pagination: {...} }
8989
* ```
9090
*/
91-
export async function getProviderTemplates(baseUrl: string): Promise<ProviderTemplatesResponse> {
91+
export async function getProviderTemplates(
92+
baseUrl: string,
93+
latestOnly: boolean = true
94+
): Promise<ProviderTemplatesResponse> {
9295
try {
96+
// latestOnly=true restricts the response to the is_latest version per
97+
// family; false returns every version so callers can group/compare.
98+
const query = latestOnly
99+
? `?query=${encodeURIComponent('latest:true')}`
100+
: '';
93101
const response = await get<ProviderTemplatesResponse>(
94-
`/llm-provider-templates?query=${encodeURIComponent('latest:true')}`,
102+
`/llm-provider-templates${query}`,
95103
undefined,
96104
baseUrl
97105
);

portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -841,19 +841,19 @@ export default function ProviderTemplateOverview() {
841841
/>
842842
</Button>
843843
)}
844-
{isReadOnly && (
845-
<Stack direction="row" spacing={1} alignItems="center">
846-
<Typography variant="body2" color="text.primary">
847-
{isEnabled ? 'Enabled' : 'Disabled'}
848-
</Typography>
849-
<Switch
850-
checked={isEnabled}
851-
disabled={isTogglingEnabled}
852-
onChange={(e) => void handleToggleEnabled(e.target.checked)}
853-
inputProps={{ 'aria-label': 'Enable or disable this version' }}
854-
/>
855-
</Stack>
856-
)}
844+
{/* Enable/disable applies to every template — built-in, custom, and
845+
gateway-originated — since it only affects control-plane listing. */}
846+
<Stack direction="row" spacing={1} alignItems="center">
847+
<Typography variant="body2" color="text.primary">
848+
{isEnabled ? 'Enabled' : 'Disabled'}
849+
</Typography>
850+
<Switch
851+
checked={isEnabled}
852+
disabled={isTogglingEnabled}
853+
onChange={(e) => void handleToggleEnabled(e.target.checked)}
854+
inputProps={{ 'aria-label': 'Enable or disable this version' }}
855+
/>
856+
</Stack>
857857
{/* Custom templates can be deleted entirely (all versions). */}
858858
{canDelete && (
859859
<Button

portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import React, { useMemo, useState } from 'react';
19+
import React, { useEffect, useMemo, useState } from 'react';
2020
import { useNavigate } from 'react-router-dom';
2121
import {
2222
Avatar,
@@ -41,8 +41,14 @@ import { useProviderTemplates } from '../../../../contexts/llmProvider/providerT
4141
import { useAppShell } from '../../../../contexts/AppShellContext';
4242
import { buildOrgPath } from '../../../../utils/projectRouting';
4343
import ErrorAlert from '../../../../Components/common/ErrorAlert';
44-
import { truncateProviderDisplayName } from '../../../../utils/providerTemplateDisplay';
44+
import {
45+
familyHandle,
46+
truncateProviderDisplayName,
47+
} from '../../../../utils/providerTemplateDisplay';
4548
import type { ProviderTemplate } from '../../../../utils/types';
49+
import * as providerTemplateApis from '../../../../apis/providerTemplateApis';
50+
import { PLATFORM_API_BASE_URL } from '../../../../config.env';
51+
import { logger } from '../../../../utils/logger';
4652
import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg';
4753
import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp';
4854
import AzureLogo from '../../../../assets/brands/Azure.png';
@@ -94,13 +100,82 @@ export default function ProviderTemplatesList({
94100
useProviderTemplates();
95101

96102
const [searchQuery, setSearchQuery] = useState('');
103+
const [allVersions, setAllVersions] = useState<ProviderTemplate[] | null>(
104+
null
105+
);
106+
107+
// The shared context fetches latest-only (one is_latest row per family).
108+
// This listing must consider every version's enabled flag, so fetch all
109+
// versions here. Re-fetched whenever the context refreshes (create/delete/
110+
// toggle) so the cards stay in sync.
111+
useEffect(() => {
112+
let cancelled = false;
113+
(async () => {
114+
try {
115+
const response = await providerTemplateApis.getProviderTemplates(
116+
PLATFORM_API_BASE_URL,
117+
false
118+
);
119+
if (!cancelled) setAllVersions(response.list ?? []);
120+
} catch (fetchError) {
121+
logger.error('Failed to fetch all template versions:', fetchError);
122+
if (!cancelled) setAllVersions(null); // fall back to latest-only list
123+
}
124+
})();
125+
return () => {
126+
cancelled = true;
127+
};
128+
}, [templatesResponse]);
129+
130+
// One card per family: the highest ENABLED version; when every version in
131+
// the family is disabled, the highest version overall (rendered with the
132+
// existing dimmed "Disabled" styling). Grouping is per (ownership, family)
133+
// because creating a version from a built-in clones into the same groupId
134+
// with managedBy 'organization' — a family-only key would make the built-in
135+
// card vanish from the Built-in section.
136+
const familyCards = useMemo(() => {
137+
const source = allVersions ?? templatesResponse.list;
138+
const parseVersion = (value?: string): [number, number] => {
139+
const match = /^v(\d+)\.(\d+)$/.exec((value ?? '').trim());
140+
return match ? [Number(match[1]), Number(match[2])] : [-1, -1];
141+
};
142+
const isHigher = (a: ProviderTemplate, b: ProviderTemplate): boolean => {
143+
const [aMajor, aMinor] = parseVersion(a.version);
144+
const [bMajor, bMinor] = parseVersion(b.version);
145+
return aMajor > bMajor || (aMajor === bMajor && aMinor > bMinor);
146+
};
147+
const enabledPick = new Map<string, ProviderTemplate>();
148+
const anyPick = new Map<string, ProviderTemplate>();
149+
for (const template of source) {
150+
const ownership =
151+
(template.managedBy ?? template.provider) === 'wso2'
152+
? 'wso2'
153+
: 'custom';
154+
const key = `${ownership}:${
155+
template.groupId ?? familyHandle((template.id ?? '').toLowerCase())
156+
}`;
157+
const currentAny = anyPick.get(key);
158+
if (!currentAny || isHigher(template, currentAny)) {
159+
anyPick.set(key, template);
160+
}
161+
if (template.enabled !== false) {
162+
const currentEnabled = enabledPick.get(key);
163+
if (!currentEnabled || isHigher(template, currentEnabled)) {
164+
enabledPick.set(key, template);
165+
}
166+
}
167+
}
168+
return Array.from(anyPick.entries()).map(
169+
([key, fallback]) => enabledPick.get(key) ?? fallback
170+
);
171+
}, [allVersions, templatesResponse.list]);
97172

98173
const templates = useMemo(
99174
() =>
100-
templatesResponse.list.filter(
175+
familyCards.filter(
101176
(template) => (template.managedBy ?? template.provider) !== 'wso2'
102177
),
103-
[templatesResponse.list]
178+
[familyCards]
104179
);
105180

106181
const templatesBase = buildOrgPath(
@@ -132,10 +207,10 @@ export default function ProviderTemplatesList({
132207

133208
const builtInTemplates = useMemo(
134209
() =>
135-
templatesResponse.list.filter(
210+
familyCards.filter(
136211
(template) => (template.managedBy ?? template.provider) === 'wso2'
137212
),
138-
[templatesResponse.list]
213+
[familyCards]
139214
);
140215
const filteredBuiltIn = useMemo(() => {
141216
const query = searchQuery.trim().toLowerCase();

0 commit comments

Comments
 (0)