diff --git a/platform-api/internal/handler/llm_template_integration_test.go b/platform-api/internal/handler/llm_template_integration_test.go index 09b9e7008..0c9c84899 100644 --- a/platform-api/internal/handler/llm_template_integration_test.go +++ b/platform-api/internal/handler/llm_template_integration_test.go @@ -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) @@ -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. diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index fdcb5ebc5..89028e3ae 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -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 { diff --git a/platform-api/internal/service/llm_provider_template_test.go b/platform-api/internal/service/llm_provider_template_test.go index b127b5ee0..e6890010c 100644 --- a/platform-api/internal/service/llm_provider_template_test.go +++ b/platform-api/internal/service/llm_provider_template_test.go @@ -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") } } diff --git a/portals/ai-workspace/src/apis/providerTemplateApis.ts b/portals/ai-workspace/src/apis/providerTemplateApis.ts index 6a74d41d2..125cf4dbb 100644 --- a/portals/ai-workspace/src/apis/providerTemplateApis.ts +++ b/portals/ai-workspace/src/apis/providerTemplateApis.ts @@ -88,10 +88,18 @@ export async function createProviderTemplate( * console.log(response); // { count: 1, list: [...], pagination: {...} } * ``` */ -export async function getProviderTemplates(baseUrl: string): Promise { +export async function getProviderTemplates( + baseUrl: string, + latestOnly: boolean = true +): Promise { 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( - `/llm-provider-templates?query=${encodeURIComponent('latest:true')}`, + `/llm-provider-templates${query}`, undefined, baseUrl ); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx index 76be6aa08..ffb87dd22 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -841,19 +841,19 @@ export default function ProviderTemplateOverview() { /> )} - {isReadOnly && ( - - - {isEnabled ? 'Enabled' : 'Disabled'} - - void handleToggleEnabled(e.target.checked)} - inputProps={{ 'aria-label': 'Enable or disable this version' }} - /> - - )} + {/* Enable/disable applies to every template — built-in, custom, and + gateway-originated — since it only affects control-plane listing. */} + + + {isEnabled ? 'Enabled' : 'Disabled'} + + void handleToggleEnabled(e.target.checked)} + inputProps={{ 'aria-label': 'Enable or disable this version' }} + /> + {/* Custom templates can be deleted entirely (all versions). */} {canDelete && (