diff --git a/platform-api/internal/handler/llm.go b/platform-api/internal/handler/llm.go index 3a08706337..5dc69cba5d 100644 --- a/platform-api/internal/handler/llm.go +++ b/platform-api/internal/handler/llm.go @@ -341,8 +341,15 @@ func (h *LLMHandler) ListLLMProviders(w http.ResponseWriter, r *http.Request) er } limit, offset := parsePagination(r) + customPolicyUUID := strings.TrimSpace(r.URL.Query().Get("customPolicyUuid")) - resp, err := h.providerService.List(orgID, limit, offset) + var resp *api.LLMProviderListResponse + var err error + if customPolicyUUID != "" { + resp, err = h.providerService.ListByCustomPolicy(orgID, customPolicyUUID, limit, offset) + } else { + resp, err = h.providerService.List(orgID, limit, offset) + } if err != nil { return serviceError(err, fmt.Sprintf("failed to list LLM providers in org %s", orgID)) } diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 13354e371d..2b501717a3 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -252,6 +252,8 @@ type LLMProviderRepository interface { GetByID(providerID, orgUUID string) (*model.LLMProvider, error) List(orgUUID string, limit, offset int) ([]*model.LLMProvider, error) Count(orgUUID string) (int, error) + ListByCustomPolicy(orgUUID, policyUUID string, limit, offset int) ([]*model.LLMProvider, error) + CountByCustomPolicy(orgUUID, policyUUID string) (int, error) Update(p *model.LLMProvider) error UpdateWithCustomPolicyUsages(p *model.LLMProvider, policyUUIDs []string) error Delete(providerID, orgUUID string) error diff --git a/platform-api/internal/repository/llm.go b/platform-api/internal/repository/llm.go index fb1809bbc1..e82a3c245c 100644 --- a/platform-api/internal/repository/llm.go +++ b/platform-api/internal/repository/llm.go @@ -1083,6 +1083,71 @@ func (r *LLMProviderRepo) Count(orgUUID string) (int, error) { return r.artifactRepo.CountByKindAndOrg(constants.LLMProvider, orgUUID) } +func (r *LLMProviderRepo) ListByCustomPolicy(orgUUID, policyUUID string, limit, offset int) ([]*model.LLMProvider, error) { + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + args := append([]any{orgUUID, policyUUID}, pageArgs...) + query := ` + SELECT + p.uuid, p.handle, p.display_name, p.version, p.organization_uuid, p.origin, p.data_version, p.created_at, p.updated_at, + p.description, p.created_by, p.updated_by, p.template_uuid, p.openapi_spec, p.model_list, p.configuration + FROM llm_providers p + INNER JOIN gateway_custom_policy_usages u ON u.artifact_uuid = p.uuid + WHERE p.organization_uuid = ? AND u.policy_uuid = ? + ORDER BY p.created_at DESC + ` + pageClause + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []*model.LLMProvider + for rows.Next() { + var p model.LLMProvider + var createdBy, updatedBy sql.NullString + var openAPISpec, modelProvidersRaw []byte + var configurationJSON []byte + err := rows.Scan( + &p.UUID, &p.ID, &p.Name, &p.Version, &p.OrganizationUUID, &p.Origin, &p.DataVersion, &p.CreatedAt, &p.UpdatedAt, + &p.Description, &createdBy, &updatedBy, &p.TemplateUUID, &openAPISpec, &modelProvidersRaw, &configurationJSON, + ) + if err != nil { + return nil, err + } + p.CreatedBy = createdBy.String + p.UpdatedBy = updatedBy.String + if len(openAPISpec) > 0 { + p.OpenAPISpec = string(openAPISpec) + } + if len(modelProvidersRaw) > 0 { + if err := json.Unmarshal(modelProvidersRaw, &p.ModelProviders); err != nil { + return nil, fmt.Errorf("unmarshal modelProviders for provider %s: %w", p.ID, err) + } + } + if len(configurationJSON) > 0 { + if config, err := deserializeLLMProviderConfiguration(configurationJSON); err != nil { + return nil, fmt.Errorf("unmarshal configuration for provider %s: %w", p.ID, err) + } else if config != nil { + p.Configuration = *config + } + } + res = append(res, &p) + } + return res, rows.Err() +} + +func (r *LLMProviderRepo) CountByCustomPolicy(orgUUID, policyUUID string) (int, error) { + var count int + query := ` + SELECT COUNT(*) FROM llm_providers p + INNER JOIN gateway_custom_policy_usages u ON u.artifact_uuid = p.uuid + WHERE p.organization_uuid = ? AND u.policy_uuid = ?` + if err := r.db.QueryRow(r.db.Rebind(query), orgUUID, policyUUID).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + func (r *LLMProviderRepo) Update(p *model.LLMProvider) error { return r.update(p, nil, false) } diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index ae96fc115e..49bb7f3dd1 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -1098,6 +1098,77 @@ func (s *LLMProviderService) List(orgUUID string, limit, offset int) (*api.LLMPr return resp, nil } +// ListByCustomPolicy lists LLM providers that reference the given custom policy UUID. +func (s *LLMProviderService) ListByCustomPolicy(orgUUID, customPolicyUUID string, limit, offset int) (*api.LLMProviderListResponse, error) { + if customPolicyUUID == "" { + return nil, apperror.ValidationFailed.New("The custom policy uuid is required.") + } + if s.customPolicyRepo == nil { + return nil, fmt.Errorf("could not initialize custom policy repository") + } + policy, err := s.customPolicyRepo.GetCustomPolicyByUUID(orgUUID, customPolicyUUID) + if err != nil { + return nil, fmt.Errorf("failed to validate custom policy: %w", err) + } + if policy == nil { + return nil, apperror.CustomPolicyNotFound.New() + } + + items, err := s.repo.ListByCustomPolicy(orgUUID, customPolicyUUID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list providers by custom policy: %w", err) + } + totalCount, err := s.repo.CountByCustomPolicy(orgUUID, customPolicyUUID) + if err != nil { + return nil, fmt.Errorf("failed to count providers by custom policy: %w", err) + } + resp := &api.LLMProviderListResponse{ + Count: len(items), + Pagination: api.Pagination{ + Limit: limit, + Offset: offset, + Total: totalCount, + }, + } + resp.List = make([]api.LLMProviderListItem, 0, len(items)) + createdByFields := make([]**string, 0, len(items)) + for _, p := range items { + // Look up template handle from UUID + tplHandle := "" + if p.TemplateUUID != "" { + tpl, err := s.templateRepo.GetByUUID(p.TemplateUUID, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to resolve template for provider %s: %w", p.ID, err) + } + if tpl != nil { + tplHandle = tpl.ID + } + } + id := p.ID + name := p.Name + desc := utils.StringPtrIfNotEmpty(p.Description) + createdBy := utils.StringPtrIfNotEmpty(p.CreatedBy) + version := p.Version + template := utils.StringPtrIfNotEmpty(tplHandle) + resp.List = append(resp.List, api.LLMProviderListItem{ + Id: &id, + DisplayName: name, + Description: desc, + CreatedBy: createdBy, + Version: &version, + Template: template, + ReadOnly: utils.BoolPtr(p.Origin == constants.OriginDP), + CreatedAt: utils.TimePtr(p.CreatedAt), + UpdatedAt: utils.TimePtr(p.UpdatedAt), + }) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err + } + return resp, nil +} + func (s *LLMProviderService) Get(orgUUID, handle string) (*api.LLMProvider, error) { if handle == "" { return nil, apperror.ValidationFailed.New("The LLM provider id is required.") diff --git a/platform-api/internal/service/llm_custom_policy_test.go b/platform-api/internal/service/llm_custom_policy_test.go index 5d0d214813..246bfcb860 100644 --- a/platform-api/internal/service/llm_custom_policy_test.go +++ b/platform-api/internal/service/llm_custom_policy_test.go @@ -30,8 +30,9 @@ import ( type llmCustomPolicyRepo struct { repository.CustomPolicyRepository - policies map[string][]*model.CustomPolicy - lookupErr error + policies map[string][]*model.CustomPolicy + policyByID map[string]*model.CustomPolicy + lookupErr error } func (r *llmCustomPolicyRepo) GetCustomPoliciesByName(_ string, name string) ([]*model.CustomPolicy, error) { @@ -41,6 +42,13 @@ func (r *llmCustomPolicyRepo) GetCustomPoliciesByName(_ string, name string) ([] return r.policies[name], nil } +func (r *llmCustomPolicyRepo) GetCustomPolicyByUUID(_ string, policyUUID string) (*model.CustomPolicy, error) { + if r.lookupErr != nil { + return nil, r.lookupErr + } + return r.policyByID[policyUUID], nil +} + func TestLLMProviderResolveCustomPolicyUUIDs(t *testing.T) { repo := &llmCustomPolicyRepo{ policies: map[string][]*model.CustomPolicy{ diff --git a/platform-api/internal/service/llm_test.go b/platform-api/internal/service/llm_test.go index e28bc25b0a..7f46cbbbaa 100644 --- a/platform-api/internal/service/llm_test.go +++ b/platform-api/internal/service/llm_test.go @@ -1009,12 +1009,15 @@ func findOperationPath(policy *api.OperationPolicy, path string) *api.OperationP type mockLLMProviderRepo struct { repository.LLMProviderRepository - existsResult bool - countResult int - getByIDFunc func(providerID, orgUUID string) (*model.LLMProvider, error) - createCalled bool - created *model.LLMProvider - updated *model.LLMProvider + existsResult bool + countResult int + getByIDFunc func(providerID, orgUUID string) (*model.LLMProvider, error) + createCalled bool + created *model.LLMProvider + updated *model.LLMProvider + listByCustomPolicyItems []*model.LLMProvider + countByCustomPolicyValue int + lastListCustomPolicyUUID string } func (m *mockLLMProviderRepo) Exists(providerID, orgUUID string) (bool, error) { @@ -1051,6 +1054,15 @@ func (m *mockLLMProviderRepo) UpdateWithCustomPolicyUsages(p *model.LLMProvider, return m.Update(p) } +func (m *mockLLMProviderRepo) ListByCustomPolicy(orgUUID, policyUUID string, limit, offset int) ([]*model.LLMProvider, error) { + m.lastListCustomPolicyUUID = policyUUID + return m.listByCustomPolicyItems, nil +} + +func (m *mockLLMProviderRepo) CountByCustomPolicy(orgUUID, policyUUID string) (int, error) { + return m.countByCustomPolicyValue, nil +} + type mockLLMTemplateRepo struct { repository.LLMProviderTemplateRepository getByIDFunc func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) @@ -1526,6 +1538,42 @@ func TestLLMProxyServiceListByProviderUsesProviderUUID(t *testing.T) { } } +func TestLLMProviderServiceListByCustomPolicyUsesPolicyUUID(t *testing.T) { + now := time.Now() + providerRepo := &mockLLMProviderRepo{ + listByCustomPolicyItems: []*model.LLMProvider{{ + UUID: "provider-uuid", + ID: "provider-1", + Name: "Provider One", + Version: "v1.0", + CreatedAt: now, + UpdatedAt: now, + }}, + countByCustomPolicyValue: 1, + } + customPolicyRepo := &llmCustomPolicyRepo{ + policyByID: map[string]*model.CustomPolicy{ + "policy-uuid": {UUID: "policy-uuid", Name: "rate-limit", Version: "v1.0.0"}, + }, + } + service := NewLLMProviderService(providerRepo, nil, nil, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService()) + service.SetCustomPolicyRepository(customPolicyRepo) + + resp, err := service.ListByCustomPolicy("org-1", "policy-uuid", 10, 0) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if providerRepo.lastListCustomPolicyUUID != "policy-uuid" { + t.Fatalf("expected list by custom policy to use policy UUID, got: %q", providerRepo.lastListCustomPolicyUUID) + } + if resp == nil || resp.Count != 1 || len(resp.List) != 1 { + t.Fatalf("expected one provider in response, got: %#v", resp) + } + if resp.List[0].DisplayName != "Provider One" { + t.Fatalf("expected provider display name to round-trip, got: %q", resp.List[0].DisplayName) + } +} + func TestLLMProxyServiceUpdatePreservesProviderAuthValue(t *testing.T) { now := time.Now() proxyRepo := &mockLLMProxyRepo{} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 46f76544e1..1864497d6b 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1474,6 +1474,7 @@ paths: tags: - LLM Providers parameters: + - $ref: '#/components/parameters/customPolicyUuid-Q' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: @@ -8871,6 +8872,17 @@ components: type: string example: "default-project" + customPolicyUuid-Q: + name: customPolicyUuid + in: query + required: false + description: | + Filter the returned LLM providers to only those referencing the custom policy with this UUID. + schema: + type: string + format: uuid + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + gatewayId-Q: name: gatewayId in: query diff --git a/portals/ai-workspace/src/apis/llmProviderApis.ts b/portals/ai-workspace/src/apis/llmProviderApis.ts index 26302bf189..d0079e93ed 100644 --- a/portals/ai-workspace/src/apis/llmProviderApis.ts +++ b/portals/ai-workspace/src/apis/llmProviderApis.ts @@ -187,25 +187,28 @@ export async function createLLMProvider( } /** - * Get all LLM Providers + * Get all LLM Providers, optionally filtered to those referencing a custom policy * * @param organizationId - The organization ID + * @param baseUrl - The platform API base URL + * @param customPolicyUuid - When provided, only providers using this custom policy are returned * @returns Promise with the list of LLM providers * * @example * ```ts - * const response = await getLLMProviders('org-uuid'); + * const response = await getLLMProviders('org-uuid', baseUrl); * console.log(response); // { count: 1, list: [...], pagination: {...} } * ``` */ export async function getLLMProviders( organizationId: string, - baseUrl: string + baseUrl: string, + customPolicyUuid?: string ): Promise { try { const response = await get( `/llm-providers`, - undefined, + customPolicyUuid ? { customPolicyUuid } : undefined, baseUrl ); return response; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/gateways/CustomPoliciesList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/gateways/CustomPoliciesList.tsx index 5911cfaa44..10c0a5e9e5 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/gateways/CustomPoliciesList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/gateways/CustomPoliciesList.tsx @@ -18,11 +18,17 @@ import type { ElementType } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { + Avatar, Box, Card, + CircularProgress, + Divider, IconButton, InputAdornment, + List, + ListItemButton, PageContent, Stack, Table, @@ -43,17 +49,28 @@ import { DialogTitle, Button, } from '@wso2/oxygen-ui'; -import { Search, ShieldCheck, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { + ChevronRight, + Search, + ShieldCheck, + Trash2, +} from '@wso2/oxygen-ui-icons-react'; import { FormattedMessage, useIntl } from 'react-intl'; import ErrorAlert from '../../../../Components/common/ErrorAlert'; import { deleteGatewayCustomPolicy, getGatewayCustomPolicies, + getGatewayCustomPolicy, } from '../../../../apis/gatewayPolicyApis'; import type { GatewayCustomPolicy } from '../../../../apis/gatewayPolicyApis'; +import { getLLMProviders } from '../../../../apis/llmProviderApis'; +import type { LLMProvider } from '../../../../utils/types'; import { useAIWorkspaceSnackbar } from '../../../../hooks/aiWorkspaceSnackbar'; -import { getErrorMessage } from '../../../../utils/apiError'; +import { getErrorCode, getErrorMessage } from '../../../../utils/apiError'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import { PLATFORM_API_BASE_URL } from '../../../../paths'; import { SCOPES } from '../../../../auth/permissions'; const ROWS_PER_PAGE_OPTIONS = [10, 25, 50]; @@ -132,6 +149,202 @@ function formatVersion(version: string): string { return `v${version.replace(/^v/i, '')}`; } +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[1][0]}`.toUpperCase(); +} + +interface PolicyInUseDialogProps { + open: boolean; + gatewayCustomPolicyId: string; + version: string; + fallbackName?: string; + onClose: () => void; +} + +function PolicyInUseDialog({ + open, + gatewayCustomPolicyId, + version, + fallbackName, + onClose, +}: PolicyInUseDialogProps) { + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + const organizationId = currentOrganization?.uuid; + + const [policy, setPolicy] = useState(null); + const [usedByProviders, setUsedByProviders] = useState([]); + const [totalProviderCount, setTotalProviderCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !gatewayCustomPolicyId || !version || !organizationId) return; + + let isMounted = true; + setIsLoading(true); + setError(null); + setPolicy(null); + setUsedByProviders([]); + setTotalProviderCount(0); + + Promise.allSettled([ + getGatewayCustomPolicy(gatewayCustomPolicyId, version), + getLLMProviders(organizationId, PLATFORM_API_BASE_URL, gatewayCustomPolicyId), + ]) + .then(([policyResult, providersResult]) => { + if (!isMounted) return; + if (policyResult.status === 'fulfilled') { + setPolicy(policyResult.value); + } + if (providersResult.status === 'fulfilled') { + const list = providersResult.value.list ?? []; + setUsedByProviders(list); + setTotalProviderCount(providersResult.value.pagination?.total ?? list.length); + } else { + setError(getErrorMessage(providersResult.reason, 'Failed to load policy usage.')); + } + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [open, gatewayCustomPolicyId, version, organizationId]); + + const handleProviderClick = (providerId: string) => { + navigate(buildOrgPath(currentOrganization, `/service-provider/${providerId}`)); + onClose(); + }; + + const displayName = policy?.displayName || policy?.name || fallbackName; + + return ( + + + + + + + + {displayName} + + {policy?.version ? ( + + {formatVersion(policy.version)} + + ) : null} + {policy?.description ? ( + + {policy.description} + + ) : null} + + + + + {isLoading ? ( + + + + ) : error ? ( + + {error} + + ) : usedByProviders.length === 0 ? ( + + + + ) : ( + <> + + + + {totalProviderCount > usedByProviders.length ? ( + + + + ) : null} + + {usedByProviders.map((provider) => { + const providerId = provider.id ?? provider.displayName; + return ( + handleProviderClick(providerId)} + sx={{ + borderRadius: 1, + mb: 0.5, + border: 1, + borderColor: 'divider', + }} + > + + + {getInitials(provider.displayName)} + + + {provider.displayName} + + + + + ); + })} + + + )} + + + + + + ); +} + interface CustomPoliciesListProps { // When true, renders bare (no PageContent padding) for embedding inside a // page that already provides its own PageContent — e.g. GatewaysList. @@ -162,6 +375,11 @@ export default function CustomPoliciesList({ name: string; } | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const [usageDialogTarget, setUsageDialogTarget] = useState<{ + uuid: string; + version: string; + name: string; + } | null>(null); const fetchPolicies = useCallback(async () => { setIsLoading(true); @@ -240,6 +458,10 @@ export default function CustomPoliciesList({ getErrorMessage(cause, 'Failed to delete the custom policy.'), 'error' ); + if (getErrorCode(cause) === 'POLICY_IN_USE') { + setUsageDialogTarget(deleteTarget); + setDeleteTarget(null); + } } finally { setIsDeleting(false); } @@ -544,6 +766,16 @@ export default function CustomPoliciesList({ + + {usageDialogTarget && ( + setUsageDialogTarget(null)} + /> + )} ); }