diff --git a/docs/rest-apis/gateway/llm-provider-template-management.md b/docs/rest-apis/gateway/llm-provider-template-management.md index 7c1a4bf277..9ab85dd24e 100644 --- a/docs/rest-apis/gateway/llm-provider-template-management.md +++ b/docs/rest-apis/gateway/llm-provider-template-management.md @@ -248,6 +248,8 @@ Status Code **200** |»»»»» **additionalProperties**|string|false|none|none| |»»» spec|[LLMProviderTemplateData](schemas.md#schemallmprovidertemplatedata)|true|none|none| |»»»» displayName|string|true|none|Human-readable LLM Template name| +|»»»» provider|string|false|none|Origin of the template. Built-in templates use 'wso2'; custom
templates default to 'other' and may be set to any value.| +|»»»» version|string|false|none|Template content version (e.g. v1.0). Multiple versions of the same
handle can coexist; defaults to v1.0 when omitted.| |»»»» promptTokens|[ExtractionIdentifier](schemas.md#schemaextractionidentifier)|false|none|none| |»»»»» location|string|true|none|Where to find the token information| |»»»»» identifier|string|true|none|JSONPath expression or header name to identify the token value| diff --git a/gateway/gateway-controller/README.md b/gateway/gateway-controller/README.md index 92db92dda2..f8ff4b7ab9 100644 --- a/gateway/gateway-controller/README.md +++ b/gateway/gateway-controller/README.md @@ -349,7 +349,7 @@ Response (the server echoes back the full k8s-shaped resource with a server-managed `status` block): ```json { - "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "apiVersion": "gateway.api-platform.wso2.com/v1", "kind": "RestApi", "metadata": { "name": "weather-api-v1.0" }, "spec": { @@ -643,7 +643,7 @@ components: properties: apiVersion: type: string - example: gateway.api-platform.wso2.com/v1alpha1 + example: gateway.api-platform.wso2.com/v1 kind: type: string example: RestApi @@ -652,7 +652,7 @@ components: spec: $ref: "#/components/schemas/APIConfigData" example: - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: petstore-api-v1.0 diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index f3926b0181..168ecbe7ad 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -5566,6 +5566,26 @@ components: minLength: 1 maxLength: 253 example: OpenAI + groupId: + type: string + description: | + Stable family-grouping identifier shared by every version of this + template. Defaults to metadata.name when omitted. + maxLength: 253 + example: openai + managedBy: + type: string + description: | + Origin of the template. Built-in templates use 'wso2'; custom + templates default to 'customer' and may be set to any value. + maxLength: 253 + example: wso2 + version: + type: string + description: | + Template content version (e.g. v1.0). Multiple versions of the same + group_id can coexist; defaults to v1.0 when omitted. + example: v1.0 promptTokens: $ref: '#/components/schemas/ExtractionIdentifier' completionTokens: diff --git a/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml index 5a46cf08b7..6744eb9d83 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml @@ -22,6 +22,9 @@ metadata: name: anthropic spec: displayName: Anthropic + groupId: anthropic + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.input_tokens diff --git a/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml index 71b8b3d82d..5b8968f9d5 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml @@ -22,6 +22,9 @@ metadata: name: awsbedrock spec: displayName: AWS Bedrock + groupId: awsbedrock + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.inputTokens diff --git a/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml index cf132b952c..a40afb1b28 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml @@ -22,6 +22,9 @@ metadata: name: azureai-foundry spec: displayName: Azure AI Foundry + groupId: azureai-foundry + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.prompt_tokens diff --git a/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml index 9784c3d7d6..a3da0b060b 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml @@ -22,6 +22,9 @@ metadata: name: azure-openai spec: displayName: Azure OpenAI + groupId: azure-openai + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.prompt_tokens diff --git a/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml index 78ba507aae..cafd577ddf 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml @@ -22,6 +22,9 @@ metadata: name: gemini spec: displayName: Gemini + groupId: gemini + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usageMetadata.promptTokenCount diff --git a/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml index 15703d138e..23f37fdced 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml @@ -22,6 +22,9 @@ metadata: name: mistralai spec: displayName: MistralAI + groupId: mistralai + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.prompt_tokens diff --git a/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml b/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml index 410797da61..c19ae69749 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml @@ -22,6 +22,9 @@ metadata: name: openai spec: displayName: OpenAI + groupId: openai + managedBy: wso2 + version: v1.0 promptTokens: location: payload identifier: $.usage.prompt_tokens diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index f207e3852c..43d7277d50 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -659,7 +659,7 @@ func (s *APIServer) getLLMProviderTemplate(sourceConfig any) (*api.LLMProviderTe return nil, fmt.Errorf("template name is empty") } - storedTemplate, err := s.store.GetTemplateByHandle(templateNameStr) + storedTemplate, err := s.store.GetTemplateByID(templateNameStr) if err != nil { return nil, fmt.Errorf("failed to get template '%s' from store: %w", templateNameStr, err) } diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index f442ae64d4..8f008849e8 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -303,7 +303,7 @@ func (m *MockStorage) GetLLMProviderTemplate(id string) (*models.StoredLLMProvid if tmpl, ok := m.templates[id]; ok { return tmpl, nil } - return nil, errors.New("template not found") + return nil, fmt.Errorf("%w: uuid=%s", storage.ErrNotFound, id) } func (m *MockStorage) GetAllLLMProviderTemplates() ([]*models.StoredLLMProviderTemplate, error) { @@ -322,7 +322,7 @@ func (m *MockStorage) GetLLMProviderTemplateByHandle(handle string) (*models.Sto return nil, m.getErr } for _, tmpl := range m.templates { - if tmpl.GetHandle() == handle { + if tmpl.GetGroupID() == handle { return tmpl, nil } } @@ -1898,7 +1898,7 @@ func TestCreateLLMProviderTemplateWithDBAndEventHub(t *testing.T) { } require.NotNil(t, storedTemplate) - assert.Equal(t, "openai", storedTemplate.GetHandle()) + assert.Equal(t, "openai", storedTemplate.GetGroupID()) assert.Equal(t, "test-gateway", mockHub.publishedEvents[0].gatewayID) assert.Equal(t, eventhub.EventTypeLLMTemplate, mockHub.publishedEvents[0].event.EventType) assert.Equal(t, "CREATE", mockHub.publishedEvents[0].event.Action) diff --git a/gateway/gateway-controller/pkg/api/handlers/list_operations_test.go b/gateway/gateway-controller/pkg/api/handlers/list_operations_test.go index d6cba725ee..306db8fe2d 100644 --- a/gateway/gateway-controller/pkg/api/handlers/list_operations_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/list_operations_test.go @@ -199,14 +199,16 @@ func TestGetLLMProviderTemplateByIdSuccess(t *testing.T) { server := createTestServerWithLLM() now := time.Now() + version := "v1.0" template := &models.StoredLLMProviderTemplate{ UUID: "0000-template1-0000-000000000000", Configuration: api.LLMProviderTemplate{ Metadata: api.Metadata{ - Name: "0000-template1-0000-000000000000", + Name: "openai", }, Spec: api.LLMProviderTemplateData{ DisplayName: "OpenAI Template", + Version: &version, }, }, CreatedAt: now, @@ -216,8 +218,9 @@ func TestGetLLMProviderTemplateByIdSuccess(t *testing.T) { require.NoError(t, server.db.SaveLLMProviderTemplate(template)) require.NoError(t, server.store.AddTemplate(template)) - c, w := createTestContext("GET", "/llm-provider-templates/template1", nil) - server.GetLLMProviderTemplateById(c, "0000-template1-0000-000000000000") + // The template is fetched by its version-specific id (handle + version). + c, w := createTestContext("GET", "/llm-provider-templates/openai-v1-0", nil) + server.GetLLMProviderTemplateById(c, "openai-v1-0") assert.Equal(t, http.StatusOK, w.Code) @@ -225,14 +228,15 @@ func TestGetLLMProviderTemplateByIdSuccess(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - // GET returns the k8s-shaped resource body directly. The server-managed - // status block carries the handle (name) as `id`. + // GET returns the k8s-shaped resource body directly. metadata.name is the + // version-independent handle, while the server-managed status block carries + // the version-specific id (handle + sanitized version). metadata, ok := response["metadata"].(map[string]interface{}) require.True(t, ok, "metadata should be a map, got %T", response["metadata"]) - assert.Equal(t, "0000-template1-0000-000000000000", metadata["name"]) + assert.Equal(t, "openai", metadata["name"]) status, ok := response["status"].(map[string]interface{}) require.True(t, ok, "status should be a map, got %T", response["status"]) - assert.Equal(t, "0000-template1-0000-000000000000", status["id"]) + assert.Equal(t, "openai-v1-0", status["id"]) } // TestListLLMProvidersEmpty tests listing with no providers diff --git a/gateway/gateway-controller/pkg/api/handlers/llm_provider_template_handler.go b/gateway/gateway-controller/pkg/api/handlers/llm_provider_template_handler.go index 0122efab89..5fff1392cf 100644 --- a/gateway/gateway-controller/pkg/api/handlers/llm_provider_template_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/llm_provider_template_handler.go @@ -82,7 +82,7 @@ func (s *APIServer) CreateLLMProviderTemplate(c *gin.Context) { log.Info("LLM provider template created successfully", slog.String("uuid", storedTemplate.UUID), - slog.String("handle", storedTemplate.GetHandle())) + slog.String("handle", storedTemplate.GetGroupID())) c.JSON(http.StatusCreated, buildTemplateResourceResponse(storedTemplate)) } @@ -109,10 +109,10 @@ func (s *APIServer) ListLLMProviderTemplates(c *gin.Context, params api.ListLLMP func (s *APIServer) GetLLMProviderTemplateById(c *gin.Context, id string) { log := middleware.GetLogger(c, s.logger) - template, err := s.llmDeploymentService.GetLLMProviderTemplateByHandle(id) + template, err := s.llmDeploymentService.GetLLMProviderTemplateByID(id) if err != nil { if storage.IsNotFoundError(err) { - log.Warn("LLM provider template not found", slog.String("handle", id)) + log.Warn("LLM provider template not found", slog.String("id", id)) c.JSON(http.StatusNotFound, api.ErrorResponse{ Status: "error", Message: fmt.Sprintf("Template with id '%s' not found", id), @@ -179,7 +179,7 @@ func (s *APIServer) UpdateLLMProviderTemplate(c *gin.Context, id string) { log.Info("LLM provider template updated successfully", slog.String("uuid", updated.UUID), - slog.String("handle", updated.GetHandle())) + slog.String("handle", updated.GetGroupID())) c.JSON(http.StatusOK, buildTemplateResourceResponse(updated)) } @@ -210,11 +210,11 @@ func (s *APIServer) DeleteLLMProviderTemplate(c *gin.Context, id string) { log.Info("LLM provider template deleted successfully", slog.String("uuid", deleted.UUID), - slog.String("handle", deleted.GetHandle())) + slog.String("handle", deleted.GetGroupID())) c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "LLM provider template deleted successfully", - "id": deleted.GetHandle(), + "id": deleted.GetID(), }) } diff --git a/gateway/gateway-controller/pkg/api/handlers/resource_response.go b/gateway/gateway-controller/pkg/api/handlers/resource_response.go index 93ccb623ff..ec6a5fe608 100644 --- a/gateway/gateway-controller/pkg/api/handlers/resource_response.go +++ b/gateway/gateway-controller/pkg/api/handlers/resource_response.go @@ -149,7 +149,7 @@ func buildTemplateResourceResponse(template *models.StoredLLMProviderTemplate) a if template == nil { return nil } - id := template.GetHandle() + id := template.GetID() createdAt := template.CreatedAt updatedAt := template.UpdatedAt status := api.ResourceStatus{ diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 845ab7d922..45d4df972f 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -892,13 +892,25 @@ type LLMProviderTemplateData struct { CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" yaml:"completionTokens,omitempty"` // DisplayName Human-readable LLM Template name - DisplayName string `json:"displayName" yaml:"displayName"` + DisplayName string `json:"displayName" yaml:"displayName"` + + // GroupId Stable family-grouping identifier shared by every version of this + // template. Defaults to metadata.name when omitted. + GroupId *string `json:"groupId,omitempty" yaml:"groupId,omitempty"` + + // ManagedBy Origin of the template. Built-in templates use 'wso2'; custom + // templates default to 'customer' and may be set to any value. + ManagedBy *string `json:"managedBy,omitempty" yaml:"managedBy,omitempty"` PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" yaml:"promptTokens,omitempty"` RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" yaml:"remainingTokens,omitempty"` RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" yaml:"requestModel,omitempty"` ResourceMappings *LLMProviderTemplateResourceMappings `json:"resourceMappings,omitempty" yaml:"resourceMappings,omitempty"` ResponseModel *ExtractionIdentifier `json:"responseModel,omitempty" yaml:"responseModel,omitempty"` TotalTokens *ExtractionIdentifier `json:"totalTokens,omitempty" yaml:"totalTokens,omitempty"` + + // Version Template content version (e.g. v1.0). Multiple versions of the same + // group_id can coexist; defaults to v1.0 when omitted. + Version *string `json:"version,omitempty" yaml:"version,omitempty"` } // LLMProviderTemplateRequest defines model for LLMProviderTemplateRequest. diff --git a/gateway/gateway-controller/pkg/eventlistener/llm_template_processor.go b/gateway/gateway-controller/pkg/eventlistener/llm_template_processor.go index 3ccb7ee0d3..6822ff0fa8 100644 --- a/gateway/gateway-controller/pkg/eventlistener/llm_template_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/llm_template_processor.go @@ -25,7 +25,6 @@ import ( "strings" "github.com/wso2/api-platform/common/eventhub" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" @@ -118,10 +117,10 @@ func (l *EventListener) handleLLMTemplateDelete(event eventhub.Event) { } if existingTemplate != nil { - if err := l.removeLLMTemplate(existingTemplate.GetHandle(), event.EventID); err != nil { + if err := l.removeLLMTemplate(existingTemplate.GetGroupID(), event.EventID); err != nil { l.logger.Warn("Failed to remove LLM template lazy resource", slog.String("template_id", entityID), - slog.String("template_handle", existingTemplate.GetHandle()), + slog.String("template_handle", existingTemplate.GetGroupID()), slog.Any("error", err)) } } @@ -136,7 +135,7 @@ func (l *EventListener) syncLLMTemplate(template *models.StoredLLMProviderTempla return nil } - resource, err := l.buildLLMTemplateLazyResource(template.Configuration) + resource, err := l.buildLLMTemplateLazyResource(template) if err != nil { return err } @@ -144,24 +143,25 @@ func (l *EventListener) syncLLMTemplate(template *models.StoredLLMProviderTempla return l.lazyResourceManager.StoreResource(resource, correlationID) } -func (l *EventListener) removeLLMTemplate(handle, correlationID string) error { - if l.lazyResourceManager == nil || handle == "" { +func (l *EventListener) removeLLMTemplate(groupVersionID, correlationID string) error { + if l.lazyResourceManager == nil || groupVersionID == "" { return nil } return l.lazyResourceManager.RemoveResourceByIDAndType( - handle, + groupVersionID, utils.LazyResourceTypeLLMProviderTemplate, correlationID, ) } -func (l *EventListener) buildLLMTemplateLazyResource(template api.LLMProviderTemplate) (*storage.LazyResource, error) { - if template.Metadata.Name == "" { - return nil, fmt.Errorf("template handle (metadata.name) is empty") +func (l *EventListener) buildLLMTemplateLazyResource(template *models.StoredLLMProviderTemplate) (*storage.LazyResource, error) { + groupVersionID := template.GetGroupID() + if groupVersionID == "" { + return nil, fmt.Errorf("template group_id (metadata.name) is empty") } - payload, err := json.Marshal(template) + payload, err := json.Marshal(template.Configuration) if err != nil { return nil, fmt.Errorf("failed to marshal template as JSON: %w", err) } @@ -172,7 +172,7 @@ func (l *EventListener) buildLLMTemplateLazyResource(template api.LLMProviderTem } return &storage.LazyResource{ - ID: template.Metadata.Name, + ID: groupVersionID, ResourceType: utils.LazyResourceTypeLLMProviderTemplate, Resource: resource, }, nil diff --git a/gateway/gateway-controller/pkg/eventlistener/llm_template_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/llm_template_processor_test.go index cd46145f0c..a97c3ae73a 100644 --- a/gateway/gateway-controller/pkg/eventlistener/llm_template_processor_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/llm_template_processor_test.go @@ -55,7 +55,7 @@ func TestHandleEvent_LLMTemplateCreate_LoadsTemplateFromDBAndPublishesLazyResour stored, err := store.GetTemplate(template.UUID) require.NoError(t, err) - assert.Equal(t, "openai", stored.GetHandle()) + assert.Equal(t, "openai", stored.GetGroupID()) resource, exists := lazyManager.GetResourceByIDAndType("openai", utils.LazyResourceTypeLLMProviderTemplate) require.True(t, exists) @@ -119,7 +119,7 @@ func TestHandleEvent_LLMTemplateDelete_RemovesLocalState(t *testing.T) { logger: newTestLogger(), } - resource, err := listener.buildLLMTemplateLazyResource(template.Configuration) + resource, err := listener.buildLLMTemplateLazyResource(template) require.NoError(t, err) require.NoError(t, lazyManager.StoreResource(resource, "")) diff --git a/gateway/gateway-controller/pkg/models/llm_provider_template.go b/gateway/gateway-controller/pkg/models/llm_provider_template.go index 9ead757304..0e05296467 100644 --- a/gateway/gateway-controller/pkg/models/llm_provider_template.go +++ b/gateway/gateway-controller/pkg/models/llm_provider_template.go @@ -19,11 +19,18 @@ package models import ( + "strings" "time" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" ) +// DefaultTemplateVersion is used when a template does not declare a version. +const DefaultTemplateVersion = "v1.0" + +// DefaultTemplateManagedBy is used when a template does not declare managedBy. +const DefaultTemplateManagedBy = "customer" + // StoredLLMProviderTemplate represents the LLM provider template stored in the database and in-memory type StoredLLMProviderTemplate struct { UUID string `json:"uuid"` @@ -32,7 +39,53 @@ type StoredLLMProviderTemplate struct { UpdatedAt time.Time `json:"updatedAt"` } -// GetHandle returns the template handle -func (t *StoredLLMProviderTemplate) GetHandle() string { +// GetGroupID returns the template's stable family-grouping identifier +// (the version-independent identifier shared by every version of this +// template, e.g. "openai"). Falls back to metadata.name when the explicit +// spec.groupId field is unset, for templates created before that +// field existed. +func (t *StoredLLMProviderTemplate) GetGroupID() string { + if t.Configuration.Spec.GroupId != nil { + if v := strings.TrimSpace(*t.Configuration.Spec.GroupId); v != "" { + return v + } + } return t.Configuration.Metadata.Name } + +func (t *StoredLLMProviderTemplate) GetHandle() string { + return strings.TrimSpace(t.Configuration.Metadata.Name) +} + +func (t *StoredLLMProviderTemplate) GetID() string { + return MakeTemplateID(t.GetGroupID(), t.GetVersion()) +} + +func MakeTemplateID(groupVersionID, version string) string { + h := strings.TrimSpace(groupVersionID) + v := strings.ToLower(strings.TrimSpace(version)) + if v == "" { + v = strings.ToLower(DefaultTemplateVersion) + } + return h + "-" + strings.ReplaceAll(v, ".", "-") +} + +// GetVersion returns the template content version, defaulting to v1.0 when unset. +func (t *StoredLLMProviderTemplate) GetVersion() string { + if t.Configuration.Spec.Version != nil { + if v := strings.TrimSpace(*t.Configuration.Spec.Version); v != "" { + return v + } + } + return DefaultTemplateVersion +} + +// GetManagedBy returns the template's managedBy origin, defaulting to "customer" when unset. +func (t *StoredLLMProviderTemplate) GetManagedBy() string { + if t.Configuration.Spec.ManagedBy != nil { + if p := strings.TrimSpace(*t.Configuration.Spec.ManagedBy); p != "" { + return p + } + } + return DefaultTemplateManagedBy +} diff --git a/gateway/gateway-controller/pkg/models/llm_provider_template_test.go b/gateway/gateway-controller/pkg/models/llm_provider_template_test.go index 6aada6cc69..fd1907fb15 100644 --- a/gateway/gateway-controller/pkg/models/llm_provider_template_test.go +++ b/gateway/gateway-controller/pkg/models/llm_provider_template_test.go @@ -74,7 +74,98 @@ func TestStoredLLMProviderTemplate_GetHandle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.template.GetHandle()) + assert.Equal(t, tt.expected, tt.template.GetGroupID()) + }) + } +} + +func strPtr(s string) *string { + return &s +} + +func TestStoredLLMProviderTemplate_GetVersion(t *testing.T) { + tests := []struct { + name string + version *string + expected string + }{ + {name: "explicit version", version: strPtr("v2.0"), expected: "v2.0"}, + {name: "version with surrounding whitespace", version: strPtr(" v3.0 "), expected: "v3.0"}, + {name: "nil version defaults to v1.0", version: nil, expected: DefaultTemplateVersion}, + {name: "blank version defaults to v1.0", version: strPtr(" "), expected: DefaultTemplateVersion}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + template := &StoredLLMProviderTemplate{ + Configuration: api.LLMProviderTemplate{ + Spec: api.LLMProviderTemplateData{Version: tt.version}, + }, + } + assert.Equal(t, tt.expected, template.GetVersion()) + }) + } +} + +func TestStoredLLMProviderTemplate_GetManagedBy(t *testing.T) { + tests := []struct { + name string + managedBy *string + expected string + }{ + {name: "explicit managedBy", managedBy: strPtr("wso2"), expected: "wso2"}, + {name: "managedBy with surrounding whitespace", managedBy: strPtr(" custom "), expected: "custom"}, + {name: "nil managedBy defaults to customer", managedBy: nil, expected: DefaultTemplateManagedBy}, + {name: "blank managedBy defaults to customer", managedBy: strPtr(" "), expected: DefaultTemplateManagedBy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + template := &StoredLLMProviderTemplate{ + Configuration: api.LLMProviderTemplate{ + Spec: api.LLMProviderTemplateData{ManagedBy: tt.managedBy}, + }, + } + assert.Equal(t, tt.expected, template.GetManagedBy()) + }) + } +} + +func TestStoredLLMProviderTemplate_GetID(t *testing.T) { + template := &StoredLLMProviderTemplate{ + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "mistralai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v2.0")}, + }, + } + assert.Equal(t, "mistralai-v2-0", template.GetID()) +} + +func TestStoredLLMProviderTemplate_GetID_DefaultsVersionWhenUnset(t *testing.T) { + template := &StoredLLMProviderTemplate{ + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + }, + } + assert.Equal(t, "openai-v1-0", template.GetID()) +} + +func TestMakeTemplateID(t *testing.T) { + tests := []struct { + name string + handle string + version string + expected string + }{ + {name: "standard handle and version", handle: "openai", version: "v1.0", expected: "openai-v1-0"}, + {name: "uppercase version is lowercased", handle: "openai", version: "V2.0", expected: "openai-v2-0"}, + {name: "blank version defaults to v1.0", handle: "openai", version: "", expected: "openai-v1-0"}, + {name: "handle with surrounding whitespace is trimmed", handle: " openai ", version: "v1.0", expected: "openai-v1-0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, MakeTemplateID(tt.handle, tt.version)) }) } } diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql index 9d2dae3e60..6bc65c580f 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql @@ -99,11 +99,15 @@ CREATE TABLE IF NOT EXISTS certificates ( CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid TEXT NOT NULL, gateway_id TEXT NOT NULL, + group_id TEXT NOT NULL, handle TEXT NOT NULL, + managed_by TEXT NOT NULL DEFAULT 'customer', + version TEXT NOT NULL DEFAULT 'v1.0', configuration TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, group_id, version), UNIQUE(gateway_id, handle) ); diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 2afd2a6194..d8cfbace22 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -121,9 +121,23 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( -- Gateway identifier gateway_id TEXT NOT NULL, - -- Template handle (must be unique within a gateway) + -- Stable family-grouping identifier; a group_id may have multiple + -- versions within a gateway + group_id TEXT NOT NULL, + + -- Verbatim handle from the template YAML (metadata.name). Built-in + -- templates use the unversioned family name (e.g. openai); custom + -- templates carry the version (e.g. mistralai-v2-0). Stored as-is. handle TEXT NOT NULL, + -- Template content version (e.g. v1.0); defaults to v1.0 when omitted + version TEXT NOT NULL DEFAULT 'v1.0', + + -- Origin of the template. Built-in templates use 'wso2'; custom templates + -- default to 'customer' and may carry any value. Persisted from the + -- template YAML's managedBy so it survives a round-trip through the DB. + managed_by TEXT NOT NULL DEFAULT 'customer', + -- Full template configuration as JSON configuration TEXT NOT NULL, @@ -131,8 +145,12 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - -- Template handles must be unique per gateway + -- Each (group_id, version) pair is unique per gateway; versions coexist. + -- The handle (metadata.name) is unique per gateway: each version carries a + -- distinct handle (e.g. openai, then openai-v2-0) and is the identifier a + -- user feeds to deploy the template. PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, group_id, version), UNIQUE(gateway_id, handle) ); @@ -307,4 +325,4 @@ CREATE TABLE IF NOT EXISTS webhook_secrets ( CREATE INDEX IF NOT EXISTS idx_webhook_secrets_artifact ON webhook_secrets(gateway_id, artifact_uuid); -PRAGMA user_version = 3; +PRAGMA user_version = 4; diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql index 4aecf99415..921f7df56a 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql @@ -119,11 +119,15 @@ IF OBJECT_ID(N'dbo.llm_provider_templates', N'U') IS NULL CREATE TABLE dbo.llm_provider_templates ( uuid NVARCHAR(64) NOT NULL, gateway_id NVARCHAR(64) NOT NULL, - handle NVARCHAR(255) NOT NULL, + group_id NVARCHAR(40) NOT NULL, + handle NVARCHAR(40) NOT NULL, + managed_by NVARCHAR(255) NOT NULL DEFAULT 'customer', + version NVARCHAR(64) NOT NULL DEFAULT 'v1.0', configuration NVARCHAR(MAX) NOT NULL, created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), PRIMARY KEY (gateway_id, uuid), + UNIQUE(gateway_id, group_id, version), UNIQUE(gateway_id, handle) ); diff --git a/gateway/gateway-controller/pkg/storage/loaders.go b/gateway/gateway-controller/pkg/storage/loaders.go index 4a057e2461..ecab6f3a7f 100644 --- a/gateway/gateway-controller/pkg/storage/loaders.go +++ b/gateway/gateway-controller/pkg/storage/loaders.go @@ -55,7 +55,7 @@ func LoadLLMProviderTemplatesFromDatabase(storage Storage, cache *ConfigStore) e for _, template := range templates { if err := cache.AddTemplate(template); err != nil { - return fmt.Errorf("failed to load llm provider template %s into cache: %w", template.GetHandle(), err) + return fmt.Errorf("failed to load llm provider template %s into cache: %w", template.GetGroupID(), err) } } diff --git a/gateway/gateway-controller/pkg/storage/memory.go b/gateway/gateway-controller/pkg/storage/memory.go index 92300e6d6b..9f49e80bcd 100644 --- a/gateway/gateway-controller/pkg/storage/memory.go +++ b/gateway/gateway-controller/pkg/storage/memory.go @@ -22,6 +22,7 @@ import ( "fmt" "strings" "sync" + "time" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" @@ -37,8 +38,15 @@ type ConfigStore struct { TopicManager *TopicManager // LLM Provider Templates - templates map[string]*models.StoredLLMProviderTemplate // Key: template ID - templateIdByHandle map[string]string + templates map[string]*models.StoredLLMProviderTemplate // Key: template ID + // templateIdByGroupID maps a handle to the UUID of its LATEST (most recently + // created) version, so handle-based lookups resolve to the newest template. + templateIdByGroupID map[string]string + // templateIdByGroupIDAndVersion maps a (handle, version) pair to a UUID, allowing + // multiple versions of the same handle to coexist. A typed composite key is + // used instead of a delimiter-joined string so distinct pairs can never + // collide on the same map key. + templateIdByGroupIDAndVersion map[templateVersionKey]string // API Keys storage apiKeysByAPI map[string]map[string]*models.APIKey // Key: configID → Value: map[keyID]*APIKey @@ -55,8 +63,9 @@ func NewConfigStore() *ConfigStore { handle: make(map[string]string), snapVersion: 0, TopicManager: NewTopicManager(), - templates: make(map[string]*models.StoredLLMProviderTemplate), - templateIdByHandle: make(map[string]string), + templates: make(map[string]*models.StoredLLMProviderTemplate), + templateIdByGroupID: make(map[string]string), + templateIdByGroupIDAndVersion: make(map[templateVersionKey]string), apiKeysByAPI: make(map[string]map[string]*models.APIKey), labelsByAPI: make(map[string]map[string]string), } @@ -345,14 +354,50 @@ func (cs *ConfigStore) SetSnapshotVersion(version int64) { // LLM Provider Template Methods // ======================================== -// AddTemplate adds a new LLM provider template. ID must be unique and immutable; name must be unique. +// templateVersionKey is a composite key type for indexing templates by (handle, version). +type templateVersionKey struct { + handle string + version string +} + +// groupVersionIDAndVersionKey builds the composite (handle, version) index key. +func groupVersionIDAndVersionKey(handle, version string) templateVersionKey { + return templateVersionKey{handle: handle, version: version} +} + +// recomputeLatestLocked re-evaluates which version of a handle is the latest +// (most recently created) and refreshes templateIdByGroupID accordingly. The +// caller must hold cs.mu. +func (cs *ConfigStore) recomputeLatestLocked(handle string) { + var latestID string + var latestAt time.Time + for id, t := range cs.templates { + if strings.TrimSpace(t.GetGroupID()) != handle { + continue + } + if latestID == "" || !t.CreatedAt.Before(latestAt) { + latestID = id + latestAt = t.CreatedAt + } + } + if latestID == "" { + delete(cs.templateIdByGroupID, handle) + return + } + cs.templateIdByGroupID[handle] = latestID +} + +// AddTemplate adds a new LLM provider template version. UUID must be unique and +// immutable; (handle, version) must be unique. Multiple versions of the same +// handle may coexist; the most recently created one resolves as the latest. func (cs *ConfigStore) AddTemplate(template *models.StoredLLMProviderTemplate) error { cs.mu.Lock() defer cs.mu.Unlock() // Normalize inputs uuid := strings.TrimSpace(template.UUID) - handle := strings.TrimSpace(template.GetHandle()) + handle := strings.TrimSpace(template.GetGroupID()) + version := template.GetVersion() if uuid == "" || handle == "" { return fmt.Errorf("template UUID and handle is required") @@ -363,25 +408,29 @@ func (cs *ConfigStore) AddTemplate(template *models.StoredLLMProviderTemplate) e return fmt.Errorf("template with uuid '%s' already exists", uuid) } - // Enforce unique handle: cannot add if handle already mapped to a different UUID - if _, exists := cs.templateIdByHandle[handle]; exists { - return fmt.Errorf("template with handle '%s' already exists", handle) + // Enforce unique (handle, version): a given version of a handle is immutable + hvKey := groupVersionIDAndVersionKey(handle, version) + if _, exists := cs.templateIdByGroupIDAndVersion[hvKey]; exists { + return fmt.Errorf("template with handle '%s' and version '%s' already exists", handle, version) } // Store cs.templates[uuid] = template - cs.templateIdByHandle[handle] = uuid + cs.templateIdByGroupIDAndVersion[hvKey] = uuid + cs.recomputeLatestLocked(handle) return nil } -// UpdateTemplate updates an existing LLM provider template's metadata. ID cannot change; only name can change. +// UpdateTemplate updates an existing LLM provider template version in place. The +// UUID is immutable; the handle and version may change as long as the resulting +// (handle, version) does not collide with a different template. func (cs *ConfigStore) UpdateTemplate(template *models.StoredLLMProviderTemplate) error { cs.mu.Lock() defer cs.mu.Unlock() // Normalize inputs uuid := strings.TrimSpace(template.UUID) - newHandle := strings.TrimSpace(template.GetHandle()) + newHandle := strings.TrimSpace(template.GetGroupID()) if uuid == "" || newHandle == "" { return fmt.Errorf("template uuid and handle is required") @@ -393,26 +442,29 @@ func (cs *ConfigStore) UpdateTemplate(template *models.StoredLLMProviderTemplate return fmt.Errorf("template with uuid '%s' not found", uuid) } - oldName := strings.TrimSpace(existing.GetHandle()) + oldHandle := strings.TrimSpace(existing.GetGroupID()) + oldKey := groupVersionIDAndVersionKey(oldHandle, existing.GetVersion()) + newKey := groupVersionIDAndVersionKey(newHandle, template.GetVersion()) - // If name is changing, ensure no collision with another template - if newHandle != oldName { - if mappedID, exists := cs.templateIdByHandle[newHandle]; exists && mappedID != uuid { - return fmt.Errorf("template with given handle '%s' already exists", newHandle) - } - // Remove old handle mapping if it points to this ID - if mappedID, ok := cs.templateIdByHandle[oldName]; ok && mappedID == uuid { - delete(cs.templateIdByHandle, oldName) + // Ensure the new (handle, version) does not collide with a different template + if newKey != oldKey { + if mappedID, exists := cs.templateIdByGroupIDAndVersion[newKey]; exists && mappedID != uuid { + return fmt.Errorf("template with given handle '%s' and version '%s' already exists", newHandle, template.GetVersion()) } + delete(cs.templateIdByGroupIDAndVersion, oldKey) } - // Update stored template and refresh name mapping + // Update stored template and refresh indexes cs.templates[uuid] = template - cs.templateIdByHandle[newHandle] = uuid + cs.templateIdByGroupIDAndVersion[newKey] = uuid + if oldHandle != newHandle { + cs.recomputeLatestLocked(oldHandle) + } + cs.recomputeLatestLocked(newHandle) return nil } -// DeleteTemplate removes an LLM provider template from the store by ID +// DeleteTemplate removes an LLM provider template version from the store by ID func (cs *ConfigStore) DeleteTemplate(id string) error { cs.mu.Lock() defer cs.mu.Unlock() @@ -422,8 +474,10 @@ func (cs *ConfigStore) DeleteTemplate(id string) error { return fmt.Errorf("template with ID '%s' not found", id) } + handle := template.GetGroupID() delete(cs.templates, id) - delete(cs.templateIdByHandle, template.GetHandle()) + delete(cs.templateIdByGroupIDAndVersion, groupVersionIDAndVersionKey(handle, template.GetVersion())) + cs.recomputeLatestLocked(handle) return nil } @@ -440,12 +494,12 @@ func (cs *ConfigStore) GetTemplate(id string) (*models.StoredLLMProviderTemplate return template, nil } -// GetTemplateByHandle retrieves an LLM provider template by handle identifier -func (cs *ConfigStore) GetTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { +// GetTemplateByGroupID retrieves an LLM provider template by handle identifier +func (cs *ConfigStore) GetTemplateByGroupID(handle string) (*models.StoredLLMProviderTemplate, error) { cs.mu.RLock() defer cs.mu.RUnlock() - templateId, exists := cs.templateIdByHandle[handle] + templateId, exists := cs.templateIdByGroupID[handle] if !exists { return nil, fmt.Errorf("template with handle '%s' not found", handle) } @@ -453,6 +507,38 @@ func (cs *ConfigStore) GetTemplateByHandle(handle string) (*models.StoredLLMProv return cs.templates[templateId], nil } +// GetTemplateByGroupIDAndVersion retrieves the specific (handle, version) template. +func (cs *ConfigStore) GetTemplateByGroupIDAndVersion(handle, version string) (*models.StoredLLMProviderTemplate, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + templateId, exists := cs.templateIdByGroupIDAndVersion[groupVersionIDAndVersionKey(strings.TrimSpace(handle), version)] + if !exists { + return nil, fmt.Errorf("template with handle '%s' and version '%s' not found", handle, version) + } + + return cs.templates[templateId], nil +} + +// GetTemplateByID retrieves an LLM provider template by ID, with fallback to +func (cs *ConfigStore) GetTemplateByID(id string) (*models.StoredLLMProviderTemplate, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + for _, template := range cs.templates { + if template.GetID() == id { + return template, nil + } + } + + // Fallback: treat the id as a base handle and resolve the latest version. + if templateId, exists := cs.templateIdByGroupID[id]; exists { + return cs.templates[templateId], nil + } + + return nil, fmt.Errorf("template with id '%s' not found", id) +} + // GetAllTemplates retrieves all LLM provider templates func (cs *ConfigStore) GetAllTemplates() []*models.StoredLLMProviderTemplate { cs.mu.RLock() diff --git a/gateway/gateway-controller/pkg/storage/memory_test.go b/gateway/gateway-controller/pkg/storage/memory_test.go index e664420671..58650a9931 100644 --- a/gateway/gateway-controller/pkg/storage/memory_test.go +++ b/gateway/gateway-controller/pkg/storage/memory_test.go @@ -36,7 +36,7 @@ func TestNewConfigStore(t *testing.T) { assert.NotNil(t, cs.nameVersion) assert.NotNil(t, cs.handle) assert.NotNil(t, cs.templates) - assert.NotNil(t, cs.templateIdByHandle) + assert.NotNil(t, cs.templateIdByGroupID) assert.NotNil(t, cs.apiKeysByAPI) assert.NotNil(t, cs.labelsByAPI) assert.NotNil(t, cs.TopicManager) @@ -85,10 +85,10 @@ func TestConfigStore_TemplateOperations(t *testing.T) { // Get by ID retrieved, err := cs.GetTemplate("0000-template-1-0000-000000000000") require.NoError(t, err) - assert.Equal(t, "openai-template", retrieved.GetHandle()) + assert.Equal(t, "openai-template", retrieved.GetGroupID()) // Get by handle - retrieved, err = cs.GetTemplateByHandle("openai-template") + retrieved, err = cs.GetTemplateByGroupID("openai-template") require.NoError(t, err) assert.Equal(t, "0000-template-1-0000-000000000000", retrieved.UUID) @@ -111,12 +111,12 @@ func TestConfigStore_TemplateOperations(t *testing.T) { require.NoError(t, err) // Verify update - retrieved, err = cs.GetTemplateByHandle("updated-template") + retrieved, err = cs.GetTemplateByGroupID("updated-template") require.NoError(t, err) assert.Equal(t, "0000-template-1-0000-000000000000", retrieved.UUID) // Old handle should not work - _, err = cs.GetTemplateByHandle("openai-template") + _, err = cs.GetTemplateByGroupID("openai-template") assert.Error(t, err) // Delete template @@ -207,10 +207,120 @@ func TestConfigStore_TemplateErrors(t *testing.T) { _, err = cs.GetTemplate("0000-non-existent-0000-000000000000") assert.Error(t, err) - _, err = cs.GetTemplateByHandle("0000-non-existent-0000-000000000000") + _, err = cs.GetTemplateByGroupID("0000-non-existent-0000-000000000000") assert.Error(t, err) } +// TestConfigStore_TemplateMultiVersion verifies that multiple versions of the same +// handle can coexist, that the most recently added one resolves as "latest" via +// GetTemplateByGroupID, and that GetTemplateByGroupIDAndVersion/GetTemplateByID can +// still reach a specific, non-latest version. +func TestConfigStore_TemplateMultiVersion(t *testing.T) { + cs := NewConfigStore() + + v1 := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-v1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "mistralai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v1.0")}, + }, + CreatedAt: time.Now(), + } + require.NoError(t, cs.AddTemplate(v1)) + + v2 := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-v2-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "mistralai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v2.0")}, + }, + CreatedAt: time.Now().Add(time.Minute), + } + require.NoError(t, cs.AddTemplate(v2)) + + // GetTemplateByGroupID resolves to the most recently created version (v2.0). + latest, err := cs.GetTemplateByGroupID("mistralai") + require.NoError(t, err) + assert.Equal(t, "0000-template-v2-0000-000000000000", latest.UUID) + + // GetTemplateByGroupIDAndVersion reaches the specific, non-latest version (v1.0). + v1Retrieved, err := cs.GetTemplateByGroupIDAndVersion("mistralai", "v1.0") + require.NoError(t, err) + assert.Equal(t, "0000-template-v1-0000-000000000000", v1Retrieved.UUID) + + v2Retrieved, err := cs.GetTemplateByGroupIDAndVersion("mistralai", "v2.0") + require.NoError(t, err) + assert.Equal(t, "0000-template-v2-0000-000000000000", v2Retrieved.UUID) + + _, err = cs.GetTemplateByGroupIDAndVersion("mistralai", "v9.0") + assert.Error(t, err) + + // GetTemplateByID resolves the version-specific id for each version... + byID, err := cs.GetTemplateByID("mistralai-v1-0") + require.NoError(t, err) + assert.Equal(t, "0000-template-v1-0000-000000000000", byID.UUID) + + byID, err = cs.GetTemplateByID("mistralai-v2-0") + require.NoError(t, err) + assert.Equal(t, "0000-template-v2-0000-000000000000", byID.UUID) + + // ...and falls back to resolving the latest version when given the bare handle. + byID, err = cs.GetTemplateByID("mistralai") + require.NoError(t, err) + assert.Equal(t, "0000-template-v2-0000-000000000000", byID.UUID) + + _, err = cs.GetTemplateByID("does-not-exist") + assert.Error(t, err) + + // Deleting the latest version demotes the remaining version back to latest. + require.NoError(t, cs.DeleteTemplate("0000-template-v2-0000-000000000000")) + latest, err = cs.GetTemplateByGroupID("mistralai") + require.NoError(t, err) + assert.Equal(t, "0000-template-v1-0000-000000000000", latest.UUID) +} + +// TestConfigStore_AddTemplate_DuplicateHandleVersionRejected verifies that adding a +// second template with the same (handle, version) pair is rejected, while a +// different version of the same handle is accepted (multi-version support). +func TestConfigStore_AddTemplate_DuplicateHandleVersionRejected(t *testing.T) { + cs := NewConfigStore() + + v1 := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-a-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v1.0")}, + }, + } + require.NoError(t, cs.AddTemplate(v1)) + + // Same handle and version, different UUID -> rejected. + dup := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-b-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v1.0")}, + }, + } + err := cs.AddTemplate(dup) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // Same handle, different version -> accepted. + newVersion := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-c-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{Version: strPtr("v2.0")}, + }, + } + assert.NoError(t, cs.AddTemplate(newVersion)) +} + +func strPtr(s string) *string { + return &s +} + func TestConfigStore_APIKeyOperations(t *testing.T) { cs := NewConfigStore() diff --git a/gateway/gateway-controller/pkg/storage/postgres_test.go b/gateway/gateway-controller/pkg/storage/postgres_test.go index c6e2b050e6..c668a195af 100644 --- a/gateway/gateway-controller/pkg/storage/postgres_test.go +++ b/gateway/gateway-controller/pkg/storage/postgres_test.go @@ -90,7 +90,7 @@ func TestPostgresStorage_TemplateAndAPIKeyCRUD(t *testing.T) { loadedTemplate, err := pg.GetLLMProviderTemplate(tmpl.UUID) assert.NilError(t, err) assert.Equal(t, loadedTemplate.UUID, tmpl.UUID) - assert.Equal(t, loadedTemplate.GetHandle(), tmpl.GetHandle()) + assert.Equal(t, loadedTemplate.GetGroupID(), tmpl.GetGroupID()) cfg := createTestStoredConfig() assert.NilError(t, pg.SaveConfig(cfg)) diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index b74e28cddc..10a063f604 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -1507,19 +1507,25 @@ func (s *sqlStore) SaveLLMProviderTemplate(template *models.StoredLLMProviderTem return fmt.Errorf("failed to marshal template configuration: %w", err) } + groupVersionID := template.GetGroupID() handle := template.GetHandle() + version := template.GetVersion() + managedBy := template.GetManagedBy() query := ` INSERT INTO llm_provider_templates ( - uuid, gateway_id, handle, configuration, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?) + uuid, gateway_id, group_id, handle, managed_by, version, configuration, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` now := time.Now() _, err = s.exec(query, template.UUID, s.gatewayId, + groupVersionID, handle, + managedBy, + version, string(configJSON), now, now, @@ -1528,14 +1534,14 @@ func (s *sqlStore) SaveLLMProviderTemplate(template *models.StoredLLMProviderTem if err != nil { // Check for unique constraint violation if s.isUniqueViolation(err) { - return fmt.Errorf("%w: template with handle '%s' already exists", ErrConflict, handle) + return fmt.Errorf("%w: template conflicts with an existing row on (group_id '%s', version '%s') or handle '%s'", ErrConflict, groupVersionID, version, handle) } return fmt.Errorf("failed to insert template: %w", err) } s.logger.Info("LLM provider template saved", slog.String("uuid", template.UUID), - slog.String("handle", handle)) + slog.String("group_id", groupVersionID)) return nil } @@ -1557,16 +1563,22 @@ func (s *sqlStore) UpdateLLMProviderTemplate(template *models.StoredLLMProviderT return fmt.Errorf("failed to marshal template configuration: %w", err) } + groupVersionID := template.GetGroupID() handle := template.GetHandle() + managedBy := template.GetManagedBy() + version := template.GetVersion() query := ` UPDATE llm_provider_templates - SET handle = ?, configuration = ?, updated_at = ? + SET group_id = ?, handle = ?, managed_by = ?, version = ?, configuration = ?, updated_at = ? WHERE uuid = ? AND gateway_id = ? ` result, err := s.exec(query, + groupVersionID, handle, + managedBy, + version, string(configJSON), time.Now(), template.UUID, @@ -1575,7 +1587,7 @@ func (s *sqlStore) UpdateLLMProviderTemplate(template *models.StoredLLMProviderT if err != nil { if s.isUniqueViolation(err) { - return fmt.Errorf("%w: template with handle '%s' already exists", ErrConflict, handle) + return fmt.Errorf("%w: template conflicts with an existing row on (group_id '%s', version '%s') or handle '%s'", ErrConflict, groupVersionID, version, handle) } return fmt.Errorf("failed to update template: %w", err) } @@ -1591,7 +1603,7 @@ func (s *sqlStore) UpdateLLMProviderTemplate(template *models.StoredLLMProviderT s.logger.Info("LLM provider template updated", slog.String("uuid", template.UUID), - slog.String("handle", handle)) + slog.String("group_id", groupVersionID)) return nil } @@ -1699,18 +1711,24 @@ func (s *sqlStore) GetAllLLMProviderTemplates() ([]*models.StoredLLMProviderTemp return templates, nil } -// GetLLMProviderTemplateByHandle retrieves an LLM provider template by handle. -func (s *sqlStore) GetLLMProviderTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { +// GetLLMProviderTemplateByHandle retrieves the latest (most recently created) +// version of an LLM provider template by group_id. +func (s *sqlStore) GetLLMProviderTemplateByHandle(groupVersionID string) (*models.StoredLLMProviderTemplate, error) { + limitClause := "LIMIT 1" + if s.backendName == "sqlserver" { + limitClause = "OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY" + } query := ` SELECT uuid, configuration, created_at, updated_at FROM llm_provider_templates - WHERE gateway_id = ? AND handle = ? - ` + WHERE gateway_id = ? AND group_id = ? + ORDER BY created_at DESC + ` + limitClause var template models.StoredLLMProviderTemplate var configJSON string - err := s.queryRow(query, s.gatewayId, handle).Scan( + err := s.queryRow(query, s.gatewayId, groupVersionID).Scan( &template.UUID, &configJSON, &template.CreatedAt, @@ -1718,7 +1736,7 @@ func (s *sqlStore) GetLLMProviderTemplateByHandle(handle string) (*models.Stored ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("%w: handle=%s", ErrNotFound, handle) + return nil, fmt.Errorf("%w: group_id=%s", ErrNotFound, groupVersionID) } return nil, fmt.Errorf("failed to query template: %w", err) } diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 96f8dc2e39..839425bd15 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -73,7 +73,7 @@ func newSQLiteStorage(dbPath string, logger *slog.Logger) (*SQLiteStorage, error return storage, nil } -const currentSchemaVersion = 3 +const currentSchemaVersion = 4 // initSchema creates the database schema if it doesn't exist func (s *SQLiteStorage) initSchema() error { diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index bb44a7108b..578c2ba4dc 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -78,7 +78,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { var version int err = storage.db.QueryRow("PRAGMA user_version").Scan(&version) assert.NilError(t, err) - assert.Equal(t, version, 3) // Current schema version + assert.Equal(t, version, 4) // Current schema version // Verify tables exist tables := []string{ @@ -128,7 +128,7 @@ func TestSQLiteStorage_RejectsUnsupportedSchemaVersion(t *testing.T) { // Reopen — should fail with unsupported version error _, err = NewStorage(BackendConfig{Type: "sqlite", SQLitePath: dbPath}, logger) assert.Assert(t, err != nil) - assert.ErrorContains(t, err, "failed to initialize schema: unsupported schema version 5, expected 3; delete the database to recreate") + assert.ErrorContains(t, err, "failed to initialize schema: unsupported schema version 5, expected 4; delete the database to recreate") } func TestSQLiteStorage_DeleteConfig_NotFound(t *testing.T) { @@ -475,7 +475,7 @@ func TestLoadLLMProviderTemplatesFromDatabase_Success(t *testing.T) { // Verify template is loaded loadedTemplate, err := cache.GetTemplate(template.UUID) assert.NilError(t, err) - assert.Equal(t, loadedTemplate.GetHandle(), template.GetHandle()) + assert.Equal(t, loadedTemplate.GetGroupID(), template.GetGroupID()) } func TestLoadLLMProviderTemplatesFromDatabase_GetAllError(t *testing.T) { @@ -532,7 +532,7 @@ func TestSQLiteStorage_GetLLMProviderTemplate_Success(t *testing.T) { retrieved, err := storage.GetLLMProviderTemplate(template.UUID) assert.NilError(t, err) assert.Equal(t, retrieved.UUID, template.UUID) - assert.Equal(t, retrieved.GetHandle(), template.GetHandle()) + assert.Equal(t, retrieved.GetGroupID(), template.GetGroupID()) } func TestSQLiteStorage_GetLLMProviderTemplate_JSONUnmarshalError(t *testing.T) { @@ -541,9 +541,9 @@ func TestSQLiteStorage_GetLLMProviderTemplate_JSONUnmarshalError(t *testing.T) { // Insert template with invalid JSON _, err := storage.db.Exec(` - INSERT INTO llm_provider_templates (uuid, gateway_id, handle, configuration, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?)`, - "invalid-template", "platform-gateway-id", "0000-test-handle-0000-000000000000", "invalid-json", time.Now(), time.Now()) + INSERT INTO llm_provider_templates (uuid, gateway_id, group_id, handle, configuration, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "invalid-template", "platform-gateway-id", "0000-test-handle-0000-000000000000", "invalid-handle", "invalid-json", time.Now(), time.Now()) assert.NilError(t, err) _, err = storage.GetLLMProviderTemplate("invalid-template") @@ -588,9 +588,9 @@ func TestSQLiteStorage_GetAllLLMProviderTemplates_JSONError(t *testing.T) { // Insert template with invalid JSON _, err := storage.db.Exec(` - INSERT INTO llm_provider_templates (uuid, gateway_id, handle, configuration, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?)`, - "invalid-template", "platform-gateway-id", "0000-test-handle-0000-000000000000", "invalid-json", time.Now(), time.Now()) + INSERT INTO llm_provider_templates (uuid, gateway_id, group_id, handle, configuration, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "invalid-template", "platform-gateway-id", "0000-test-handle-0000-000000000000", "invalid-handle", "invalid-json", time.Now(), time.Now()) assert.NilError(t, err) _, err = storage.GetAllLLMProviderTemplates() @@ -605,7 +605,7 @@ func TestSQLiteStorage_GetLLMProviderTemplateByHandle(t *testing.T) { err := storage.SaveLLMProviderTemplate(template) assert.NilError(t, err) - found, err := storage.GetLLMProviderTemplateByHandle(template.GetHandle()) + found, err := storage.GetLLMProviderTemplateByHandle(template.GetGroupID()) assert.NilError(t, err) assert.Equal(t, found.UUID, template.UUID) diff --git a/gateway/gateway-controller/pkg/storage/sqlserver_test.go b/gateway/gateway-controller/pkg/storage/sqlserver_test.go index 9eed91520e..f99c33e0bb 100644 --- a/gateway/gateway-controller/pkg/storage/sqlserver_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlserver_test.go @@ -93,7 +93,7 @@ func TestSQLServerStorage_TemplateAndAPIKeyCRUD(t *testing.T) { loadedTemplate, err := ms.GetLLMProviderTemplate(tmpl.UUID) assert.NilError(t, err) assert.Equal(t, loadedTemplate.UUID, tmpl.UUID) - assert.Equal(t, loadedTemplate.GetHandle(), tmpl.GetHandle()) + assert.Equal(t, loadedTemplate.GetGroupID(), tmpl.GetGroupID()) cfg := createTestStoredConfig() assert.NilError(t, ms.SaveConfig(cfg)) diff --git a/gateway/gateway-controller/pkg/utils/llm_deployment.go b/gateway/gateway-controller/pkg/utils/llm_deployment.go index 238d65d5b6..0f0fae6818 100644 --- a/gateway/gateway-controller/pkg/utils/llm_deployment.go +++ b/gateway/gateway-controller/pkg/utils/llm_deployment.go @@ -131,14 +131,18 @@ func (s *LLMDeploymentService) publishLLMProxyEvent(action, entityID, correlatio s.deploymentService.publishEvent(eventhub.EventTypeLLMProxy, action, entityID, correlationID, logger) } -func (s *LLMDeploymentService) validateTemplateHandleConflict(handle string) error { - existing, err := s.db.GetLLMProviderTemplateByHandle(handle) - if err == nil && existing != nil { - return fmt.Errorf("%w: template with handle '%s' already exists", storage.ErrConflict, handle) - } - if err != nil && !storage.IsNotFoundError(err) { +// validateTemplateConflict rejects only an exact (handle, version) duplicate. +// Different versions of the same handle are allowed to coexist. +func (s *LLMDeploymentService) validateTemplateConflict(handle, version string) error { + templates, err := s.db.GetAllLLMProviderTemplates() + if err != nil { return err } + for _, t := range templates { + if t.GetGroupID() == handle && t.GetVersion() == version { + return fmt.Errorf("%w: template with handle '%s' and version '%s' already exists", storage.ErrConflict, handle, version) + } + } return nil } @@ -606,6 +610,17 @@ func (s *LLMDeploymentService) parseAndValidateLLMTemplate(params LLMTemplatePar } return nil, fmt.Errorf("%w: %d error(s): %s", ErrLLMTemplateValidation, len(validationErrors), strings.Join(errs, "; ")) } + + // Normalize version/managedBy so the persisted config and API responses are + // explicit even when the spec omitted them. + if tmpl.Spec.Version == nil || strings.TrimSpace(*tmpl.Spec.Version) == "" { + v := models.DefaultTemplateVersion + tmpl.Spec.Version = &v + } + if tmpl.Spec.ManagedBy == nil || strings.TrimSpace(*tmpl.Spec.ManagedBy) == "" { + p := models.DefaultTemplateManagedBy + tmpl.Spec.ManagedBy = &p + } return &tmpl, nil } @@ -621,10 +636,6 @@ func (s *LLMDeploymentService) CreateLLMProviderTemplate(params LLMTemplateParam return nil, fmt.Errorf("failed to generate template ID: %w", err) } - if err := s.validateTemplateHandleConflict(tmpl.Metadata.Name); err != nil { - return nil, err - } - stored := &models.StoredLLMProviderTemplate{ UUID: id, Configuration: *tmpl, @@ -632,6 +643,10 @@ func (s *LLMDeploymentService) CreateLLMProviderTemplate(params LLMTemplateParam UpdatedAt: time.Now(), } + if err := s.validateTemplateConflict(stored.GetGroupID(), stored.GetVersion()); err != nil { + return nil, err + } + // Persist to DB if err := s.db.SaveLLMProviderTemplate(stored); err != nil { if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { @@ -651,7 +666,8 @@ func (s *LLMDeploymentService) InitializeOOBTemplates(templateDefinitions map[st } var allErrors []string - processedHandles := make(map[string]bool) // Track which templates were processed from files + // Track which templates were processed from files + processedTemplates := make(map[string]bool) for _, tmpl := range templateDefinitions { // Render template expressions into a separate copy so the validator sees resolved values. @@ -686,8 +702,18 @@ func (s *LLMDeploymentService) InitializeOOBTemplates(templateDefinitions map[st continue } - // Check if template already exists - existing, err := s.store.GetTemplateByHandle(tmpl.Metadata.Name) + tmplVersion := models.DefaultTemplateVersion + if tmpl.Spec.Version != nil && strings.TrimSpace(*tmpl.Spec.Version) != "" { + tmplVersion = strings.TrimSpace(*tmpl.Spec.Version) + } + groupVersionID := tmpl.Metadata.Name + if tmpl.Spec.GroupId != nil && strings.TrimSpace(*tmpl.Spec.GroupId) != "" { + groupVersionID = strings.TrimSpace(*tmpl.Spec.GroupId) + } + templateID := models.MakeTemplateID(groupVersionID, tmplVersion) + + // Check if this specific (group_id, version) already exists + existing, err := s.store.GetTemplateByGroupIDAndVersion(groupVersionID, tmplVersion) if err == nil && existing != nil { // --------------------------- // UPDATE existing template @@ -722,7 +748,7 @@ func (s *LLMDeploymentService) InitializeOOBTemplates(templateDefinitions map[st continue } - processedHandles[tmpl.Metadata.Name] = true + processedTemplates[templateID] = true continue } @@ -771,14 +797,14 @@ func (s *LLMDeploymentService) InitializeOOBTemplates(templateDefinitions map[st continue } - processedHandles[tmpl.Metadata.Name] = true + processedTemplates[templateID] = true } // Publish all templates from store that weren't processed from files (DB-only templates) allTemplates := s.store.GetAllTemplates() for _, stored := range allTemplates { - handle := stored.GetHandle() - if !processedHandles[handle] { + handle := stored.GetGroupID() + if !processedTemplates[stored.GetID()] { // Render the stored (unresolved) configuration before publishing so the // policy engine receives actual resolved values, not raw template expressions. renderHolder := &models.StoredConfig{Configuration: stored.Configuration} @@ -824,10 +850,11 @@ func (s *LLMDeploymentService) UpdateLLMProviderTemplate(handle string, params L return nil, err } - // Validate that handle doesn't change unexpectedly - // If the new template has a different handle, that's a different template - oldHandle := existing.GetHandle() + oldHandle := existing.GetGroupID() newHandle := tmpl.Metadata.Name + if tmpl.Spec.GroupId != nil && strings.TrimSpace(*tmpl.Spec.GroupId) != "" { + newHandle = strings.TrimSpace(*tmpl.Spec.GroupId) + } if oldHandle != newHandle { return nil, fmt.Errorf("%w: cannot change template handle from '%s' to '%s'; use create/delete instead", ErrLLMTemplateValidation, oldHandle, newHandle) } @@ -899,7 +926,36 @@ func (s *LLMDeploymentService) GetLLMProviderTemplateByHandle(handle string) (*m return nil, err } - return s.store.GetTemplateByHandle(handle) + return s.store.GetTemplateByGroupID(handle) +} + +func (s *LLMDeploymentService) GetLLMProviderTemplateByID(id string) (*models.StoredLLMProviderTemplate, error) { + t, err := s.db.GetLLMProviderTemplate(id) + if err == nil { + return t, nil + } + if !storage.IsNotFoundError(err) && !storage.IsDatabaseUnavailableError(err) { + return nil, err + } + if storage.IsDatabaseUnavailableError(err) { + if t, err := s.store.GetTemplate(id); err == nil { + return t, nil + } + } + + if byHandle, handleErr := s.GetLLMProviderTemplateByHandle(id); handleErr == nil { + return byHandle, nil + } + + if templates, allErr := s.db.GetAllLLMProviderTemplates(); allErr == nil { + for _, candidate := range templates { + if candidate.GetID() == id { + return candidate, nil + } + } + } + + return nil, fmt.Errorf("%w: id=%s", storage.ErrNotFound, id) } func (s *LLMDeploymentService) publishTemplateAsLazyResource(tmpl *api.LLMProviderTemplate, correlationID string) error { @@ -909,8 +965,12 @@ func (s *LLMDeploymentService) publishTemplateAsLazyResource(tmpl *api.LLMProvid if tmpl == nil { return fmt.Errorf("template is nil") } - if tmpl.Metadata.Name == "" { - return fmt.Errorf("template handle (metadata.name) is empty") + groupVersionID := tmpl.Metadata.Name + if tmpl.Spec.GroupId != nil && strings.TrimSpace(*tmpl.Spec.GroupId) != "" { + groupVersionID = strings.TrimSpace(*tmpl.Spec.GroupId) + } + if groupVersionID == "" { + return fmt.Errorf("template group_id (metadata.name) is empty") } // Convert typed template to map[string]interface{} for the generic lazy resource payload. @@ -924,7 +984,7 @@ func (s *LLMDeploymentService) publishTemplateAsLazyResource(tmpl *api.LLMProvid } return s.lazyResourceManager.StoreResource(&storage.LazyResource{ - ID: tmpl.Metadata.Name, + ID: groupVersionID, ResourceType: LazyResourceTypeLLMProviderTemplate, Resource: m, }, correlationID) diff --git a/gateway/gateway-controller/pkg/utils/llm_deployment_test.go b/gateway/gateway-controller/pkg/utils/llm_deployment_test.go index 2033e61728..591d87896a 100644 --- a/gateway/gateway-controller/pkg/utils/llm_deployment_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_deployment_test.go @@ -650,7 +650,75 @@ func TestLLMDeploymentService_CreateLLMProviderTemplate_HandleConflict(t *testin require.Error(t, err) assert.ErrorIs(t, err, storage.ErrConflict) - assert.Contains(t, err.Error(), "template with handle 'openai' already exists") + assert.Contains(t, err.Error(), "template with handle 'openai' and version 'v1.0' already exists") +} + +// templateYAMLWithManagedByVersion builds a template manifest carrying explicit +// groupId, managedBy and version fields (as produced by the AI workspace +// download). Each version is pushed with a distinct handle (metadata.name) but +// shares the same groupId, so the (gateway_id, handle) uniqueness holds while +// versions of one family coexist. +func templateYAMLWithManagedByVersion(handle, groupID, displayName, managedBy, version string) []byte { + return []byte(fmt.Sprintf(` +apiVersion: gateway.api-platform.wso2.com/v1 +kind: LlmProviderTemplate +metadata: + name: %s +spec: + displayName: %s + groupId: %s + managedBy: %s + version: %s +`, handle, displayName, groupID, managedBy, version)) +} + +func TestLLMDeploymentService_CreateLLMProviderTemplate_VersionWise(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + store := storage.NewConfigStore() + db := newTestSQLiteStorage(t, logger) + routerConfig := &config.RouterConfig{ListenerPort: 8080} + apiDeploymentService := newTestAPIDeploymentService(store, db, nil, nil, nil) + service := NewLLMDeploymentService(store, db, nil, nil, nil, apiDeploymentService, routerConfig, nil, nil) + + // Deploy v1.0 of a custom template and confirm managedBy/version round-trip. + v1, err := service.CreateLLMProviderTemplate(LLMTemplateParams{ + Spec: templateYAMLWithManagedByVersion("deep-seek", "deep-seek", "deep seek", "other", "v1.0"), + ContentType: "application/yaml", + Logger: logger, + }) + require.NoError(t, err) + require.NotNil(t, v1.Configuration.Spec.ManagedBy) + assert.Equal(t, "other", *v1.Configuration.Spec.ManagedBy) + require.NotNil(t, v1.Configuration.Spec.Version) + assert.Equal(t, "v1.0", *v1.Configuration.Spec.Version) + + // Deploy v2.0 of the same family (same groupId) with its own distinct handle + // (deep-seek-v2-0) — versions coexist, no conflict. + v2, err := service.CreateLLMProviderTemplate(LLMTemplateParams{ + Spec: templateYAMLWithManagedByVersion("deep-seek-v2-0", "deep-seek", "deep seek", "deepseek", "v2.0"), + ContentType: "application/yaml", + Logger: logger, + }) + require.NoError(t, err) + assert.NotEqual(t, v1.UUID, v2.UUID) + + // Group-based lookup resolves to the latest (most recently created) version, + // and managedBy/version are persisted (read back from the DB, not in-memory). + latest, err := db.GetLLMProviderTemplateByHandle("deep-seek") + require.NoError(t, err) + assert.Equal(t, v2.UUID, latest.UUID) + assert.Equal(t, "v2.0", latest.GetVersion()) + require.NotNil(t, latest.Configuration.Spec.ManagedBy) + assert.Equal(t, "deepseek", *latest.Configuration.Spec.ManagedBy) + + // Re-deploying an existing (handle, version) is rejected. + _, err = service.CreateLLMProviderTemplate(LLMTemplateParams{ + Spec: templateYAMLWithManagedByVersion("deep-seek-v2-0", "deep-seek", "deep seek", "deepseek", "v2.0"), + ContentType: "application/yaml", + Logger: logger, + }) + require.Error(t, err) + assert.ErrorIs(t, err, storage.ErrConflict) } func TestLLMDeploymentService_UpdateLLMProviderTemplate_WithDBAndEventHubPublishesUpdate(t *testing.T) { @@ -1309,7 +1377,7 @@ func TestLLMDeploymentService_InitializeOOBTemplates_ValidTemplates(t *testing.T assert.NoError(t, err) // Verify template was added - found, err := store.GetTemplateByHandle("openai") + found, err := store.GetTemplateByGroupID("openai") assert.NoError(t, err) assert.NotNil(t, found) } @@ -1350,7 +1418,7 @@ func TestLLMDeploymentService_InitializeOOBTemplates_UpdateExisting(t *testing.T assert.NoError(t, err) // Verify template was updated - found, err := store.GetTemplateByHandle("existing") + found, err := store.GetTemplateByGroupID("existing") assert.NoError(t, err) assert.Equal(t, "Updated Template", found.Configuration.Spec.DisplayName) } diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_template_loader.go b/gateway/gateway-controller/pkg/utils/llm_provider_template_loader.go index df45337847..8bf292b5df 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_template_loader.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_template_loader.go @@ -27,6 +27,7 @@ import ( "strings" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "gopkg.in/yaml.v3" ) @@ -80,15 +81,29 @@ func (tl *LLMTemplateLoader) LoadTemplatesFromDirectory(dirPath string) (map[str return err } - // Check for duplicates - templateHandle := template.Metadata.Name - if _, exists := templates[templateHandle]; exists { - return fmt.Errorf("duplicate template handle: %s", templateHandle) + // Key by version-specific id so distinct versions of the same + // group_id can coexist as separate files instead of colliding + // on the group_id alone. + groupVersionID := template.Metadata.Name + if template.Spec.GroupId != nil { + if v := strings.TrimSpace(*template.Spec.GroupId); v != "" { + groupVersionID = v + } + } + templateVersion := "" + if template.Spec.Version != nil { + templateVersion = *template.Spec.Version + } + templateID := models.MakeTemplateID(groupVersionID, templateVersion) + if _, exists := templates[templateID]; exists { + return fmt.Errorf("duplicate template id (group_id+version): %s", templateID) } - templates[templateHandle] = template + templates[templateID] = template tl.logger.Info("Loaded LLM provider template", - slog.String("handle", templateHandle), + slog.String("id", templateID), + slog.String("group_id", groupVersionID), + slog.String("version", templateVersion), slog.String("apiVersion", string(template.ApiVersion)), slog.String("file", path)) @@ -141,7 +156,7 @@ func (tl *LLMTemplateLoader) loadTemplateFile(filePath string) (*api.LLMProvider tl.logger.Debug("Parsed template from YAML", slog.String("file", filePath), - slog.String("handle", templateConfig.Metadata.Name), + slog.String("name", templateConfig.Metadata.Name), slog.String("apiVersion", string(templateConfig.ApiVersion))) } diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_template_loader_test.go b/gateway/gateway-controller/pkg/utils/llm_provider_template_loader_test.go index 8df56d4745..53f39a0030 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_template_loader_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_template_loader_test.go @@ -104,7 +104,7 @@ func TestLLMTemplateLoader_LoadTemplatesFromDirectory_JSONFile(t *testing.T) { templates, err := loader.LoadTemplatesFromDirectory(tmpDir) assert.NoError(t, err) assert.Len(t, templates, 1) - assert.Contains(t, templates, "test-template") + assert.Contains(t, templates, "test-template-v1-0") } func TestLLMTemplateLoader_LoadTemplatesFromDirectory_YAMLFile(t *testing.T) { @@ -131,7 +131,7 @@ spec: templates, err := loader.LoadTemplatesFromDirectory(tmpDir) assert.NoError(t, err) assert.Len(t, templates, 1) - assert.Contains(t, templates, "yaml-template") + assert.Contains(t, templates, "yaml-template-v1-0") } func TestLLMTemplateLoader_LoadTemplatesFromDirectory_YMLFile(t *testing.T) { @@ -158,7 +158,7 @@ spec: templates, err := loader.LoadTemplatesFromDirectory(tmpDir) assert.NoError(t, err) assert.Len(t, templates, 1) - assert.Contains(t, templates, "yml-template") + assert.Contains(t, templates, "yml-template-v1-0") } func TestLLMTemplateLoader_LoadTemplatesFromDirectory_MultipleFiles(t *testing.T) { @@ -193,8 +193,8 @@ spec: templates, err := loader.LoadTemplatesFromDirectory(tmpDir) assert.NoError(t, err) assert.Len(t, templates, 2) - assert.Contains(t, templates, "template-1") - assert.Contains(t, templates, "template-2") + assert.Contains(t, templates, "template-1-v1-0") + assert.Contains(t, templates, "template-2-v1-0") } func TestLLMTemplateLoader_LoadTemplatesFromDirectory_DuplicateHandle(t *testing.T) { @@ -226,7 +226,39 @@ func TestLLMTemplateLoader_LoadTemplatesFromDirectory_DuplicateHandle(t *testing _, err = loader.LoadTemplatesFromDirectory(tmpDir) assert.Error(t, err) - assert.Contains(t, err.Error(), "duplicate template handle") + assert.Contains(t, err.Error(), "duplicate template id") +} + +func TestLLMTemplateLoader_LoadTemplatesFromDirectory_SameHandleDifferentVersions(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + loader := NewLLMTemplateLoader(logger) + + tmpDir, err := os.MkdirTemp("", "llm-templates-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + v1 := `{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "LlmProviderTemplate", + "metadata": {"name": "versioned-template"}, + "spec": {"displayName": "Versioned Template", "version": "v1.0"} + }` + v2 := `{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "LlmProviderTemplate", + "metadata": {"name": "versioned-template"}, + "spec": {"displayName": "Versioned Template", "version": "v2.0"} + }` + err = os.WriteFile(filepath.Join(tmpDir, "versioned-v1.json"), []byte(v1), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "versioned-v2.json"), []byte(v2), 0644) + require.NoError(t, err) + + templates, err := loader.LoadTemplatesFromDirectory(tmpDir) + assert.NoError(t, err) + assert.Len(t, templates, 2) + assert.Contains(t, templates, "versioned-template-v1-0") + assert.Contains(t, templates, "versioned-template-v2-0") } func TestLLMTemplateLoader_LoadTemplatesFromDirectory_InvalidJSON(t *testing.T) { @@ -297,8 +329,8 @@ func TestLLMTemplateLoader_LoadTemplatesFromDirectory_Subdirectories(t *testing. templates, err := loader.LoadTemplatesFromDirectory(tmpDir) assert.NoError(t, err) assert.Len(t, templates, 2) - assert.Contains(t, templates, "root-template") - assert.Contains(t, templates, "sub-template") + assert.Contains(t, templates, "root-template-v1-0") + assert.Contains(t, templates, "sub-template-v1-0") } func TestLLMTemplateLoader_loadTemplateFile_NonExistent(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go index f5ed2e5706..ef7631b126 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go @@ -279,6 +279,59 @@ func TestTransform_NonExistentTemplate(t *testing.T) { assert.Contains(t, err.Error(), "nonexistent-template") } +func TestTransform_ProviderReferencesVersionedTemplateId(t *testing.T) { + store := storage.NewConfigStore() + + customTemplate := &models.StoredLLMProviderTemplate{ + UUID: "0000-ecustom-v1-template-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProviderTemplate", + Metadata: api.Metadata{Name: "ecustom-v1-0"}, + Spec: api.LLMProviderTemplateData{ + DisplayName: "E2E Custom v1", + GroupId: stringPtr("ecustom"), + ManagedBy: stringPtr("customer"), + Version: stringPtr("v1.0"), + PromptTokens: &api.ExtractionIdentifier{ + Location: api.Payload, + Identifier: "$.usage.prompt_tokens", + }, + }, + }, + } + // Sanity: the versioned id differs from the group-version handle. + require.Equal(t, "ecustom-v1-0", customTemplate.GetID()) + require.Equal(t, "ecustom", customTemplate.GetGroupID()) + + require.NoError(t, store.AddTemplate(customTemplate)) + db := newTestMockDB() + require.NoError(t, db.SaveLLMProviderTemplate(customTemplate)) + cfg := loadDummyConfig() + transformer := NewLLMProviderTransformer(store, db, &cfg, newTestPolicyVersionResolver()) + + provider := &api.LLMProviderConfiguration{ + ApiVersion: "gateway.api-platform.wso2.com/v1", + Kind: "LlmProvider", + Metadata: api.Metadata{Name: "ecustom-provider-v1"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "E2E Custom Provider v1", + Version: "v1.0", + Template: "ecustom-v1-0", // versioned id, exactly as platform-api deploys it + Upstream: api.LLMProviderConfigData_Upstream{ + Url: stringPtr("https://api.example.com"), + }, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + }, + }, + } + + output := &api.RestAPI{} + _, err := transformer.Transform(provider, output) + require.NoError(t, err, "Transform should resolve a provider that references a template by its versioned id") +} + // ============================================================================ // Context Handling Tests // ============================================================================ diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer.go b/gateway/gateway-controller/pkg/utils/llm_transformer.go index 0146d2ad64..b9c1d69bd8 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "fmt" "sort" "strings" @@ -95,9 +96,27 @@ func (t *LLMProviderTransformer) resolvePolicyVersion(name string) (string, erro } func (t *LLMProviderTransformer) getTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { + if tmpl, err := t.getTemplateByVersionedID(handle); err == nil { + return tmpl, nil + } else if !errors.Is(err, storage.ErrNotFound) { + return nil, err + } return t.db.GetLLMProviderTemplateByHandle(handle) } +func (t *LLMProviderTransformer) getTemplateByVersionedID(id string) (*models.StoredLLMProviderTemplate, error) { + templates, err := t.db.GetAllLLMProviderTemplates() + if err != nil { + return nil, err + } + for _, tmpl := range templates { + if tmpl.GetID() == id { + return tmpl, nil + } + } + return nil, fmt.Errorf("%w: versioned template id %q", storage.ErrNotFound, id) +} + func (t *LLMProviderTransformer) transformProxy(proxy *api.LLMProxyConfiguration, output *api.RestAPI) (*api.RestAPI, error) { diff --git a/gateway/gateway-controller/pkg/utils/mock_db_test.go b/gateway/gateway-controller/pkg/utils/mock_db_test.go index dc80d2dec7..1e6d90d60d 100644 --- a/gateway/gateway-controller/pkg/utils/mock_db_test.go +++ b/gateway/gateway-controller/pkg/utils/mock_db_test.go @@ -140,7 +140,7 @@ func (m *testMockDB) GetAllLLMProviderTemplates() ([]*models.StoredLLMProviderTe func (m *testMockDB) GetLLMProviderTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { for _, t := range m.templates { - if t.GetHandle() == handle { + if t.GetGroupID() == handle { return t, nil } } diff --git a/gateway/gateway-controller/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 068744aa89..32e2ff1bb0 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -104,7 +104,7 @@ func TestSchemaInitialization(t *testing.T) { var version int err := rawDB.QueryRow("PRAGMA user_version").Scan(&version) assert.NoError(t, err) - assert.Equal(t, 3, version, "Schema version should be 3") + assert.Equal(t, 4, version, "Schema version should be 4") }) // Verify artifacts table exists diff --git a/gateway/it/LLM_PROVIDER_TEST_PLAN.md b/gateway/it/LLM_PROVIDER_TEST_PLAN.md index bdb09c37e6..e77b26ce66 100644 --- a/gateway/it/LLM_PROVIDER_TEST_PLAN.md +++ b/gateway/it/LLM_PROVIDER_TEST_PLAN.md @@ -119,7 +119,7 @@ This test plan provides comprehensive testing coverage for the LLM Provider Mana Based on the LLMProviderConfiguration schema, the following components are tested: ### Core Components -- ✅ **apiVersion**: `gateway.api-platform.wso2.com/v1alpha1` +- ✅ **apiVersion**: `gateway.api-platform.wso2.com/v1` - ✅ **kind**: `LlmProvider` - ✅ **metadata**: name (RFC-1123 compliant) - ✅ **spec**: All sub-components below diff --git a/gateway/it/README.md b/gateway/it/README.md index d7ba210768..74140078f0 100644 --- a/gateway/it/README.md +++ b/gateway/it/README.md @@ -133,7 +133,7 @@ Feature: API Deployment and Invocation Scenario: Deploy a simple HTTP API and invoke it successfully When I deploy this API configuration: """ - apiVersion: gateway.api-platform.wso2.com/v1alpha1 + apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: name: weather-api-v1.0 diff --git a/gateway/it/docker-compose.test.yaml b/gateway/it/docker-compose.test.yaml index 48d4f02306..68717929f0 100644 --- a/gateway/it/docker-compose.test.yaml +++ b/gateway/it/docker-compose.test.yaml @@ -31,7 +31,11 @@ services: context: ../../tests/mock-servers/mock-platform-api dockerfile: Dockerfile ports: - - "9243:9243" # HTTPS/WebSocket (gateway connects) + # Host publish of 9243 dropped: the controller reaches this mock over the + # compose network (it-mock-platform-api:9243) and the suite only uses the + # 9244 inject endpoint from the host, so publishing 9243 is unnecessary and + # collides with a locally-running platform-api on :9243. + # - "9243:9243" # HTTPS/WebSocket (gateway connects) - "9244:9244" # HTTP inject endpoint (IT triggers subscription.created) environment: - GATEWAY_DB_PATH=/app/data/gateway.db diff --git a/gateway/it/features/lazy-resources-xds.feature b/gateway/it/features/lazy-resources-xds.feature index 01f0f06b01..670a9fc6b0 100644 --- a/gateway/it/features/lazy-resources-xds.feature +++ b/gateway/it/features/lazy-resources-xds.feature @@ -59,7 +59,7 @@ Feature: Lazy Resources xDS Synchronization identifier: $.model """ Then the response status code should be 201 - And the JSON response field "status.id" should be "xds-test-template" + And the JSON response field "status.id" should be "xds-test-template-v1-0" # Wait for xDS propagation When I wait for 3 seconds @@ -412,7 +412,7 @@ Feature: Lazy Resources xDS Synchronization identifier: $.usage.completion_tokens """ Then the response status code should be 201 - And the JSON response field "status.id" should be "collision-test" + And the JSON response field "status.id" should be "collision-test-v1-0" # Wait for xDS propagation When I wait for 3 seconds diff --git a/gateway/it/features/llm-custom-template-invoke.feature b/gateway/it/features/llm-custom-template-invoke.feature new file mode 100644 index 0000000000..5975bc8342 --- /dev/null +++ b/gateway/it/features/llm-custom-template-invoke.feature @@ -0,0 +1,184 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +Feature: Custom LLM Provider Template deploy and invoke + As an API administrator + I want to deploy providers built from custom (managedBy != wso2) multi-version templates + So that the gateway resolves the exact template version a provider references + + # This mirrors the platform-api -> gateway contract: platform-api sets the + # deployed provider's spec.template to the template's *versioned id* + # (e.g. "ecustom-v1-0"), not the bare group-version handle ("ecustom"). + + Background: + Given the gateway services are running + + Scenario: Provider referencing a custom template by its versioned id resolves and invokes + # --- Custom template family "ecustom", version v1.0 (managedBy: customer) --- + Given I authenticate using basic auth as "admin" + When I create this LLM provider template: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProviderTemplate + metadata: + name: ecustom-v1-0 + spec: + displayName: E2E Custom v1 + groupId: ecustom + managedBy: customer + version: v1.0 + promptTokens: + location: payload + identifier: $.usage.prompt_tokens + completionTokens: + location: payload + identifier: $.usage.completion_tokens + totalTokens: + location: payload + identifier: $.usage.total_tokens + requestModel: + location: payload + identifier: $.model + responseModel: + location: payload + identifier: $.model + """ + Then the response status code should be 201 + And the JSON response field "status" should be "success" + And the JSON response field "status.id" should be "ecustom-v1-0" + + # --- Second version v2.0 in the same family --- + Given I authenticate using basic auth as "admin" + When I create this LLM provider template: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProviderTemplate + metadata: + name: ecustom-v2-0 + spec: + displayName: E2E Custom v2 + groupId: ecustom + managedBy: customer + version: v2.0 + promptTokens: + location: payload + identifier: $.usage.prompt_tokens + completionTokens: + location: payload + identifier: $.usage.completion_tokens + totalTokens: + location: payload + identifier: $.usage.total_tokens + requestModel: + location: payload + identifier: $.model + responseModel: + location: payload + identifier: $.model + """ + Then the response status code should be 201 + And the JSON response field "status.id" should be "ecustom-v2-0" + + # --- Provider P1 built from v1.0, referenced by versioned id --- + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: ecustom-provider-v1 + spec: + displayName: E2E Custom Provider v1 + version: v1.0 + context: /ecustom-v1 + template: ecustom-v1-0 + upstream: + url: http://mock-openapi:4010/openai/v1 + auth: + type: api-key + header: Authorization + value: Bearer sk-test-key + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + + # --- Provider P2 built from v2.0, referenced by versioned id --- + Given I authenticate using basic auth as "admin" + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: ecustom-provider-v2 + spec: + displayName: E2E Custom Provider v2 + version: v2.0 + context: /ecustom-v2 + template: ecustom-v2-0 + upstream: + url: http://mock-openapi:4010/openai/v1 + auth: + type: api-key + header: Authorization + value: Bearer sk-test-key + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + And I wait for 3 seconds + + # --- Invoke P1 (v1.0) through the gateway --- + When I set header "Content-Type" to "application/json" + And I send a POST request to "http://localhost:8080/ecustom-v1/chat/completions" with body: + """ + { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello from v1"}] + } + """ + Then the response status code should be 200 + And the response should be valid JSON + And the JSON response field "object" should be "chat.completion" + + # --- Invoke P2 (v2.0) through the gateway --- + When I set header "Content-Type" to "application/json" + And I send a POST request to "http://localhost:8080/ecustom-v2/chat/completions" with body: + """ + { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello from v2"}] + } + """ + Then the response status code should be 200 + And the response should be valid JSON + And the JSON response field "object" should be "chat.completion" + + # --- Cleanup --- + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "ecustom-provider-v1" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "ecustom-provider-v2" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the LLM provider template "ecustom-v2-0" + Then the response status code should be 200 + Given I authenticate using basic auth as "admin" + When I delete the LLM provider template "ecustom-v1-0" + Then the response status code should be 200 diff --git a/gateway/it/features/llm-provider-templates.feature b/gateway/it/features/llm-provider-templates.feature index 4500f92604..0c67b04e2a 100644 --- a/gateway/it/features/llm-provider-templates.feature +++ b/gateway/it/features/llm-provider-templates.feature @@ -60,7 +60,7 @@ Feature: LLM Provider Template Management Then the response status code should be 201 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response field "status.id" should be "openai-test" + And the JSON response field "status.id" should be "openai-test-v1-0" And the JSON response field "metadata.name" should be "openai-test" Given I authenticate using basic auth as "admin" @@ -68,7 +68,7 @@ Feature: LLM Provider Template Management Then the response status code should be 200 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response field "status.id" should be "openai-test" + And the JSON response field "status.id" should be "openai-test-v1-0" And the JSON response field "spec.displayName" should be "OpenAI" And the JSON response field "spec.promptTokens.location" should be "payload" And the JSON response field "spec.promptTokens.identifier" should be "$.usage.prompt_tokens" @@ -104,7 +104,7 @@ Feature: LLM Provider Template Management Then the response status code should be 200 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response field "status.id" should be "openai-test" + And the JSON response field "status.id" should be "openai-test-v1-0" And the JSON response field "metadata.name" should be "openai-test" Given I authenticate using basic auth as "admin" @@ -139,7 +139,7 @@ Feature: LLM Provider Template Management """ Then the response status code should be 201 And the response should be valid JSON - And the JSON response field "status.id" should be "minimal-template" + And the JSON response field "status.id" should be "minimal-template-v1-0" Given I authenticate using basic auth as "admin" When I retrieve the LLM provider template "minimal-template" diff --git a/gateway/it/steps_llm.go b/gateway/it/steps_llm.go index e161edffd5..618069236f 100644 --- a/gateway/it/steps_llm.go +++ b/gateway/it/steps_llm.go @@ -99,6 +99,9 @@ func RegisterLLMSteps(ctx *godog.ScenarioContext, state *TestState, httpSteps *s Metadata struct { Name string `json:"name"` } `json:"metadata"` + Spec struct { + GroupId string `json:"groupId"` + } `json:"spec"` } `json:"templates"` } @@ -127,11 +130,13 @@ func RegisterLLMSteps(ctx *godog.ScenarioContext, state *TestState, httpSteps *s ) } - // 3️⃣ Collect actual template IDs (k8s-shaped list uses metadata.name) + // 3️⃣ Collect actual template IDs. OOB templates now carry a versioned + // metadata.name (e.g. openai-v1-0); the bare family id lives in + // spec.groupId, which is what expectedIDs matches against. actualIDs := make(map[string]bool) for _, t := range response.Templates { - if t.Metadata.Name != "" { - actualIDs[t.Metadata.Name] = true + if t.Spec.GroupId != "" { + actualIDs[t.Spec.GroupId] = true } } diff --git a/platform-api/src/api/generated.go b/platform-api/src/api/generated.go index 49d7e2af23..4cd35cce70 100644 --- a/platform-api/src/api/generated.go +++ b/platform-api/src/api/generated.go @@ -515,6 +515,12 @@ const ( GetGatewayArtifactsParamsArtifactTypeRESTAPI GetGatewayArtifactsParamsArtifactType = "REST_API" ) +// Defines values for ListLLMProviderTemplatesParamsVersions. +const ( + All ListLLMProviderTemplatesParamsVersions = "all" + Latest ListLLMProviderTemplatesParamsVersions = "latest" +) + // Defines values for GetLLMProviderDeploymentsParamsStatus. const ( GetLLMProviderDeploymentsParamsStatusARCHIVED GetLLMProviderDeploymentsParamsStatus = "ARCHIVED" @@ -939,6 +945,35 @@ type CreateLLMProviderAPIKeyResponse struct { Status string `binding:"required" json:"status" yaml:"status"` } +// CreateLLMProviderTemplateVersionRequest defines model for CreateLLMProviderTemplateVersionRequest. +type CreateLLMProviderTemplateVersionRequest struct { + CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" yaml:"completionTokens,omitempty"` + + // Description Description of the LLM provider template + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // ManagedBy Identifies who manages the template. Custom templates default to 'customer'. + ManagedBy *string `json:"managedBy,omitempty" yaml:"managedBy,omitempty"` + Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + + // Name Human-readable LLM Template name + Name string `binding:"required" json:"name" yaml:"name"` + + // Openapi OpenAPI specification content (JSON or YAML) for the provider, when + // uploaded/pasted. Use metadata.openapiSpecUrl instead to reference the + // spec by URL. + Openapi *string `json:"openapi,omitempty" yaml:"openapi,omitempty"` + PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" yaml:"promptTokens,omitempty"` + RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" yaml:"remainingTokens,omitempty"` + RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" yaml:"requestModel,omitempty"` + ResourceMappings *LLMProviderTemplateResourceMappings `json:"resourceMappings,omitempty" yaml:"resourceMappings,omitempty"` + ResponseModel *ExtractionIdentifier `json:"responseModel,omitempty" yaml:"responseModel,omitempty"` + TotalTokens *ExtractionIdentifier `json:"totalTokens,omitempty" yaml:"totalTokens,omitempty"` + + // Version New version identifier, e.g. v2.0. Must be unique for this template. + Version string `binding:"required" json:"version" yaml:"version"` +} + // CreateLLMProxyAPIKeyRequest defines model for CreateLLMProxyAPIKeyRequest. type CreateLLMProxyAPIKeyRequest struct { // AllowedTargets Comma-separated list of gateways this key is valid for. @@ -1761,12 +1796,36 @@ type LLMProviderTemplate struct { // Description Description of the LLM provider template Description *string `json:"description,omitempty" yaml:"description,omitempty"` + // Enabled Whether this version is offered when creating providers. Disabled + // versions stay in the catalog but are hidden from the provider picker. + // Response-only: create/update default new versions to enabled; toggle + // via PATCH /llm-provider-templates/{id}/versions/{version}. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + + // GroupId Stable identifier shared by every version of a template family + // (defaults to the first version's handle). Read-only. + GroupId *string `json:"groupId,omitempty" yaml:"groupId,omitempty"` + // Id Unique handle for the template - Id string `binding:"required" json:"id" yaml:"id"` - Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Id string `binding:"required" json:"id" yaml:"id"` + + // IsLatest Whether this is the latest version of the template. + IsLatest *bool `json:"isLatest,omitempty" yaml:"isLatest,omitempty"` + + // ManagedBy Identifies who manages the template. Built-in templates use 'wso2'; + // custom templates default to 'customer' and may be set to any value. + // Optional on create/update — the server defaults it to 'customer' when + // omitted, so a request/YAML without a managedBy is accepted. + ManagedBy *string `json:"managedBy,omitempty" yaml:"managedBy,omitempty"` + Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` // Name Human-readable LLM Template name - Name string `binding:"required" json:"name" yaml:"name"` + Name string `binding:"required" json:"name" yaml:"name"` + + // Openapi OpenAPI specification content (JSON or YAML) for the provider, when + // uploaded/pasted. Use metadata.openapiSpecUrl instead to reference the + // spec by URL. + Openapi *string `json:"openapi,omitempty" yaml:"openapi,omitempty"` PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" yaml:"promptTokens,omitempty"` RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" yaml:"remainingTokens,omitempty"` RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" yaml:"requestModel,omitempty"` @@ -1776,6 +1835,15 @@ type LLMProviderTemplate struct { // UpdatedAt Timestamp when the resource was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy Username of the last user to update the template + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` + + // Version Content version, e.g. v1.0. Required. A new template starts at v1.0; + // editing updates that version in place. Supply a new unique version + // only when creating a new version via + // POST /llm-provider-templates/{id}/versions. + Version string `binding:"required" json:"version" yaml:"version"` } // LLMProviderTemplateAuth defines model for LLMProviderTemplateAuth. @@ -1795,9 +1863,24 @@ type LLMProviderTemplateListItem struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Id *string `json:"id,omitempty" yaml:"id,omitempty"` - Name *string `json:"name,omitempty" yaml:"name,omitempty"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // Enabled Whether this version is offered when creating providers. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + + // IsLatest Whether this is the latest version of the template. + IsLatest *bool `json:"isLatest,omitempty" yaml:"isLatest,omitempty"` + + // LogoUrl URL of the provider logo + LogoUrl *string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` + + // ManagedBy Who manages the template ('wso2' for built-in, otherwise custom-defined). + ManagedBy *string `json:"managedBy,omitempty" yaml:"managedBy,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // Version Content version, matching the v. pattern (e.g. v1.0, v2.0). + Version *string `json:"version,omitempty" yaml:"version,omitempty"` } // LLMProviderTemplateListResponse defines model for LLMProviderTemplateListResponse. @@ -3743,6 +3826,28 @@ type ListLLMProviderTemplatesParams struct { // Offset Number of LLM provider templates to skip Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // Versions Which versions to include. The default ("latest") returns one entry + // per template — its latest version, for the catalog. Use "all" to + // return every version row across all templates. + Versions *ListLLMProviderTemplatesParamsVersions `form:"versions,omitempty" json:"versions,omitempty" yaml:"versions,omitempty"` +} + +// ListLLMProviderTemplatesParamsVersions defines parameters for ListLLMProviderTemplates. +type ListLLMProviderTemplatesParamsVersions string + +// ListLLMProviderTemplateVersionsParams defines parameters for ListLLMProviderTemplateVersions. +type ListLLMProviderTemplateVersionsParams struct { + // Limit Maximum number of versions to return + Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Number of versions to skip + Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// SetLLMProviderTemplateVersionEnabledJSONBody defines parameters for SetLLMProviderTemplateVersionEnabled. +type SetLLMProviderTemplateVersionEnabledJSONBody struct { + Enabled bool `json:"enabled" yaml:"enabled"` } // ListLLMProvidersParams defines parameters for ListLLMProviders. @@ -3766,18 +3871,6 @@ type GetLLMProviderDeploymentsParams struct { // GetLLMProviderDeploymentsParamsStatus defines parameters for GetLLMProviderDeployments. type GetLLMProviderDeploymentsParamsStatus string -// RestoreLLMProviderDeploymentDeprecatedParams defines parameters for RestoreLLMProviderDeploymentDeprecated. -type RestoreLLMProviderDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployLLMProviderDeploymentDeprecatedParams defines parameters for UndeployLLMProviderDeploymentDeprecated. -type UndeployLLMProviderDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreLLMProviderDeploymentParams defines parameters for RestoreLLMProviderDeployment. type RestoreLLMProviderDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3823,18 +3916,6 @@ type GetLLMProxyDeploymentsParams struct { // GetLLMProxyDeploymentsParamsStatus defines parameters for GetLLMProxyDeployments. type GetLLMProxyDeploymentsParamsStatus string -// RestoreLLMProxyDeploymentDeprecatedParams defines parameters for RestoreLLMProxyDeploymentDeprecated. -type RestoreLLMProxyDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployLLMProxyDeploymentDeprecatedParams defines parameters for UndeployLLMProxyDeploymentDeprecated. -type UndeployLLMProxyDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreLLMProxyDeploymentParams defines parameters for RestoreLLMProxyDeployment. type RestoreLLMProxyDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3871,18 +3952,6 @@ type GetMCPProxyDeploymentsParams struct { // GetMCPProxyDeploymentsParamsStatus defines parameters for GetMCPProxyDeployments. type GetMCPProxyDeploymentsParamsStatus string -// RestoreMCPProxyDeploymentDeprecatedParams defines parameters for RestoreMCPProxyDeploymentDeprecated. -type RestoreMCPProxyDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployMCPProxyDeploymentDeprecatedParams defines parameters for UndeployMCPProxyDeploymentDeprecated. -type UndeployMCPProxyDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreMCPProxyDeploymentParams defines parameters for RestoreMCPProxyDeployment. type RestoreMCPProxyDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -3931,18 +4000,6 @@ type GetDeploymentsParams struct { // GetDeploymentsParamsStatus defines parameters for GetDeployments. type GetDeploymentsParamsStatus string -// RestoreDeploymentDeprecatedParams defines parameters for RestoreDeploymentDeprecated. -type RestoreDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployDeploymentDeprecatedParams defines parameters for UndeployDeploymentDeprecated. -type UndeployDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreDeploymentParams defines parameters for RestoreDeployment. type RestoreDeploymentParams struct { // GatewayId UUID of the gateway (validated against deployment's bound gateway) @@ -4013,18 +4070,6 @@ type GetWebBrokerAPIDeploymentsParams struct { Status *string `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` } -// RestoreWebBrokerAPIDeploymentDeprecatedParams defines parameters for RestoreWebBrokerAPIDeploymentDeprecated. -type RestoreWebBrokerAPIDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployWebBrokerAPIDeprecatedParams defines parameters for UndeployWebBrokerAPIDeprecated. -type UndeployWebBrokerAPIDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreWebBrokerAPIDeploymentParams defines parameters for RestoreWebBrokerAPIDeployment. type RestoreWebBrokerAPIDeploymentParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` @@ -4048,18 +4093,6 @@ type GetWebSubAPIDeploymentsParams struct { Status *string `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` } -// RestoreWebSubAPIDeploymentDeprecatedParams defines parameters for RestoreWebSubAPIDeploymentDeprecated. -type RestoreWebSubAPIDeploymentDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - -// UndeployWebSubAPIDeprecatedParams defines parameters for UndeployWebSubAPIDeprecated. -type UndeployWebSubAPIDeprecatedParams struct { - DeploymentId string `form:"deploymentId" json:"deploymentId" yaml:"deploymentId"` - GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` -} - // RestoreWebSubAPIDeploymentParams defines parameters for RestoreWebSubAPIDeployment. type RestoreWebSubAPIDeploymentParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` @@ -4112,6 +4145,12 @@ type CreateLLMProviderTemplateJSONRequestBody = LLMProviderTemplate // UpdateLLMProviderTemplateJSONRequestBody defines body for UpdateLLMProviderTemplate for application/json ContentType. type UpdateLLMProviderTemplateJSONRequestBody = LLMProviderTemplate +// CreateLLMProviderTemplateVersionJSONRequestBody defines body for CreateLLMProviderTemplateVersion for application/json ContentType. +type CreateLLMProviderTemplateVersionJSONRequestBody = CreateLLMProviderTemplateVersionRequest + +// SetLLMProviderTemplateVersionEnabledJSONRequestBody defines body for SetLLMProviderTemplateVersionEnabled for application/json ContentType. +type SetLLMProviderTemplateVersionEnabledJSONRequestBody SetLLMProviderTemplateVersionEnabledJSONBody + // CreateLLMProviderJSONRequestBody defines body for CreateLLMProvider for application/json ContentType. type CreateLLMProviderJSONRequestBody = LLMProvider diff --git a/platform-api/src/api/manual_types.go b/platform-api/src/api/manual_types.go index dbb81fb116..b61c01887e 100644 --- a/platform-api/src/api/manual_types.go +++ b/platform-api/src/api/manual_types.go @@ -82,3 +82,18 @@ type ValidateRESTAPIParams struct { // Version **API Version** to check for existence within the organization. Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` } + +// UnpublishFromDevPortalRequest defines model for UnpublishFromDevPortalRequest. +type UnpublishFromDevPortalRequest struct { + // DevPortalUuid UUID of the DevPortal to unpublish from + DevPortalUuid openapi_types.UUID `binding:"required" json:"devPortalUuid" yaml:"devPortalUuid"` +} + +// UnpublishRESTAPIFromDevPortalJSONRequestBody defines body for UnpublishRESTAPIFromDevPortal. +type UnpublishRESTAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest + +// UnpublishWebBrokerAPIFromDevPortalJSONRequestBody defines body for UnpublishWebBrokerAPIFromDevPortal. +type UnpublishWebBrokerAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest + +// UnpublishWebSubAPIFromDevPortalJSONRequestBody defines body for UnpublishWebSubAPIFromDevPortal. +type UnpublishWebSubAPIFromDevPortalJSONRequestBody = UnpublishFromDevPortalRequest diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 23c093b6a3..85e5b68f41 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -150,6 +150,10 @@ var ( var ( ErrLLMProviderTemplateExists = errors.New("llm provider template already exists") ErrLLMProviderTemplateNotFound = errors.New("llm provider template not found") + ErrLLMProviderTemplateVersionExists = errors.New("llm provider template version already exists") + ErrLLMProviderTemplateInUse = errors.New("llm provider template is in use by one or more providers") + ErrLLMProviderTemplateReadOnly = errors.New("built-in llm provider template is read-only") + ErrLLMProviderTemplateManagedByReserved = errors.New("'wso2' is reserved and cannot be used as managedBy on custom templates") ErrLLMProviderExists = errors.New("llm provider already exists") ErrLLMProviderNotFound = errors.New("llm provider not found") ErrLLMProviderLimitReached = errors.New("llm provider limit reached for organization") diff --git a/platform-api/src/internal/database/connection.go b/platform-api/src/internal/database/connection.go index cdc0f63d65..32a260de74 100644 --- a/platform-api/src/internal/database/connection.go +++ b/platform-api/src/internal/database/connection.go @@ -273,10 +273,21 @@ func splitSQLStatements(sql string) []string { var statements []string current := strings.Builder{} inString := false + inLineComment := false escapeNext := false - // Process character by character to properly handle strings and comments - for _, r := range sql { + runes := []rune(sql) + for i := 0; i < len(runes); i++ { + r := runes[i] + + if inLineComment { + current.WriteRune(r) + if r == '\n' { + inLineComment = false + } + continue + } + if escapeNext { current.WriteRune(r) escapeNext = false @@ -290,6 +301,13 @@ func splitSQLStatements(sql string) []string { continue } + // Start of a -- line comment (only when not inside a string literal) + if !inString && r == '-' && i+1 < len(runes) && runes[i+1] == '-' { + inLineComment = true + current.WriteRune(r) + continue + } + // Track string literals - everything inside single quotes is literal if r == '\'' { inString = !inString diff --git a/platform-api/src/internal/database/schema.postgres.sql b/platform-api/src/internal/database/schema.postgres.sql index 0df6cd668a..c52204bb08 100644 --- a/platform-api/src/internal/database/schema.postgres.sql +++ b/platform-api/src/internal/database/schema.postgres.sql @@ -303,14 +303,23 @@ CREATE TABLE IF NOT EXISTS publication_mappings ( CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, - handle VARCHAR(255) NOT NULL, - name VARCHAR(253) NOT NULL, + handle VARCHAR(40) NOT NULL, + group_id VARCHAR(40) NOT NULL, + name VARCHAR(255) NOT NULL, + managed_by VARCHAR(255) NOT NULL DEFAULT 'customer', + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', description VARCHAR(1023), - created_by VARCHAR(255), - configuration TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + configuration BYTEA NOT NULL, + openapi_spec BYTEA, + is_latest SMALLINT NOT NULL DEFAULT 1, + enabled SMALLINT NOT NULL DEFAULT 1, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, group_id, version), UNIQUE(organization_uuid, handle) ); @@ -463,6 +472,8 @@ CREATE INDEX IF NOT EXISTS idx_association_mappings_resource ON association_mapp CREATE INDEX IF NOT EXISTS idx_association_mappings_org ON association_mappings(organization_uuid); CREATE INDEX IF NOT EXISTS idx_artifacts_org ON artifacts(organization_uuid); CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_org ON llm_provider_templates(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_group ON llm_provider_templates(organization_uuid, group_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_latest ON llm_provider_templates(organization_uuid, group_id) WHERE is_latest = 1; CREATE INDEX IF NOT EXISTS idx_llm_providers_template ON llm_providers(template_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_project ON llm_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider_uuid); diff --git a/platform-api/src/internal/database/schema.sql b/platform-api/src/internal/database/schema.sql index 1a57075c0d..d0020592af 100644 --- a/platform-api/src/internal/database/schema.sql +++ b/platform-api/src/internal/database/schema.sql @@ -298,14 +298,23 @@ CREATE TABLE IF NOT EXISTS publication_mappings ( CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, - handle VARCHAR(255) NOT NULL, - name VARCHAR(253) NOT NULL, + handle VARCHAR(40) NOT NULL, + group_id VARCHAR(40) NOT NULL, + name VARCHAR(255) NOT NULL, + managed_by VARCHAR(255) NOT NULL DEFAULT 'customer', + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', description VARCHAR(1023), - created_by VARCHAR(255), - configuration TEXT NOT NULL, + configuration BLOB NOT NULL, + openapi_spec BLOB, + is_latest INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, group_id, version), UNIQUE(organization_uuid, handle) ); @@ -461,6 +470,8 @@ CREATE INDEX IF NOT EXISTS idx_association_mappings_resource ON association_mapp CREATE INDEX IF NOT EXISTS idx_association_mappings_org ON association_mappings(organization_uuid); CREATE INDEX IF NOT EXISTS idx_artifacts_org ON artifacts(organization_uuid); CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_org ON llm_provider_templates(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_group ON llm_provider_templates(organization_uuid, group_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_latest ON llm_provider_templates(organization_uuid, group_id) WHERE is_latest = 1; CREATE INDEX IF NOT EXISTS idx_llm_providers_template ON llm_providers(template_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_project ON llm_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider_uuid); diff --git a/platform-api/src/internal/database/schema.sqlite.sql b/platform-api/src/internal/database/schema.sqlite.sql index 08d28d9531..7e44fe4fd6 100644 --- a/platform-api/src/internal/database/schema.sqlite.sql +++ b/platform-api/src/internal/database/schema.sqlite.sql @@ -301,14 +301,23 @@ CREATE TABLE IF NOT EXISTS publication_mappings ( CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, - handle VARCHAR(255) NOT NULL, - name VARCHAR(253) NOT NULL, + handle VARCHAR(40) NOT NULL, + group_id VARCHAR(40) NOT NULL, + name VARCHAR(255) NOT NULL, + managed_by VARCHAR(255) NOT NULL DEFAULT 'customer', + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', description VARCHAR(1023), - created_by VARCHAR(255), - configuration TEXT NOT NULL, + configuration BLOB NOT NULL, + openapi_spec BLOB, + is_latest INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, group_id, version), UNIQUE(organization_uuid, handle) ); @@ -461,6 +470,8 @@ CREATE INDEX IF NOT EXISTS idx_association_mappings_resource ON association_mapp CREATE INDEX IF NOT EXISTS idx_association_mappings_org ON association_mappings(organization_uuid); CREATE INDEX IF NOT EXISTS idx_artifacts_org ON artifacts(organization_uuid); CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_org ON llm_provider_templates(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_llm_provider_templates_group ON llm_provider_templates(organization_uuid, group_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_latest ON llm_provider_templates(organization_uuid, group_id) WHERE is_latest = 1; CREATE INDEX IF NOT EXISTS idx_llm_providers_template ON llm_providers(template_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_project ON llm_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider_uuid); diff --git a/platform-api/src/internal/database/schema.sqlserver.sql b/platform-api/src/internal/database/schema.sqlserver.sql index b9073ab295..d94255d9e3 100644 --- a/platform-api/src/internal/database/schema.sqlserver.sql +++ b/platform-api/src/internal/database/schema.sqlserver.sql @@ -348,15 +348,24 @@ CREATE TABLE dbo.publication_mappings ( IF OBJECT_ID(N'dbo.llm_provider_templates', N'U') IS NULL CREATE TABLE dbo.llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, - organization_uuid VARCHAR(40) NOT NULL, - handle VARCHAR(255) NOT NULL, - name VARCHAR(253) NOT NULL, + handle VARCHAR(40) NOT NULL, + group_id VARCHAR(40) NOT NULL, + name VARCHAR(255) NOT NULL, + managed_by VARCHAR(255) NOT NULL DEFAULT 'customer', + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', description VARCHAR(1023), - created_by VARCHAR(255), - configuration NVARCHAR(MAX) NOT NULL, + configuration VARBINARY(MAX) NOT NULL, + openapi_spec VARBINARY(MAX), + is_latest SMALLINT NOT NULL DEFAULT 1, + enabled SMALLINT NOT NULL DEFAULT 1, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + created_by VARCHAR(200), created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + organization_uuid VARCHAR(40) NOT NULL, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, group_id, version), UNIQUE(organization_uuid, handle) ); @@ -545,6 +554,10 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifacts_org' AND o CREATE INDEX idx_artifacts_org ON dbo.artifacts(organization_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_provider_templates_org' AND object_id = OBJECT_ID(N'dbo.llm_provider_templates')) CREATE INDEX idx_llm_provider_templates_org ON dbo.llm_provider_templates(organization_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_provider_templates_group' AND object_id = OBJECT_ID(N'dbo.llm_provider_templates')) +CREATE INDEX idx_llm_provider_templates_group ON dbo.llm_provider_templates(organization_uuid, group_id); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_provider_templates_latest' AND object_id = OBJECT_ID(N'dbo.llm_provider_templates')) +CREATE UNIQUE INDEX idx_llm_provider_templates_latest ON dbo.llm_provider_templates(organization_uuid, group_id) WHERE is_latest = 1; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_providers_template' AND object_id = OBJECT_ID(N'dbo.llm_providers')) CREATE INDEX idx_llm_providers_template ON dbo.llm_providers(template_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_proxies_project' AND object_id = OBJECT_ID(N'dbo.llm_proxies')) diff --git a/platform-api/src/internal/handler/llm.go b/platform-api/src/internal/handler/llm.go index 9f929affb1..b9c76e9c30 100644 --- a/platform-api/src/internal/handler/llm.go +++ b/platform-api/src/internal/handler/llm.go @@ -18,6 +18,7 @@ package handler import ( + "encoding/json" "errors" "log/slog" "net/http" @@ -56,6 +57,11 @@ func (h *LLMHandler) RegisterRoutes(r *gin.Engine) { v1.POST("/llm-provider-templates", h.CreateLLMProviderTemplate) v1.GET("/llm-provider-templates", h.ListLLMProviderTemplates) v1.GET("/llm-provider-templates/:id", h.GetLLMProviderTemplate) + v1.GET("/llm-provider-templates/:id/versions", h.ListLLMProviderTemplateVersions) + v1.GET("/llm-provider-templates/:id/versions/:version", h.GetLLMProviderTemplateVersion) + v1.POST("/llm-provider-templates/:id/versions", h.CreateLLMProviderTemplateVersion) + v1.PATCH("/llm-provider-templates/:id/versions/:version", h.SetLLMProviderTemplateVersionEnabled) + v1.DELETE("/llm-provider-templates/:id/versions/:version", h.DeleteLLMProviderTemplateVersion) v1.PUT("/llm-provider-templates/:id", h.UpdateLLMProviderTemplate) v1.DELETE("/llm-provider-templates/:id", h.DeleteLLMProviderTemplate) @@ -98,6 +104,9 @@ func (h *LLMHandler) CreateLLMProviderTemplate(c *gin.Context) { case errors.Is(err, constants.ErrLLMProviderTemplateExists): c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "LLM provider template already exists")) return + case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) + return case errors.Is(err, constants.ErrInvalidInput): c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) return @@ -134,7 +143,9 @@ func (h *LLMHandler) ListLLMProviderTemplates(c *gin.Context) { offset = 0 } - resp, err := h.templateService.List(orgID, limit, offset) + allVersions := strings.EqualFold(c.Query("versions"), "all") + + resp, err := h.templateService.List(orgID, limit, offset, allVersions) if err != nil { h.slogger.Error("Failed to list LLM provider templates", "organizationId", orgID, "error", err) c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM provider templates")) @@ -169,6 +180,150 @@ func (h *LLMHandler) GetLLMProviderTemplate(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *LLMHandler) ListLLMProviderTemplateVersions(c *gin.Context) { + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + id := c.Param("id") + + limit, err := strconv.Atoi(c.DefaultQuery("limit", "20")) + if err != nil || limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")) + if err != nil || offset < 0 { + offset = 0 + } + + resp, err := h.templateService.ListVersions(orgID, id, limit, offset) + if err != nil { + switch { + case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) + return + case errors.Is(err, constants.ErrInvalidInput): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id")) + return + default: + h.slogger.Error("Failed to list LLM provider template versions", "organizationId", orgID, "templateId", id, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to list LLM provider template versions")) + return + } + } + c.JSON(http.StatusOK, resp) +} + +func (h *LLMHandler) CreateLLMProviderTemplateVersion(c *gin.Context) { + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + id := c.Param("id") + + createdBy, _ := middleware.GetUsernameFromContext(c) + + var req api.CreateLLMProviderTemplateVersionRequest + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + return + } + + created, err := h.templateService.CreateVersion(orgID, id, createdBy, &req) + if err != nil { + switch { + case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateVersionExists): + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "The version already exists")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) + return + case errors.Is(err, constants.ErrInvalidInput): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input. Version must match the v. pattern (e.g. v1.0)")) + return + default: + h.slogger.Error("Failed to create LLM provider template version", "organizationId", orgID, "templateId", id, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to create LLM provider template version")) + return + } + } + c.JSON(http.StatusCreated, created) +} + +func (h *LLMHandler) GetLLMProviderTemplateVersion(c *gin.Context) { + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + id := c.Param("id") + version := c.Param("version") + + resp, err := h.templateService.GetVersion(orgID, id, version) + if err != nil { + switch { + case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) + return + case errors.Is(err, constants.ErrInvalidInput): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id or version")) + return + default: + h.slogger.Error("Failed to get LLM provider template version", "organizationId", orgID, "templateId", id, "version", version, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to get LLM provider template version")) + return + } + } + c.JSON(http.StatusOK, resp) +} + +// SetLLMProviderTemplateVersionEnabled enables or disables a specific version of a template. +func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(c *gin.Context) { + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + id := c.Param("id") + version := c.Param("version") + + var body struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil || body.Enabled == nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Request body must include a boolean 'enabled' field")) + return + } + + resp, err := h.templateService.SetVersionEnabled(orgID, id, version, *body.Enabled) + if err != nil { + switch { + case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) + return + case errors.Is(err, constants.ErrInvalidInput): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id or version")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateInUse): + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Cannot disable template version while providers are using it")) + return + default: + h.slogger.Error("Failed to set LLM provider template version enabled", "organizationId", orgID, "templateId", id, "version", version, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to update template version")) + return + } + } + c.JSON(http.StatusOK, resp) +} + func (h *LLMHandler) UpdateLLMProviderTemplate(c *gin.Context) { orgID, ok := middleware.GetOrganizationFromContext(c) if !ok { @@ -189,6 +344,12 @@ func (h *LLMHandler) UpdateLLMProviderTemplate(c *gin.Context) { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) return + case errors.Is(err, constants.ErrLLMProviderTemplateReadOnly): + c.JSON(http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Built-in templates are read-only and cannot be edited")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateManagedByReserved): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "'wso2' is reserved and cannot be used as managedBy on custom templates")) + return case errors.Is(err, constants.ErrInvalidInput): c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) return @@ -214,6 +375,12 @@ func (h *LLMHandler) DeleteLLMProviderTemplate(c *gin.Context) { case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template not found")) return + case errors.Is(err, constants.ErrLLMProviderTemplateInUse): + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Template cannot be deleted while providers are using it")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateReadOnly): + c.JSON(http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Built-in templates are read-only and cannot be deleted")) + return case errors.Is(err, constants.ErrInvalidInput): c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id")) return @@ -227,6 +394,40 @@ func (h *LLMHandler) DeleteLLMProviderTemplate(c *gin.Context) { c.Status(http.StatusNoContent) } +// DeleteLLMProviderTemplateVersion removes a single version of a template. +func (h *LLMHandler) DeleteLLMProviderTemplateVersion(c *gin.Context) { + orgID, ok := middleware.GetOrganizationFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", "Organization claim not found in token")) + return + } + id := c.Param("id") + version := c.Param("version") + + if err := h.templateService.DeleteVersion(orgID, id, version); err != nil { + switch { + case errors.Is(err, constants.ErrLLMProviderTemplateNotFound): + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", "LLM provider template version not found")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateInUse): + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "Template version cannot be deleted while providers are using it")) + return + case errors.Is(err, constants.ErrLLMProviderTemplateReadOnly): + c.JSON(http.StatusForbidden, utils.NewErrorResponse(403, "Forbidden", "Built-in template versions are read-only and cannot be deleted")) + return + case errors.Is(err, constants.ErrInvalidInput): + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid template id or version")) + return + default: + h.slogger.Error("Failed to delete LLM provider template version", "organizationId", orgID, "templateId", id, "version", version, "error", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", "Failed to delete template version")) + return + } + } + + c.Status(http.StatusNoContent) +} + // ---- Providers ---- func (h *LLMHandler) CreateLLMProvider(c *gin.Context) { diff --git a/platform-api/src/internal/model/llm.go b/platform-api/src/internal/model/llm.go index efe8916ba2..54bc8fc5f3 100644 --- a/platform-api/src/internal/model/llm.go +++ b/platform-api/src/internal/model/llm.go @@ -162,9 +162,15 @@ type LLMProviderTemplate struct { UUID string `json:"uuid" db:"uuid"` OrganizationUUID string `json:"organizationId" db:"organization_uuid"` ID string `json:"id" db:"handle"` + GroupID string `json:"groupId,omitempty" db:"group_id"` Name string `json:"name" db:"name"` Description string `json:"description,omitempty" db:"description"` + ManagedBy string `json:"managedBy,omitempty" db:"managed_by"` CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + Version string `json:"version" db:"version"` + IsLatest bool `json:"isLatest" db:"is_latest"` + Enabled bool `json:"enabled" db:"enabled"` Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" db:"-"` PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" db:"-"` CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" db:"-"` @@ -173,6 +179,7 @@ type LLMProviderTemplate struct { RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" db:"-"` ResponseModel *ExtractionIdentifier `json:"responseModel,omitempty" db:"-"` ResourceMappings *LLMProviderTemplateResourceMappings `json:"resourceMappings,omitempty" db:"-"` + OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` CreatedAt time.Time `json:"createdAt" db:"created_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } diff --git a/platform-api/src/internal/repository/interfaces.go b/platform-api/src/internal/repository/interfaces.go index 6e371b083e..cd0ba3734f 100644 --- a/platform-api/src/internal/repository/interfaces.go +++ b/platform-api/src/internal/repository/interfaces.go @@ -227,13 +227,25 @@ type APIPublicationRepository interface { // LLMProviderTemplateRepository defines the interface for LLM provider template persistence type LLMProviderTemplateRepository interface { Create(t *model.LLMProviderTemplate) error + CreateNewVersion(t *model.LLMProviderTemplate) error GetByID(templateID, orgUUID string) (*model.LLMProviderTemplate, error) GetByUUID(uuid, orgUUID string) (*model.LLMProviderTemplate, error) + GetByVersion(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) + ListVersions(templateID, orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) + CountVersions(templateID, orgUUID string) (int, error) List(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) Count(orgUUID string) (int, error) + ListAllVersions(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) + CountAllVersions(orgUUID string) (int, error) Update(t *model.LLMProviderTemplate) error + RenameFamily(baseHandle, orgUUID, name string) error + SetEnabled(templateID, orgUUID, version string, enabled bool) error + DeleteVersion(templateID, orgUUID, version string) error Delete(templateID, orgUUID string) error Exists(templateID, orgUUID string) (bool, error) + GetGroupID(handle, orgUUID string) (string, error) + ManagedByForHandle(handle, orgUUID string) (string, error) + CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error) } // LLMProviderRepository defines the interface for LLM provider persistence diff --git a/platform-api/src/internal/repository/llm.go b/platform-api/src/internal/repository/llm.go index 7955244d92..b243b9d4b7 100644 --- a/platform-api/src/internal/repository/llm.go +++ b/platform-api/src/internal/repository/llm.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "platform-api/src/internal/constants" @@ -36,6 +37,7 @@ type LLMProviderTemplateRepo struct { } type llmProviderTemplateConfig struct { + ManagedBy string `json:"managedBy,omitempty"` Metadata *model.LLMProviderTemplateMetadata `json:"metadata,omitempty"` PromptTokens *model.ExtractionIdentifier `json:"promptTokens,omitempty"` CompletionTokens *model.ExtractionIdentifier `json:"completionTokens,omitempty"` @@ -50,6 +52,13 @@ func NewLLMProviderTemplateRepo(db *database.DB) LLMProviderTemplateRepository { return &LLMProviderTemplateRepo{db: db} } +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { uuidStr, err := utils.GenerateUUID() if err != nil { @@ -60,6 +69,7 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { t.UpdatedAt = time.Now() configJSON, err := json.Marshal(&llmProviderTemplateConfig{ + ManagedBy: t.ManagedBy, Metadata: t.Metadata, PromptTokens: t.PromptTokens, CompletionTokens: t.CompletionTokens, @@ -73,80 +83,253 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { return err } + if t.Version == "" { + t.Version = "v1.0" + } + if t.ManagedBy == "" { + t.ManagedBy = "customer" + } + if t.GroupID == "" { + t.GroupID = t.ID + } + t.IsLatest = true + t.Enabled = true + t.UpdatedBy = t.CreatedBy + query := ` INSERT INTO llm_provider_templates ( - uuid, organization_uuid, handle, name, description, created_by, - configuration, created_at, updated_at + uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, + configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = r.db.Exec(r.db.Rebind(query), - t.UUID, t.OrganizationUUID, t.ID, t.Name, t.Description, t.CreatedBy, - string(configJSON), + t.UUID, t.OrganizationUUID, t.ID, t.GroupID, t.Name, t.ManagedBy, t.Description, t.CreatedBy, t.UpdatedBy, + configJSON, []byte(t.OpenAPISpec), t.Version, boolToInt(t.IsLatest), boolToInt(t.Enabled), t.CreatedAt, t.UpdatedAt, ) return err } +func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) error { + configJSON, err := json.Marshal(&llmProviderTemplateConfig{ + ManagedBy: t.ManagedBy, + Metadata: t.Metadata, + PromptTokens: t.PromptTokens, + CompletionTokens: t.CompletionTokens, + TotalTokens: t.TotalTokens, + RemainingTokens: t.RemainingTokens, + RequestModel: t.RequestModel, + ResponseModel: t.ResponseModel, + ResourceMappings: t.ResourceMappings, + }) + if err != nil { + return err + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + lockSQL := `SELECT created_by FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ? AND is_latest = ?` + switch r.db.Driver() { + case database.DriverPostgres, database.DriverPostgreSQL, database.DriverPGX: + lockSQL += " FOR UPDATE" + case database.DriverSQLServer, database.DriverMSSQL: + lockSQL = `SELECT created_by FROM llm_provider_templates WITH (UPDLOCK, HOLDLOCK) WHERE group_id = ? AND organization_uuid = ? AND is_latest = ?` + } + var createdBy sql.NullString + err = tx.QueryRow(r.db.Rebind(lockSQL), t.GroupID, t.OrganizationUUID, 1).Scan(&createdBy) + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + if err != nil { + return err + } + + var sameVersion int + if err = tx.QueryRow(r.db.Rebind(` + SELECT COUNT(*) FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ? AND version = ? + `), t.GroupID, t.OrganizationUUID, t.Version).Scan(&sameVersion); err != nil { + return err + } + if sameVersion > 0 { + return constants.ErrLLMProviderTemplateVersionExists + } + + // Demote the current latest within this family (same group_id). + if _, err = tx.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET is_latest = ? WHERE group_id = ? AND organization_uuid = ? AND is_latest = ? + `), 0, t.GroupID, t.OrganizationUUID, 1); err != nil { + return err + } + + uuidStr, err := utils.GenerateUUID() + if err != nil { + return fmt.Errorf("failed to generate LLM provider template ID: %w", err) + } + t.UUID = uuidStr + t.IsLatest = true + t.Enabled = true + t.CreatedBy = createdBy.String + t.UpdatedBy = t.CreatedBy + t.CreatedAt = time.Now() + t.UpdatedAt = time.Now() + + if _, err = tx.Exec(r.db.Rebind(` + INSERT INTO llm_provider_templates ( + uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, + configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + t.UUID, t.OrganizationUUID, t.ID, t.GroupID, t.Name, t.ManagedBy, t.Description, t.CreatedBy, t.UpdatedBy, + configJSON, []byte(t.OpenAPISpec), t.Version, boolToInt(t.IsLatest), boolToInt(t.Enabled), + t.CreatedAt, t.UpdatedAt, + ); err != nil { + return err + } + + return tx.Commit() +} + +func (r *LLMProviderTemplateRepo) familyGroupID(handle, orgUUID string) (string, error) { + var base string + err := r.db.QueryRow(r.db.Rebind(` + SELECT group_id FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? + ORDER BY (SELECT NULL) `+r.db.FetchFirstClause(1)), handle, orgUUID).Scan(&base) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return base, nil +} + +func (r *LLMProviderTemplateRepo) GetGroupID(handle, orgUUID string) (string, error) { + return r.familyGroupID(handle, orgUUID) +} + +func (r *LLMProviderTemplateRepo) ManagedByForHandle(handle, orgUUID string) (string, error) { + var managedBy string + err := r.db.QueryRow(r.db.Rebind(` + SELECT managed_by FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? + ORDER BY (SELECT NULL) `+r.db.FetchFirstClause(1)), handle, orgUUID).Scan(&managedBy) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return managedBy, nil +} + func (r *LLMProviderTemplateRepo) GetByID(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { row := r.db.QueryRow(r.db.Rebind(` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, created_at, updated_at + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? `), templateID, orgUUID) + return scanTemplateRow(row) +} - var t model.LLMProviderTemplate - var configJSON sql.NullString - if err := row.Scan( - &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, - &t.CreatedAt, &t.UpdatedAt, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } +func (r *LLMProviderTemplateRepo) GetByUUID(uuid, orgUUID string) (*model.LLMProviderTemplate, error) { + row := r.db.QueryRow(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + FROM llm_provider_templates + WHERE uuid = ? AND organization_uuid = ? + `), uuid, orgUUID) + return scanTemplateRow(row) +} + +func (r *LLMProviderTemplateRepo) GetByVersion(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { return nil, err } + if base == "" { + return nil, nil + } + row := r.db.QueryRow(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? AND version = ? + `), base, orgUUID, version) + return scanTemplateRow(row) +} - if configJSON.Valid && configJSON.String != "" { - var cfg llmProviderTemplateConfig - if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { +func (r *LLMProviderTemplateRepo) ListVersions(templateID, orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return nil, err + } + if base == "" { + return nil, nil + } + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + query := ` + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? + ORDER BY created_at DESC + ` + pageClause + rows, err := r.db.Query(r.db.Rebind(query), append([]any{base, orgUUID}, pageArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []*model.LLMProviderTemplate + for rows.Next() { + t, err := scanTemplate(rows) + if err != nil { return nil, err } - t.Metadata = cfg.Metadata - t.PromptTokens = cfg.PromptTokens - t.CompletionTokens = cfg.CompletionTokens - t.TotalTokens = cfg.TotalTokens - t.RemainingTokens = cfg.RemainingTokens - t.RequestModel = cfg.RequestModel - t.ResponseModel = cfg.ResponseModel - t.ResourceMappings = cfg.ResourceMappings + res = append(res, t) } - - return &t, nil + return res, rows.Err() } -func (r *LLMProviderTemplateRepo) GetByUUID(uuid, orgUUID string) (*model.LLMProviderTemplate, error) { - row := r.db.QueryRow(r.db.Rebind(` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, created_at, updated_at - FROM llm_provider_templates - WHERE uuid = ? AND organization_uuid = ? - `), uuid, orgUUID) +func (r *LLMProviderTemplateRepo) CountVersions(templateID, orgUUID string) (int, error) { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return 0, err + } + if base == "" { + return 0, nil + } + var count int + if err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ?`), base, orgUUID).Scan(&count); err != nil { + return 0, err + } + return count, nil +} +func scanTemplate(s rowScanner) (*model.LLMProviderTemplate, error) { var t model.LLMProviderTemplate - var configJSON sql.NullString - if err := row.Scan( - &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, + // configuration and openapi_spec are binary columns (BLOB/BYTEA/VARBINARY); + // is_latest and enabled are integer columns. Scanning an integer column + // straight into a Go bool fails on Postgres/SQL Server, so scan into int + // and convert. + var configJSON []byte + var openapiSpec []byte + var isLatest, enabled int + if err := s.Scan( + &t.UUID, &t.OrganizationUUID, &t.ID, &t.GroupID, &t.Name, &t.ManagedBy, &t.Description, &t.CreatedBy, &t.UpdatedBy, + &configJSON, &openapiSpec, &t.Version, &isLatest, &enabled, &t.CreatedAt, &t.UpdatedAt, ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } return nil, err } - - if configJSON.Valid && configJSON.String != "" { + t.IsLatest = isLatest != 0 + t.Enabled = enabled != 0 + t.OpenAPISpec = string(openapiSpec) + if len(configJSON) > 0 { var cfg llmProviderTemplateConfig - if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { + if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, err } t.Metadata = cfg.Metadata @@ -158,19 +341,30 @@ func (r *LLMProviderTemplateRepo) GetByUUID(uuid, orgUUID string) (*model.LLMPro t.ResponseModel = cfg.ResponseModel t.ResourceMappings = cfg.ResourceMappings } - return &t, nil } +func scanTemplateRow(row *sql.Row) (*model.LLMProviderTemplate, error) { + t, err := scanTemplate(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return t, nil +} + func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { pageClause, pageArgs := r.db.PaginationClause(limit, offset) query := ` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, created_at, updated_at + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates WHERE organization_uuid = ? + AND (managed_by = 'wso2' OR (managed_by != 'wso2' AND is_latest = ?)) ORDER BY created_at DESC ` + pageClause - rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID}, pageArgs...)...) + rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID, 1}, pageArgs...)...) if err != nil { return nil, err } @@ -178,38 +372,53 @@ func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*mo var res []*model.LLMProviderTemplate for rows.Next() { - var t model.LLMProviderTemplate - var configJSON sql.NullString - err := rows.Scan( - &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, - &t.CreatedAt, &t.UpdatedAt, - ) + t, err := scanTemplate(rows) if err != nil { return nil, err } - if configJSON.Valid && configJSON.String != "" { - var cfg llmProviderTemplateConfig - if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { - return nil, err - } - t.Metadata = cfg.Metadata - t.PromptTokens = cfg.PromptTokens - t.CompletionTokens = cfg.CompletionTokens - t.TotalTokens = cfg.TotalTokens - t.RemainingTokens = cfg.RemainingTokens - t.RequestModel = cfg.RequestModel - t.ResponseModel = cfg.ResponseModel - t.ResourceMappings = cfg.ResourceMappings + res = append(res, t) + } + return res, rows.Err() +} + +func (r *LLMProviderTemplateRepo) ListAllVersions(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + query := ` + SELECT uuid, organization_uuid, handle, group_id, name, managed_by, description, created_by, updated_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + FROM llm_provider_templates + WHERE organization_uuid = ? + ORDER BY name ASC, created_at DESC + ` + pageClause + rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID}, pageArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []*model.LLMProviderTemplate + for rows.Next() { + t, err := scanTemplate(rows) + if err != nil { + return nil, err } - res = append(res, &t) + res = append(res, t) } return res, rows.Err() } +func (r *LLMProviderTemplateRepo) CountAllVersions(orgUUID string) (int, error) { + var count int + if err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE organization_uuid = ?`), orgUUID).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + func (r *LLMProviderTemplateRepo) Update(t *model.LLMProviderTemplate) error { t.UpdatedAt = time.Now() configJSON, err := json.Marshal(&llmProviderTemplateConfig{ + ManagedBy: t.ManagedBy, Metadata: t.Metadata, PromptTokens: t.PromptTokens, CompletionTokens: t.CompletionTokens, @@ -223,15 +432,12 @@ func (r *LLMProviderTemplateRepo) Update(t *model.LLMProviderTemplate) error { return err } - query := ` + result, err := r.db.Exec(r.db.Rebind(` UPDATE llm_provider_templates - SET name = ?, description = ?, configuration = ?, updated_at = ? + SET name = ?, description = ?, configuration = ?, openapi_spec = ?, updated_by = ?, updated_at = ? WHERE handle = ? AND organization_uuid = ? - ` - result, err := r.db.Exec(r.db.Rebind(query), - t.Name, t.Description, - string(configJSON), - t.UpdatedAt, + `), + t.Name, t.Description, configJSON, []byte(t.OpenAPISpec), t.UpdatedBy, t.UpdatedAt, t.ID, t.OrganizationUUID, ) if err != nil { @@ -248,8 +454,78 @@ func (r *LLMProviderTemplateRepo) Update(t *model.LLMProviderTemplate) error { return nil } +func (r *LLMProviderTemplateRepo) RenameFamily(baseHandle, orgUUID, name string) error { + _, err := r.db.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET name = ?, updated_at = ? + WHERE group_id = ? AND organization_uuid = ? AND managed_by != ? + `), name, time.Now(), baseHandle, orgUUID, "wso2") + return err +} + func (r *LLMProviderTemplateRepo) Delete(templateID, orgUUID string) error { - result, err := r.db.Exec(r.db.Rebind(`DELETE FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ?`), templateID, orgUUID) + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return err + } + if base == "" { + return sql.ErrNoRows + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.Exec(r.db.Rebind(` + DELETE FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? AND managed_by != ? + `), base, orgUUID, "wso2") + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + + var remaining, latestCount int + if err := tx.QueryRow(r.db.Rebind(` + SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest = 1 THEN 1 ELSE 0 END), 0) + FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ? + `), base, orgUUID).Scan(&remaining, &latestCount); err != nil { + return err + } + if remaining > 0 && latestCount == 0 { + if _, err := tx.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET is_latest = ? + WHERE uuid = ( + SELECT uuid FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? + ORDER BY created_at DESC `+r.db.FetchFirstClause(1)+` + ) + `), 1, base, orgUUID); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *LLMProviderTemplateRepo) SetEnabled(templateID, orgUUID, version string, enabled bool) error { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return err + } + if base == "" { + return sql.ErrNoRows + } + result, err := r.db.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET enabled = ?, updated_at = ? + WHERE group_id = ? AND organization_uuid = ? AND version = ? + `), boolToInt(enabled), time.Now(), base, orgUUID, version) if err != nil { return err } @@ -263,6 +539,58 @@ func (r *LLMProviderTemplateRepo) Delete(templateID, orgUUID string) error { return nil } +func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version string) error { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return err + } + if base == "" { + return sql.ErrNoRows + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.Exec(r.db.Rebind(` + DELETE FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? AND version = ? + `), base, orgUUID, version) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + + var remaining, latestCount int + if err := tx.QueryRow(r.db.Rebind(` + SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest = 1 THEN 1 ELSE 0 END), 0) + FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ? + `), base, orgUUID).Scan(&remaining, &latestCount); err != nil { + return err + } + if remaining > 0 && latestCount == 0 { + if _, err := tx.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET is_latest = ? + WHERE uuid = ( + SELECT uuid FROM llm_provider_templates + WHERE group_id = ? AND organization_uuid = ? + ORDER BY created_at DESC `+r.db.FetchFirstClause(1)+` + ) + `), 1, base, orgUUID); err != nil { + return err + } + } + return tx.Commit() +} + func (r *LLMProviderTemplateRepo) Exists(templateID, orgUUID string) (bool, error) { var count int err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ?`), templateID, orgUUID).Scan(&count) @@ -274,7 +602,35 @@ func (r *LLMProviderTemplateRepo) Exists(templateID, orgUUID string) (bool, erro func (r *LLMProviderTemplateRepo) Count(orgUUID string) (int, error) { var count int - if err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE organization_uuid = ?`), orgUUID).Scan(&count); err != nil { + if err := r.db.QueryRow(r.db.Rebind(` + SELECT COUNT(*) FROM llm_provider_templates + WHERE organization_uuid = ? + AND (managed_by = 'wso2' OR (managed_by != 'wso2' AND is_latest = ?)) + `), orgUUID, 1).Scan(&count); err != nil { + return 0, err + } + return count, nil +} +func (r *LLMProviderTemplateRepo) CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error) { + base, err := r.familyGroupID(templateID, orgUUID) + if err != nil { + return 0, err + } + if base == "" { + return 0, nil + } + query := ` + SELECT COUNT(*) + FROM llm_providers p + JOIN llm_provider_templates t ON p.template_uuid = t.uuid + WHERE t.group_id = ? AND t.organization_uuid = ?` + args := []interface{}{base, orgUUID} + if strings.TrimSpace(version) != "" { + query += ` AND t.version = ?` + args = append(args, version) + } + var count int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&count); err != nil { return 0, err } return count, nil diff --git a/platform-api/src/internal/repository/llm_test.go b/platform-api/src/internal/repository/llm_test.go new file mode 100644 index 0000000000..4680982742 --- /dev/null +++ b/platform-api/src/internal/repository/llm_test.go @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package repository + +import ( + "testing" + "time" + + "platform-api/src/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// TestLLMProviderTemplateRepo_GetByID_ExactVersion verifies that GetByID honours +// the exact handle it is given (rather than always returning the family's latest +// version), and returns nil for an unknown handle. This is the resolution that +// keeps a provider bound to the specific template version selected at creation +// time. +func TestLLMProviderTemplateRepo_GetByID_ExactVersion(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewLLMProviderTemplateRepo(db) + + orgUUID := "org-tpl-001" + projectUUID := "project-tpl-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + now := time.Now() + // Built-in v1.0 — seeded with handle == group_id (no version suffix), as + // the template loader does (managedBy "wso2"). + v1 := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: "mistralai", + GroupID: "mistralai", + Name: "Mistral", + ManagedBy: "wso2", + Version: "v1.0", + CreatedAt: now, + UpdatedAt: now, + } + if err := repo.Create(v1); err != nil { + t.Fatalf("failed to create v1.0: %v", err) + } + v1UUID := v1.UUID + + // Custom v2.0 spun off the built-in — version-suffixed handle, managedBy + // "customer"; this demotes v1.0 and becomes the family's latest. + v2 := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: "mistralai-v2-0", + GroupID: "mistralai", + Name: "Mistral", + ManagedBy: "customer", + Version: "v2.0", + } + if err := repo.CreateNewVersion(v2); err != nil { + t.Fatalf("failed to create v2.0: %v", err) + } + + // Built-in handle must resolve to its own v1.0 — NOT the latest (v2.0). This + // is the bug fix: previously GetByID always returned the is_latest row. + got, err := repo.GetByID("mistralai", orgUUID) + if err != nil { + t.Fatalf("GetByID(builtin) error: %v", err) + } + if got == nil || got.Version != "v1.0" || got.UUID != v1UUID { + t.Fatalf("GetByID(builtin) = %+v, want version v1.0 (uuid %s)", got, v1UUID) + } + if got.ManagedBy != "wso2" { + t.Errorf("GetByID(builtin).ManagedBy = %q, want wso2", got.ManagedBy) + } + + // The version-specific custom handle must return v2.0. + got, err = repo.GetByID("mistralai-v2-0", orgUUID) + if err != nil { + t.Fatalf("GetByID(v2) error: %v", err) + } + if got == nil || got.Version != "v2.0" || got.ManagedBy != "customer" { + t.Fatalf("GetByID(v2) = %+v, want version v2.0 (managedBy customer)", got) + } + + // An unknown handle resolves to nothing (no spurious latest match). + got, err = repo.GetByID("does-not-exist", orgUUID) + if err != nil { + t.Fatalf("GetByID(unknown) error: %v", err) + } + if got != nil { + t.Fatalf("GetByID(unknown) = %+v, want nil", got) + } +} diff --git a/platform-api/src/internal/service/llm.go b/platform-api/src/internal/service/llm.go index 0202bcf8d7..708cde3723 100644 --- a/platform-api/src/internal/service/llm.go +++ b/platform-api/src/internal/service/llm.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "log/slog" + "regexp" "strings" "platform-api/src/api" @@ -112,11 +113,29 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. if req == nil { return nil, constants.ErrInvalidInput } - if req.Id == "" || req.Name == "" { + if req.Name == "" { return nil, constants.ErrInvalidInput } - exists, err := s.repo.Exists(req.Id, orgUUID) + baseHandle, err := utils.GenerateHandle(req.Name, nil) + if err != nil || baseHandle == "" { + return nil, constants.ErrInvalidInput + } + version := "v1.0" + if v := req.Version; v != "" { + normalized, ok := normalizeTemplateVersion(v) + if !ok { + return nil, constants.ErrInvalidInput + } + version = normalized + } + handle := makeTemplateHandle(baseHandle, version) + + if req.ManagedBy != nil && strings.TrimSpace(*req.ManagedBy) == constants.PolicyManagedByWSO2 { + return nil, constants.ErrLLMProviderTemplateManagedByReserved + } + + exists, err := s.repo.Exists(handle, orgUUID) if err != nil { return nil, fmt.Errorf("failed to check template exists: %w", err) } @@ -126,10 +145,14 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. m := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, - ID: req.Id, + ID: handle, + GroupID: baseHandle, + Version: version, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), + ManagedBy: defaultTemplateManagedBy(req.ManagedBy), CreatedBy: createdBy, + OpenAPISpec: utils.ValueOrEmpty(req.Openapi), Metadata: mapTemplateMetadataAPI(req.Metadata), PromptTokens: mapExtractionIdentifierAPI(req.PromptTokens), CompletionTokens: mapExtractionIdentifierAPI(req.CompletionTokens), @@ -154,12 +177,18 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. return mapTemplateModelToAPI(m), nil } -func (s *LLMProviderTemplateService) List(orgUUID string, limit, offset int) (*api.LLMProviderTemplateListResponse, error) { - items, err := s.repo.List(orgUUID, limit, offset) +func (s *LLMProviderTemplateService) List(orgUUID string, limit, offset int, allVersions bool) (*api.LLMProviderTemplateListResponse, error) { + listFn := s.repo.List + countFn := s.repo.Count + if allVersions { + listFn = s.repo.ListAllVersions + countFn = s.repo.CountAllVersions + } + items, err := listFn(orgUUID, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list templates: %w", err) } - totalCount, err := s.repo.Count(orgUUID) + totalCount, err := countFn(orgUUID) if err != nil { return nil, fmt.Errorf("failed to count templates: %w", err) } @@ -173,18 +202,7 @@ func (s *LLMProviderTemplateService) List(orgUUID string, limit, offset int) (*a } resp.List = make([]api.LLMProviderTemplateListItem, 0, len(items)) for _, t := range items { - id := t.ID - name := t.Name - desc := utils.StringPtrIfNotEmpty(t.Description) - createdBy := utils.StringPtrIfNotEmpty(t.CreatedBy) - resp.List = append(resp.List, api.LLMProviderTemplateListItem{ - Id: &id, - Name: &name, - Description: desc, - CreatedBy: createdBy, - CreatedAt: utils.TimePtr(t.CreatedAt), - UpdatedAt: utils.TimePtr(t.UpdatedAt), - }) + resp.List = append(resp.List, templateListItem(t)) } return resp, nil } @@ -213,12 +231,37 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM if req.Name == "" { return nil, constants.ErrInvalidInput } + if req.ManagedBy != nil && strings.TrimSpace(*req.ManagedBy) == constants.PolicyManagedByWSO2 { + return nil, constants.ErrLLMProviderTemplateManagedByReserved + } + + existing, err := s.repo.GetByID(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to resolve template: %w", err) + } + if existing == nil { + return nil, constants.ErrLLMProviderTemplateNotFound + } + if existing.ManagedBy == "wso2" { + return nil, constants.ErrLLMProviderTemplateReadOnly + } + + managedBy := existing.ManagedBy + if req.ManagedBy != nil { + managedBy = defaultTemplateManagedBy(req.ManagedBy) + } + openapiSpec := existing.OpenAPISpec + if req.Openapi != nil { + openapiSpec = utils.ValueOrEmpty(req.Openapi) + } m := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, ID: handle, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), + ManagedBy: managedBy, + OpenAPISpec: openapiSpec, Metadata: mapTemplateMetadataAPI(req.Metadata), PromptTokens: mapExtractionIdentifierAPI(req.PromptTokens), CompletionTokens: mapExtractionIdentifierAPI(req.CompletionTokens), @@ -240,6 +283,12 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM return nil, fmt.Errorf("failed to update template: %w", err) } + if base, baseErr := s.repo.GetGroupID(handle, orgUUID); baseErr == nil && base != "" { + if err := s.repo.RenameFamily(base, orgUUID, req.Name); err != nil { + return nil, fmt.Errorf("failed to propagate template name: %w", err) + } + } + updated, err := s.repo.GetByID(handle, orgUUID) if err != nil { return nil, fmt.Errorf("failed to fetch updated template: %w", err) @@ -250,10 +299,182 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM return mapTemplateModelToAPI(updated), nil } +var templateVersionPattern = regexp.MustCompile(`^[vV]\d+\.\d+$`) + +func normalizeTemplateVersion(v string) (string, bool) { + v = strings.TrimSpace(v) + if !templateVersionPattern.MatchString(v) { + return "", false + } + return "v" + strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V"), true +} +func makeTemplateHandle(baseHandle, version string) string { + return baseHandle + "-" + strings.ReplaceAll(strings.ToLower(strings.TrimSpace(version)), ".", "-") +} + +func (s *LLMProviderTemplateService) CreateVersion(orgUUID, handle, createdBy string, req *api.CreateLLMProviderTemplateVersionRequest) (*api.LLMProviderTemplate, error) { + if handle == "" || req == nil { + return nil, constants.ErrInvalidInput + } + if req.Name == "" { + return nil, constants.ErrInvalidInput + } + version, ok := normalizeTemplateVersion(req.Version) + if !ok { + return nil, constants.ErrInvalidInput + } + + baseHandle, err := s.repo.GetGroupID(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to resolve template family: %w", err) + } + if baseHandle == "" { + return nil, constants.ErrLLMProviderTemplateNotFound + } + + m := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: makeTemplateHandle(baseHandle, version), + GroupID: baseHandle, + Name: req.Name, + Description: utils.ValueOrEmpty(req.Description), + ManagedBy: constants.PolicyManagedByCustomer, + CreatedBy: createdBy, + Version: version, + OpenAPISpec: utils.ValueOrEmpty(req.Openapi), + Metadata: mapTemplateMetadataAPI(req.Metadata), + PromptTokens: mapExtractionIdentifierAPI(req.PromptTokens), + CompletionTokens: mapExtractionIdentifierAPI(req.CompletionTokens), + TotalTokens: mapExtractionIdentifierAPI(req.TotalTokens), + RemainingTokens: mapExtractionIdentifierAPI(req.RemainingTokens), + RequestModel: mapExtractionIdentifierAPI(req.RequestModel), + ResponseModel: mapExtractionIdentifierAPI(req.ResponseModel), + } + resourceMappings, err := mapTemplateResourceMappingsAPI(req.ResourceMappings) + if err != nil { + return nil, err + } + m.ResourceMappings = resourceMappings + + if err := s.repo.CreateNewVersion(m); err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, constants.ErrLLMProviderTemplateNotFound + case errors.Is(err, constants.ErrLLMProviderTemplateVersionExists): + return nil, constants.ErrLLMProviderTemplateVersionExists + default: + return nil, fmt.Errorf("failed to create new template version: %w", err) + } + } + + return mapTemplateModelToAPI(m), nil +} + +func (s *LLMProviderTemplateService) ListVersions(orgUUID, handle string, limit, offset int) (*api.LLMProviderTemplateListResponse, error) { + if handle == "" { + return nil, constants.ErrInvalidInput + } + total, err := s.repo.CountVersions(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to count template versions: %w", err) + } + if total == 0 { + return nil, constants.ErrLLMProviderTemplateNotFound + } + items, err := s.repo.ListVersions(handle, orgUUID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list template versions: %w", err) + } + resp := &api.LLMProviderTemplateListResponse{ + Count: len(items), + Pagination: api.Pagination{ + Limit: limit, + Offset: offset, + Total: total, + }, + } + resp.List = make([]api.LLMProviderTemplateListItem, 0, len(items)) + for _, t := range items { + resp.List = append(resp.List, templateListItem(t)) + } + return resp, nil +} + +func (s *LLMProviderTemplateService) GetVersion(orgUUID, handle, version string) (*api.LLMProviderTemplate, error) { + v := strings.TrimSpace(version) + if handle == "" || v == "" { + return nil, constants.ErrInvalidInput + } + if normalized, ok := normalizeTemplateVersion(v); ok { + v = normalized + } + m, err := s.repo.GetByVersion(handle, orgUUID, v) + if err != nil { + return nil, fmt.Errorf("failed to get template version: %w", err) + } + if m == nil { + return nil, constants.ErrLLMProviderTemplateNotFound + } + return mapTemplateModelToAPI(m), nil +} + +// SetVersionEnabled enables or disables a specific version of a template. +// Disabling is blocked when any provider was created from this specific version. +func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, handle, version string, enabled bool) (*api.LLMProviderTemplate, error) { + v := strings.TrimSpace(version) + if handle == "" || v == "" { + return nil, constants.ErrInvalidInput + } + if normalized, ok := normalizeTemplateVersion(v); ok { + v = normalized + } + if !enabled { + inUse, err := s.repo.CountProvidersUsingTemplate(handle, orgUUID, v) + if err != nil { + return nil, fmt.Errorf("failed to check template version usage: %w", err) + } + if inUse > 0 { + return nil, constants.ErrLLMProviderTemplateInUse + } + } + if err := s.repo.SetEnabled(handle, orgUUID, v, enabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, constants.ErrLLMProviderTemplateNotFound + } + return nil, fmt.Errorf("failed to set template version enabled: %w", err) + } + m, err := s.repo.GetByVersion(handle, orgUUID, v) + if err != nil { + return nil, fmt.Errorf("failed to reload template version: %w", err) + } + if m == nil { + return nil, constants.ErrLLMProviderTemplateNotFound + } + return mapTemplateModelToAPI(m), nil +} + func (s *LLMProviderTemplateService) Delete(orgUUID, handle string) error { if handle == "" { return constants.ErrInvalidInput } + provider, err := s.repo.ManagedByForHandle(handle, orgUUID) + if err != nil { + return fmt.Errorf("failed to resolve template: %w", err) + } + if provider == "" { + return constants.ErrLLMProviderTemplateNotFound + } + if provider == "wso2" { + return constants.ErrLLMProviderTemplateReadOnly + } + // Block deletion while any provider (built from any version) still depends on it. + inUse, err := s.repo.CountProvidersUsingTemplate(handle, orgUUID, "") + if err != nil { + return fmt.Errorf("failed to check template usage: %w", err) + } + if inUse > 0 { + return constants.ErrLLMProviderTemplateInUse + } if err := s.repo.Delete(handle, orgUUID); err != nil { if errors.Is(err, sql.ErrNoRows) { return constants.ErrLLMProviderTemplateNotFound @@ -263,6 +484,41 @@ func (s *LLMProviderTemplateService) Delete(orgUUID, handle string) error { return nil } +func (s *LLMProviderTemplateService) DeleteVersion(orgUUID, handle, version string) error { + v := strings.TrimSpace(version) + if handle == "" || v == "" { + return constants.ErrInvalidInput + } + if normalized, ok := normalizeTemplateVersion(v); ok { + v = normalized + } + target, err := s.repo.GetByVersion(handle, orgUUID, v) + if err != nil { + return fmt.Errorf("failed to resolve template version: %w", err) + } + if target == nil { + return constants.ErrLLMProviderTemplateNotFound + } + if target.ManagedBy == "wso2" { + return constants.ErrLLMProviderTemplateReadOnly + } + // Block deletion while any provider built from this specific version still depends on it. + inUse, err := s.repo.CountProvidersUsingTemplate(handle, orgUUID, v) + if err != nil { + return fmt.Errorf("failed to check template version usage: %w", err) + } + if inUse > 0 { + return constants.ErrLLMProviderTemplateInUse + } + if err := s.repo.DeleteVersion(handle, orgUUID, v); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return constants.ErrLLMProviderTemplateNotFound + } + return fmt.Errorf("failed to delete template version: %w", err) + } + return nil +} + func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvider) (*api.LLMProvider, error) { if req == nil { return nil, constants.ErrInvalidInput @@ -324,6 +580,14 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi if err := validateLLMResourceLimit(providerCount, constants.MaxLLMProvidersPerOrganization, constants.ErrLLMProviderLimitReached); err != nil { return nil, err } + if !tpl.Enabled { + return nil, constants.ErrInvalidInput + } + + openapiSpec := utils.ValueOrEmpty(req.Openapi) + if openapiSpec == "" { + openapiSpec = tpl.OpenAPISpec + } contextValue := utils.DefaultStringPtr(req.Context, "/") m := &model.LLMProvider{ @@ -334,7 +598,7 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi CreatedBy: createdBy, Version: req.Version, TemplateUUID: tpl.UUID, - OpenAPISpec: utils.ValueOrEmpty(req.Openapi), + OpenAPISpec: openapiSpec, ModelProviders: mapModelProvidersAPI(req.ModelProviders), Status: llmStatusPending, Configuration: model.LLMProviderConfig{ @@ -1508,15 +1772,49 @@ func mapResourceWiseRateLimitingAPIToModel(in *api.ResourceWiseRateLimitingConfi } } +func templateListItem(t *model.LLMProviderTemplate) api.LLMProviderTemplateListItem { + id := t.ID + name := t.Name + version := t.Version + isLatest := t.IsLatest + enabled := t.Enabled + var logoURL string + if t.Metadata != nil { + logoURL = t.Metadata.LogoURL + } + return api.LLMProviderTemplateListItem{ + Id: &id, + Name: &name, + Description: utils.StringPtrIfNotEmpty(t.Description), + ManagedBy: utils.StringPtrIfNotEmpty(t.ManagedBy), + CreatedBy: utils.StringPtrIfNotEmpty(t.CreatedBy), + Version: &version, + IsLatest: &isLatest, + Enabled: &enabled, + LogoUrl: utils.StringPtrIfNotEmpty(logoURL), + CreatedAt: utils.TimePtr(t.CreatedAt), + UpdatedAt: utils.TimePtr(t.UpdatedAt), + } +} + func mapTemplateModelToAPI(m *model.LLMProviderTemplate) *api.LLMProviderTemplate { if m == nil { return nil } + isLatest := m.IsLatest + enabled := m.Enabled return &api.LLMProviderTemplate{ Id: m.ID, + GroupId: utils.StringPtrIfNotEmpty(m.GroupID), Name: m.Name, Description: utils.StringPtrIfNotEmpty(m.Description), + ManagedBy: utils.StringPtrIfNotEmpty(m.ManagedBy), CreatedBy: utils.StringPtrIfNotEmpty(m.CreatedBy), + UpdatedBy: utils.StringPtrIfNotEmpty(m.UpdatedBy), + Version: m.Version, + IsLatest: &isLatest, + Enabled: &enabled, + Openapi: utils.StringPtrIfNotEmpty(m.OpenAPISpec), Metadata: mapTemplateMetadataModelToAPI(m.Metadata), PromptTokens: mapExtractionIdentifierModelToAPI(m.PromptTokens), CompletionTokens: mapExtractionIdentifierModelToAPI(m.CompletionTokens), @@ -1611,6 +1909,17 @@ func mapTemplateResourceMappingModelToAPI(in *model.LLMProviderTemplateResourceM } } +// defaultTemplateManagedBy normalizes the managedBy label supplied on a custom +// template. An empty value defaults to "customer"; built-in templates are seeded +// with "wso2" by the template seeder/loader. +func defaultTemplateManagedBy(in *string) string { + v := strings.TrimSpace(utils.ValueOrEmpty(in)) + if v == "" { + return "customer" + } + return v +} + func mapTemplateMetadataAPI(in *api.LLMProviderTemplateMetadata) *model.LLMProviderTemplateMetadata { if in == nil { return nil diff --git a/platform-api/src/internal/service/llm_provider_template_test.go b/platform-api/src/internal/service/llm_provider_template_test.go new file mode 100644 index 0000000000..b276e8503f --- /dev/null +++ b/platform-api/src/internal/service/llm_provider_template_test.go @@ -0,0 +1,717 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "database/sql" + "strings" + "testing" + + "platform-api/src/api" + "platform-api/src/internal/constants" + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// mockLLMProviderTemplateCRUDRepo is a configurable fake covering the +// LLMProviderTemplateRepository methods exercised by LLMProviderTemplateService. +// Each field models the return value(s) of the corresponding repository call so +// tests can drive every branch in the service without standing up a database. +type mockLLMProviderTemplateCRUDRepo struct { + repository.LLMProviderTemplateRepository + + existsResult bool + existsErr error + createErr error + created *model.LLMProviderTemplate + + managedByForHandleResult string + managedByForHandleErr error + + updateErr error + updated *model.LLMProviderTemplate + + getGroupIDResult string + getGroupIDErr error + + renameFamilyCalled bool + renameFamilyBase string + renameFamilyOrg string + renameFamilyName string + renameFamilyErr error + + getByIDFunc func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) + + createNewVersionErr error + createdVersion *model.LLMProviderTemplate + + countVersionsResult int + countVersionsErr error + listVersionsResult []*model.LLMProviderTemplate + listVersionsErr error + + getByVersionFunc func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) + + setEnabledErr error + setEnabledCalled bool + setEnabledVersion string + setEnabledEnabled bool + + countProvidersUsingTemplateResult int + countProvidersUsingTemplateErr error + countProvidersUsingTemplateCalled bool + countProvidersUsingTemplateVersion string + + deleteErr error + deleteCalled bool + + deleteVersionErr error + deleteVersionCalled bool +} + +func (m *mockLLMProviderTemplateCRUDRepo) Exists(templateID, orgUUID string) (bool, error) { + return m.existsResult, m.existsErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) Create(t *model.LLMProviderTemplate) error { + if m.createErr != nil { + return m.createErr + } + m.created = t + return nil +} + +func (m *mockLLMProviderTemplateCRUDRepo) ManagedByForHandle(handle, orgUUID string) (string, error) { + return m.managedByForHandleResult, m.managedByForHandleErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) Update(t *model.LLMProviderTemplate) error { + if m.updateErr != nil { + return m.updateErr + } + m.updated = t + return nil +} + +func (m *mockLLMProviderTemplateCRUDRepo) GetGroupID(handle, orgUUID string) (string, error) { + return m.getGroupIDResult, m.getGroupIDErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) RenameFamily(baseHandle, orgUUID, name string) error { + m.renameFamilyCalled = true + m.renameFamilyBase = baseHandle + m.renameFamilyOrg = orgUUID + m.renameFamilyName = name + return m.renameFamilyErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) GetByID(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(templateID, orgUUID) + } + return nil, nil +} + +func (m *mockLLMProviderTemplateCRUDRepo) CreateNewVersion(t *model.LLMProviderTemplate) error { + if m.createNewVersionErr != nil { + return m.createNewVersionErr + } + m.createdVersion = t + return nil +} + +func (m *mockLLMProviderTemplateCRUDRepo) CountVersions(templateID, orgUUID string) (int, error) { + return m.countVersionsResult, m.countVersionsErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) ListVersions(templateID, orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { + return m.listVersionsResult, m.listVersionsErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) GetByVersion(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + if m.getByVersionFunc != nil { + return m.getByVersionFunc(templateID, orgUUID, version) + } + return nil, nil +} + +func (m *mockLLMProviderTemplateCRUDRepo) SetEnabled(templateID, orgUUID, version string, enabled bool) error { + m.setEnabledCalled = true + m.setEnabledVersion = version + m.setEnabledEnabled = enabled + return m.setEnabledErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error) { + m.countProvidersUsingTemplateCalled = true + m.countProvidersUsingTemplateVersion = version + return m.countProvidersUsingTemplateResult, m.countProvidersUsingTemplateErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) Delete(templateID, orgUUID string) error { + m.deleteCalled = true + return m.deleteErr +} + +func (m *mockLLMProviderTemplateCRUDRepo) DeleteVersion(templateID, orgUUID, version string) error { + m.deleteVersionCalled = true + return m.deleteVersionErr +} + +func validTemplateRequest(name string) *api.LLMProviderTemplate { + return &api.LLMProviderTemplate{ + Name: name, + Version: "v1.0", + } +} + +// ---- Create ---- + +func TestLLMProviderTemplateServiceCreate_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + resp, err := svc.Create("org-1", "alice", validTemplateRequest("My Custom Provider")) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if resp == nil || !strings.HasPrefix(resp.Id, "my-custom-provider") { + t.Fatalf("expected handle to be derived from name, got: %#v", resp) + } + if repo.created == nil || repo.created.CreatedBy != "alice" { + t.Fatalf("expected repo.Create to be called with createdBy, got: %#v", repo.created) + } + if repo.created.ManagedBy != "customer" { + t.Fatalf("expected default managedBy 'customer', got: %q", repo.created.ManagedBy) + } +} + +func TestLLMProviderTemplateServiceCreate_RejectsEmptyName(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + req := validTemplateRequest("") + _, err := svc.Create("org-1", "alice", req) + if err != constants.ErrInvalidInput { + t.Fatalf("expected ErrInvalidInput, got: %v", err) + } + if repo.created != nil { + t.Fatalf("did not expect repository create to be called") + } +} + +func TestLLMProviderTemplateServiceCreate_RejectsInvalidVersion(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + req := validTemplateRequest("My Provider") + req.Version = "not-a-version" + _, err := svc.Create("org-1", "alice", req) + if err != constants.ErrInvalidInput { + t.Fatalf("expected ErrInvalidInput, got: %v", err) + } + if repo.created != nil { + t.Fatalf("did not expect repository create to be called") + } +} + +func TestLLMProviderTemplateServiceCreate_RejectsReservedManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := validTemplateRequest("My Provider") + req.ManagedBy = &wso2 + _, err := svc.Create("org-1", "alice", req) + if err != constants.ErrLLMProviderTemplateManagedByReserved { + t.Fatalf("expected ErrLLMProviderTemplateManagedByReserved, got: %v", err) + } + if repo.created != nil { + t.Fatalf("did not expect repository create to be called") + } +} + +func TestLLMProviderTemplateServiceCreate_ReturnsConflictForDuplicateHandle(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{existsResult: true} + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.Create("org-1", "alice", validTemplateRequest("My Provider")) + if err != constants.ErrLLMProviderTemplateExists { + t.Fatalf("expected ErrLLMProviderTemplateExists, got: %v", err) + } + if repo.created != nil { + t.Fatalf("did not expect repository create to be called") + } +} + +// ---- Update ---- + +func TestLLMProviderTemplateServiceUpdate_RejectsReservedManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := validTemplateRequest("My Provider") + req.ManagedBy = &wso2 + _, err := svc.Update("org-1", "my-provider", req) + if err != constants.ErrLLMProviderTemplateManagedByReserved { + t.Fatalf("expected ErrLLMProviderTemplateManagedByReserved, got: %v", err) + } + if repo.updated != nil { + t.Fatalf("did not expect repository update to be called") + } +} + +func TestLLMProviderTemplateServiceUpdate_RejectsReadOnlyBuiltin(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + repo.getByIDFunc = func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, OrganizationUUID: orgUUID, ManagedBy: "wso2"}, nil + } + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.Update("org-1", "openai", validTemplateRequest("OpenAI")) + if err != constants.ErrLLMProviderTemplateReadOnly { + t.Fatalf("expected ErrLLMProviderTemplateReadOnly, got: %v", err) + } + if repo.updated != nil { + t.Fatalf("did not expect repository update to be called") + } +} + +func TestLLMProviderTemplateServiceUpdate_ReturnsNotFoundWhenTemplateMissing(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.Update("org-1", "does-not-exist", validTemplateRequest("Name")) + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceUpdate_PreservesOpenAPISpecWhenOmitted(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + repo.getByIDFunc = func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ + ID: templateID, + OrganizationUUID: orgUUID, + ManagedBy: "customer", + OpenAPISpec: "openapi: 3.0.0", + Version: "v1.0", + }, nil + } + svc := NewLLMProviderTemplateService(repo) + + req := validTemplateRequest("Renamed") + req.Openapi = nil + req.ManagedBy = nil + if _, err := svc.Update("org-1", "mistralai", req); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if repo.updated == nil || repo.updated.OpenAPISpec != "openapi: 3.0.0" { + t.Fatalf("expected existing OpenAPI spec to be preserved, got: %#v", repo.updated) + } + if repo.updated.ManagedBy != "customer" { + t.Fatalf("expected existing managedBy to be preserved, got: %q", repo.updated.ManagedBy) + } +} + +func TestLLMProviderTemplateServiceUpdate_PropagatesNameToFamily(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + managedByForHandleResult: "customer", + getGroupIDResult: "mistralai", + } + repo.getByIDFunc = func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, OrganizationUUID: orgUUID, Name: "Mistral Updated", Version: "v1.0"}, nil + } + svc := NewLLMProviderTemplateService(repo) + + req := validTemplateRequest("Mistral Updated") + resp, err := svc.Update("org-1", "mistralai", req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !repo.renameFamilyCalled || repo.renameFamilyBase != "mistralai" || repo.renameFamilyName != "Mistral Updated" { + t.Fatalf("expected RenameFamily to be called with the family base handle and new name, got called=%v base=%q name=%q", + repo.renameFamilyCalled, repo.renameFamilyBase, repo.renameFamilyName) + } + if resp == nil || resp.Name != "Mistral Updated" { + t.Fatalf("expected updated template to be returned, got: %#v", resp) + } +} + +func TestLLMProviderTemplateServiceUpdate_RejectsMismatchedID(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{managedByForHandleResult: "customer"} + svc := NewLLMProviderTemplateService(repo) + + req := validTemplateRequest("Name") + req.Id = "some-other-handle" + _, err := svc.Update("org-1", "mistralai", req) + if err != constants.ErrInvalidInput { + t.Fatalf("expected ErrInvalidInput, got: %v", err) + } +} + +// ---- CreateVersion ---- + +func TestLLMProviderTemplateServiceCreateVersion_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{getGroupIDResult: "mistralai"} + svc := NewLLMProviderTemplateService(repo) + + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "v2.0"} + resp, err := svc.CreateVersion("org-1", "mistralai", "test-user", req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if resp == nil || resp.Version != "v2.0" { + t.Fatalf("expected new version v2.0, got: %#v", resp) + } + if repo.createdVersion == nil || repo.createdVersion.GroupID != "mistralai" { + t.Fatalf("expected created version to carry the family base handle, got: %#v", repo.createdVersion) + } + if repo.createdVersion.ManagedBy != "customer" { + t.Fatalf("expected new custom versions to default to managedBy 'customer', got: %q", repo.createdVersion.ManagedBy) + } +} + +func TestLLMProviderTemplateServiceCreateVersion_ForkFromBuiltinSetsCustomerManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{getGroupIDResult: "mistralai"} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "v2.0", ManagedBy: &wso2} + resp, err := svc.CreateVersion("org-1", "mistralai", "test-user", req) + if err != nil { + t.Fatalf("expected no error when forking a built-in, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.createdVersion == nil { + t.Fatal("expected CreateNewVersion to be called") + } + if repo.createdVersion.ManagedBy != constants.PolicyManagedByCustomer { + t.Fatalf("expected forked version to have managedBy='customer', got: %q", repo.createdVersion.ManagedBy) + } +} + +func TestLLMProviderTemplateServiceCreateVersion_NotFoundWhenFamilyMissing(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{getGroupIDResult: ""} + svc := NewLLMProviderTemplateService(repo) + + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "v2.0"} + _, err := svc.CreateVersion("org-1", "does-not-exist", "test-user", req) + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceCreateVersion_ConflictWhenVersionExists(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getGroupIDResult: "mistralai", + createNewVersionErr: constants.ErrLLMProviderTemplateVersionExists, + } + svc := NewLLMProviderTemplateService(repo) + + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "v1.0"} + _, err := svc.CreateVersion("org-1", "mistralai", "test-user", req) + if err != constants.ErrLLMProviderTemplateVersionExists { + t.Fatalf("expected ErrLLMProviderTemplateVersionExists, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceCreateVersion_RejectsInvalidVersionFormat(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{getGroupIDResult: "mistralai"} + svc := NewLLMProviderTemplateService(repo) + + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "2.0"} + _, err := svc.CreateVersion("org-1", "mistralai", "test-user", req) + if err != constants.ErrInvalidInput { + t.Fatalf("expected ErrInvalidInput, got: %v", err) + } + if repo.createdVersion != nil { + t.Fatalf("did not expect CreateNewVersion to be called") + } +} + +// ---- ListVersions / GetVersion ---- + +func TestLLMProviderTemplateServiceListVersions_NotFoundWhenNoVersions(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{countVersionsResult: 0} + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.ListVersions("org-1", "does-not-exist", 10, 0) + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceListVersions_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + countVersionsResult: 2, + listVersionsResult: []*model.LLMProviderTemplate{ + {ID: "mistralai", Version: "v1.0"}, + {ID: "mistralai-v2-0", Version: "v2.0"}, + }, + } + svc := NewLLMProviderTemplateService(repo) + + resp, err := svc.ListVersions("org-1", "mistralai", 10, 0) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if resp == nil || resp.Count != 2 || resp.Pagination.Total != 2 { + t.Fatalf("expected two versions in response, got: %#v", resp) + } +} + +func TestLLMProviderTemplateServiceGetVersion_NotFound(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return nil, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.GetVersion("org-1", "mistralai", "v9.0") + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceGetVersion_NormalizesVersionCasing(t *testing.T) { + var receivedVersion string + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + receivedVersion = version + return &model.LLMProviderTemplate{ID: templateID, Version: version}, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + resp, err := svc.GetVersion("org-1", "mistralai", "V2.0") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if receivedVersion != "v2.0" { + t.Fatalf("expected version to be normalized to v2.0, got: %q", receivedVersion) + } + if resp == nil || resp.Version != "v2.0" { + t.Fatalf("expected response to carry normalized version, got: %#v", resp) + } +} + +// ---- SetVersionEnabled ---- + +func TestLLMProviderTemplateServiceSetVersionEnabled_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, Version: version, Enabled: false}, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + resp, err := svc.SetVersionEnabled("org-1", "mistralai", "v1.0", false) + if err != nil { + t.Fatalf("expected no error, 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_NotFound(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{setEnabledErr: sql.ErrNoRows} + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.SetVersionEnabled("org-1", "does-not-exist", "v1.0", true) + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceSetVersionEnabled_DisableBlocksWhenInUse(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + countProvidersUsingTemplateResult: 1, + } + svc := NewLLMProviderTemplateService(repo) + + _, err := svc.SetVersionEnabled("org-1", "mistralai", "v1.0", false) + if err != constants.ErrLLMProviderTemplateInUse { + t.Fatalf("expected ErrLLMProviderTemplateInUse, got: %v", err) + } + if repo.countProvidersUsingTemplateVersion != "v1.0" { + t.Fatalf("expected usage check scoped to the specific version, got: %q", repo.countProvidersUsingTemplateVersion) + } + if repo.setEnabledCalled { + t.Fatalf("did not expect SetEnabled to be called while version is in use") + } +} + +func TestLLMProviderTemplateServiceSetVersionEnabled_EnableIgnoresUsage(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + countProvidersUsingTemplateResult: 5, + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, Version: version, Enabled: true}, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + resp, err := svc.SetVersionEnabled("org-1", "mistralai", "v1.0", true) + if err != nil { + t.Fatalf("expected no error on enable regardless of usage, got: %v", err) + } + if repo.countProvidersUsingTemplateCalled { + t.Fatalf("did not expect usage check to be called when enabling") + } + if resp == nil || resp.Enabled == nil || !*resp.Enabled { + t.Fatalf("expected response to reflect enabled state, got: %#v", resp) + } +} + +// ---- Delete (whole template family) ---- + +func TestLLMProviderTemplateServiceDelete_BlocksReadOnlyBuiltin(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{managedByForHandleResult: "wso2"} + svc := NewLLMProviderTemplateService(repo) + + err := svc.Delete("org-1", "openai") + if err != constants.ErrLLMProviderTemplateReadOnly { + t.Fatalf("expected ErrLLMProviderTemplateReadOnly, got: %v", err) + } + if repo.deleteCalled || repo.countProvidersUsingTemplateCalled { + t.Fatalf("did not expect usage check or delete to be called for a read-only template") + } +} + +func TestLLMProviderTemplateServiceDelete_NotFound(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{managedByForHandleResult: ""} + svc := NewLLMProviderTemplateService(repo) + + err := svc.Delete("org-1", "does-not-exist") + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceDelete_BlocksWhenInUse(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + managedByForHandleResult: "customer", + countProvidersUsingTemplateResult: 2, + } + svc := NewLLMProviderTemplateService(repo) + + err := svc.Delete("org-1", "mistralai") + if err != constants.ErrLLMProviderTemplateInUse { + t.Fatalf("expected ErrLLMProviderTemplateInUse, got: %v", err) + } + if repo.countProvidersUsingTemplateVersion != "" { + t.Fatalf("expected usage check to span the whole family (empty version), got: %q", repo.countProvidersUsingTemplateVersion) + } + if repo.deleteCalled { + t.Fatalf("did not expect repository delete to be called while template is in use") + } +} + +func TestLLMProviderTemplateServiceDelete_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + managedByForHandleResult: "customer", + countProvidersUsingTemplateResult: 0, + } + svc := NewLLMProviderTemplateService(repo) + + if err := svc.Delete("org-1", "mistralai"); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !repo.deleteCalled { + t.Fatalf("expected repository delete to be called") + } +} + +// ---- DeleteVersion ---- + +func TestLLMProviderTemplateServiceDeleteVersion_NotFoundWhenVersionMissing(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return nil, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + err := svc.DeleteVersion("org-1", "mistralai", "v9.0") + if err != constants.ErrLLMProviderTemplateNotFound { + t.Fatalf("expected ErrLLMProviderTemplateNotFound, got: %v", err) + } +} + +func TestLLMProviderTemplateServiceDeleteVersion_BlocksReadOnlyBuiltin(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "wso2"}, nil + }, + } + svc := NewLLMProviderTemplateService(repo) + + err := svc.DeleteVersion("org-1", "openai", "v1.0") + if err != constants.ErrLLMProviderTemplateReadOnly { + t.Fatalf("expected ErrLLMProviderTemplateReadOnly, got: %v", err) + } + if repo.deleteVersionCalled { + t.Fatalf("did not expect repository delete to be called for a read-only template version") + } +} + +func TestLLMProviderTemplateServiceDeleteVersion_BlocksWhenInUse(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "customer"}, nil + }, + countProvidersUsingTemplateResult: 1, + } + svc := NewLLMProviderTemplateService(repo) + + err := svc.DeleteVersion("org-1", "mistralai-v2-0", "v2.0") + if err != constants.ErrLLMProviderTemplateInUse { + t.Fatalf("expected ErrLLMProviderTemplateInUse, got: %v", err) + } + if repo.countProvidersUsingTemplateVersion != "v2.0" { + t.Fatalf("expected usage check to be scoped to the specific version, got: %q", repo.countProvidersUsingTemplateVersion) + } + if repo.deleteVersionCalled { + t.Fatalf("did not expect repository delete to be called while version is in use") + } +} + +func TestLLMProviderTemplateServiceDeleteVersion_Success(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{ + getByVersionFunc: func(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + return &model.LLMProviderTemplate{ID: templateID, Version: version, ManagedBy: "customer"}, nil + }, + countProvidersUsingTemplateResult: 0, + } + svc := NewLLMProviderTemplateService(repo) + + if err := svc.DeleteVersion("org-1", "mistralai-v2-0", "v2.0"); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !repo.deleteVersionCalled { + t.Fatalf("expected repository DeleteVersion to be called") + } +} diff --git a/platform-api/src/internal/service/llm_template_seeder.go b/platform-api/src/internal/service/llm_template_seeder.go index 8ab76048a7..c4e1628975 100644 --- a/platform-api/src/internal/service/llm_template_seeder.go +++ b/platform-api/src/internal/service/llm_template_seeder.go @@ -77,6 +77,7 @@ func (s *LLMTemplateSeeder) SeedForOrg(orgUUID string) error { if current != nil { current.Name = tpl.Name current.Description = tpl.Description + current.ManagedBy = tpl.ManagedBy current.Metadata = tpl.Metadata current.PromptTokens = tpl.PromptTokens current.CompletionTokens = tpl.CompletionTokens @@ -96,8 +97,11 @@ func (s *LLMTemplateSeeder) SeedForOrg(orgUUID string) error { toCreate := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, ID: tpl.ID, + GroupID: tpl.GroupID, + Version: tpl.Version, Name: tpl.Name, Description: tpl.Description, + ManagedBy: tpl.ManagedBy, CreatedBy: tpl.CreatedBy, Metadata: tpl.Metadata, PromptTokens: tpl.PromptTokens, diff --git a/platform-api/src/internal/service/llm_test.go b/platform-api/src/internal/service/llm_test.go index 427a72348e..3490017b5d 100644 --- a/platform-api/src/internal/service/llm_test.go +++ b/platform-api/src/internal/service/llm_test.go @@ -1126,7 +1126,7 @@ func TestLLMProviderServiceCreateAllowsAggregatorTemplate(t *testing.T) { } templateRepo := &mockLLMTemplateRepo{ getByIDFunc: func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { - return &model.LLMProviderTemplate{UUID: "tpl-agg", ID: "awsbedrock", CreatedAt: now, UpdatedAt: now}, nil + return &model.LLMProviderTemplate{UUID: "tpl-agg", ID: "awsbedrock", Enabled: true, CreatedAt: now, UpdatedAt: now}, nil }, } orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1"}} @@ -1169,7 +1169,7 @@ func TestLLMProviderServiceCreateMigratesLegacyPolicies(t *testing.T) { } templateRepo := &mockLLMTemplateRepo{ getByIDFunc: func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { - return &model.LLMProviderTemplate{UUID: "tpl-openai", ID: "openai", CreatedAt: now, UpdatedAt: now}, nil + return &model.LLMProviderTemplate{UUID: "tpl-openai", ID: "openai", Enabled: true, CreatedAt: now, UpdatedAt: now}, nil }, } orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1"}} diff --git a/platform-api/src/internal/utils/llm_provider_template_loader.go b/platform-api/src/internal/utils/llm_provider_template_loader.go index 6fce454f4c..63f5b4cd05 100644 --- a/platform-api/src/internal/utils/llm_provider_template_loader.go +++ b/platform-api/src/internal/utils/llm_provider_template_loader.go @@ -54,6 +54,8 @@ type llmProviderTemplateYAML struct { } `yaml:"metadata"` Spec struct { DisplayName string `yaml:"displayName"` + ManagedBy string `yaml:"managedBy"` + Version string `yaml:"version"` Metadata *llmProviderTemplateMetadataYAML `yaml:"metadata"` PromptTokens *extractionIdentifierYAML `yaml:"promptTokens"` CompletionTokens *extractionIdentifierYAML `yaml:"completionTokens"` @@ -118,9 +120,24 @@ func LoadLLMProviderTemplatesFromDirectory(dirPath string) ([]*model.LLMProvider return nil, fmt.Errorf("template file %s is missing spec.displayName", filePath) } + managedBy := strings.TrimSpace(doc.Spec.ManagedBy) + if managedBy == "" { + managedBy = "wso2" + } + + seedVersion := strings.TrimSpace(doc.Spec.Version) + if seedVersion == "" { + seedVersion = "v1.0" + } + baseHandle := strings.TrimSpace(doc.Metadata.Name) + handle := baseHandle + "-" + strings.ReplaceAll(strings.ToLower(seedVersion), ".", "-") + res = append(res, &model.LLMProviderTemplate{ - ID: doc.Metadata.Name, - Name: doc.Spec.DisplayName, + ID: handle, + GroupID: baseHandle, + Version: seedVersion, + Name: doc.Spec.DisplayName, + ManagedBy: managedBy, Metadata: mapTemplateMetadata(doc.Spec.Metadata), PromptTokens: mapExtractionIdentifier(doc.Spec.PromptTokens), CompletionTokens: mapExtractionIdentifier(doc.Spec.CompletionTokens), diff --git a/platform-api/src/resources/default-llm-provider-templates/anthropic-template.yaml b/platform-api/src/resources/default-llm-provider-templates/anthropic-template.yaml index 7e55a54eb6..a9d343b20e 100644 --- a/platform-api/src/resources/default-llm-provider-templates/anthropic-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/anthropic-template.yaml @@ -22,6 +22,8 @@ metadata: name: anthropic spec: displayName: Anthropic + managedBy: wso2 + version: v1.0 metadata: endpointUrl: https://api.anthropic.com auth: diff --git a/platform-api/src/resources/default-llm-provider-templates/awsbedrock-template.yaml b/platform-api/src/resources/default-llm-provider-templates/awsbedrock-template.yaml index 3a543089e9..1b35470d5d 100644 --- a/platform-api/src/resources/default-llm-provider-templates/awsbedrock-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/awsbedrock-template.yaml @@ -22,6 +22,8 @@ metadata: name: awsbedrock spec: displayName: AWS Bedrock + managedBy: wso2 + version: v1.0 metadata: logoUrl: https://raw.githubusercontent.com/nomadxd/openapi-connectors/main/openapi/aws.bedrock/icon.png openapiSpecUrl: https://raw.githubusercontent.com/nomadxd/openapi-connectors/main/openapi/aws.bedrock/openapi.yaml diff --git a/platform-api/src/resources/default-llm-provider-templates/azureaifoundry-template.yaml b/platform-api/src/resources/default-llm-provider-templates/azureaifoundry-template.yaml index 691ada955e..f9bafab848 100644 --- a/platform-api/src/resources/default-llm-provider-templates/azureaifoundry-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/azureaifoundry-template.yaml @@ -22,6 +22,8 @@ metadata: name: azureai-foundry spec: displayName: Azure AI Foundry + managedBy: wso2 + version: v1.0 metadata: auth: type: apiKey diff --git a/platform-api/src/resources/default-llm-provider-templates/azureopenai-template.yaml b/platform-api/src/resources/default-llm-provider-templates/azureopenai-template.yaml index 7b88cc487b..e4e8b8854f 100644 --- a/platform-api/src/resources/default-llm-provider-templates/azureopenai-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/azureopenai-template.yaml @@ -22,6 +22,8 @@ metadata: name: azure-openai spec: displayName: Azure OpenAI + managedBy: wso2 + version: v1.0 metadata: auth: type: apiKey diff --git a/platform-api/src/resources/default-llm-provider-templates/gemini-template.yaml b/platform-api/src/resources/default-llm-provider-templates/gemini-template.yaml index 0a4ee45fac..77d6a0632d 100644 --- a/platform-api/src/resources/default-llm-provider-templates/gemini-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/gemini-template.yaml @@ -22,6 +22,8 @@ metadata: name: gemini spec: displayName: Gemini + managedBy: wso2 + version: v1.0 metadata: endpointUrl: https://generativelanguage.googleapis.com auth: diff --git a/platform-api/src/resources/default-llm-provider-templates/mistral-template.yaml b/platform-api/src/resources/default-llm-provider-templates/mistral-template.yaml index a6ec4d3d37..0a283d95a4 100644 --- a/platform-api/src/resources/default-llm-provider-templates/mistral-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/mistral-template.yaml @@ -22,6 +22,8 @@ metadata: name: mistralai spec: displayName: Mistral + managedBy: wso2 + version: v1.0 metadata: endpointUrl: https://api.mistral.ai auth: diff --git a/platform-api/src/resources/default-llm-provider-templates/openai-template.yaml b/platform-api/src/resources/default-llm-provider-templates/openai-template.yaml index 41a15ea34a..5880e7cccc 100644 --- a/platform-api/src/resources/default-llm-provider-templates/openai-template.yaml +++ b/platform-api/src/resources/default-llm-provider-templates/openai-template.yaml @@ -22,6 +22,8 @@ metadata: name: openai spec: displayName: OpenAI + managedBy: wso2 + version: v1.0 metadata: endpointUrl: https://api.openai.com/v1 auth: diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index f1f96fd212..c8a3adc47b 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -2705,6 +2705,16 @@ paths: type: integer minimum: 0 default: 0 + - name: versions + in: query + description: | + Which versions to include. The default ("latest") returns one entry + per template — its latest version, for the catalog. Use "all" to + return every version row across all templates. + schema: + type: string + enum: [latest, all] + default: latest responses: '200': description: List of LLM provider templates @@ -2752,7 +2762,10 @@ paths: put: summary: Update an existing LLM provider template - description: Update an existing LLM provider template configuration. + description: | + Update the latest version of a template in place (name, description, and + other configuration). This does NOT create a new version — use + POST /llm-provider-templates/{id}/versions to create a new version. operationId: updateLLMProviderTemplate security: - OAuth2Security: @@ -2821,6 +2834,237 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-provider-templates/{id}/versions: + get: + summary: List versions of an LLM provider template + description: List all immutable versions of a template, newest first. + operationId: listLLMProviderTemplateVersions + security: + - OAuth2Security: + - ap:llm_template:read + - ap:llm_template:manage + tags: + - LLM Provider Templates + parameters: + - name: id + in: path + required: true + description: Unique identifier for the LLM provider template + schema: + type: string + example: openai + - name: limit + in: query + description: Maximum number of versions to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: offset + in: query + description: Number of versions to skip + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: List of template versions + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProviderTemplateListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + post: + summary: Create a new version of an LLM provider template + description: | + Create a new version of an existing template. The version (e.g. v2.0) + must be supplied in the body, match the v. pattern, and be + unique for the template. The new version becomes the latest. + + The template family is identified by the path parameter; the new version's + handle is computed server-side from the family handle and the supplied version. + operationId: createLLMProviderTemplateVersion + security: + - OAuth2Security: + - ap:llm_template:create + - ap:llm_template:manage + tags: + - LLM Provider Templates + parameters: + - name: id + in: path + required: true + description: Unique identifier for the LLM provider template + schema: + type: string + example: openai + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateLLMProviderTemplateVersionRequest' + responses: + '201': + description: New version created + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProviderTemplate' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-provider-templates/{id}/versions/{version}: + get: + summary: Get a specific version of an LLM provider template + description: Retrieve the complete configuration for a specific immutable version. + operationId: getLLMProviderTemplateVersion + security: + - OAuth2Security: + - ap:llm_template:read + - ap:llm_template:manage + tags: + - LLM Provider Templates + parameters: + - name: id + in: path + required: true + description: Unique identifier for the LLM provider template + schema: + type: string + example: openai + - name: version + in: path + required: true + description: Version to retrieve (e.g. v2.0). Matched case-insensitively. + schema: + type: string + example: v2.0 + responses: + '200': + description: LLM provider template version details + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProviderTemplate' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + patch: + summary: Enable or disable a specific version of an LLM provider template + description: | + Toggle whether a version is offered when creating providers. Disabled + versions stay in the catalog but are hidden from the provider picker. + operationId: setLLMProviderTemplateVersionEnabled + security: + - OAuth2Security: + - ap:llm_template:manage + tags: + - LLM Provider Templates + parameters: + - name: id + in: path + required: true + description: Unique identifier for the LLM provider template + schema: + type: string + example: openai + - name: version + in: path + required: true + description: Version to toggle (e.g. v1.0). Matched case-insensitively. + schema: + type: string + example: v1.0 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - enabled + properties: + enabled: + type: boolean + example: false + responses: + '200': + description: Updated template version + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProviderTemplate' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete a specific version of an LLM provider template + description: | + Delete a single version. If it was the only version the template is + removed; otherwise the newest remaining version becomes the latest. + operationId: deleteLLMProviderTemplateVersion + security: + - OAuth2Security: + - ap:llm_template:delete + - ap:llm_template:manage + tags: + - LLM Provider Templates + parameters: + - name: id + in: path + required: true + description: Unique identifier for the LLM provider template + schema: + type: string + example: openai + - name: version + in: path + required: true + description: Version to delete (e.g. v2.0). Matched case-insensitively. + schema: + type: string + example: v2.0 + responses: + '204': + description: Version deleted + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -9831,17 +10075,37 @@ components: required: - id - name + - version properties: id: type: string description: Unique handle for the template + maxLength: 40 + example: openai + groupId: + type: string + description: | + Stable identifier shared by every version of a template family + (defaults to the first version's handle). Read-only. + readOnly: true + maxLength: 40 example: openai name: type: string description: Human-readable LLM Template name minLength: 1 - maxLength: 253 + maxLength: 255 example: OpenAI + managedBy: + type: string + description: | + Identifies who manages the template. Built-in templates use 'wso2'; + custom templates default to 'customer' and may be set to any value. + Optional on create/update — the server defaults it to 'customer' when + omitted, so a request/YAML without a managedBy is accepted. + default: customer + maxLength: 255 + example: wso2 description: type: string description: Description of the LLM provider template @@ -9851,7 +10115,50 @@ components: type: string description: Username of the creator readOnly: true + maxLength: 200 example: jane.doe + updatedBy: + type: string + description: Username of the last user to update the template + readOnly: true + maxLength: 200 + example: jane.doe + version: + type: string + description: | + Content version, e.g. v1.0. Required. A new template starts at v1.0; + editing updates that version in place. Supply a new unique version + only when creating a new version via + POST /llm-provider-templates/{id}/versions. + pattern: '^[vV]\d+\.\d+$' + maxLength: 30 + example: v1.0 + isLatest: + type: boolean + description: Whether this is the latest version of the template. + readOnly: true + example: true + enabled: + type: boolean + description: | + Whether this version is offered when creating providers. Disabled + versions stay in the catalog but are hidden from the provider picker. + Response-only: create/update default new versions to enabled; toggle + via PATCH /llm-provider-templates/{id}/versions/{version}. + readOnly: true + example: true + openapi: + type: string + description: | + OpenAPI specification content (JSON or YAML) for the provider, when + uploaded/pasted. Use metadata.openapiSpecUrl instead to reference the + spec by URL. + example: | + openapi: 3.0.3 + info: + title: Provider API + version: v1.0 + paths: {} metadata: $ref: '#/components/schemas/LLMProviderTemplateMetadata' promptTokens: @@ -9881,21 +10188,103 @@ components: readOnly: true example: "2023-10-12T10:30:00Z" + CreateLLMProviderTemplateVersionRequest: + type: object + required: + - name + - version + properties: + name: + type: string + description: Human-readable LLM Template name + minLength: 1 + maxLength: 255 + example: OpenAI + version: + type: string + description: New version identifier, e.g. v2.0. Must be unique for this template. + pattern: '^[vV]\d+\.\d+$' + maxLength: 30 + example: v2.0 + managedBy: + type: string + description: | + Identifies who manages the template. Custom templates default to 'customer'. + default: customer + maxLength: 255 + example: customer + description: + type: string + description: Description of the LLM provider template + maxLength: 1023 + example: Default OpenAI template + openapi: + type: string + description: | + OpenAPI specification content (JSON or YAML) for the provider, when + uploaded/pasted. Use metadata.openapiSpecUrl instead to reference the + spec by URL. + metadata: + $ref: '#/components/schemas/LLMProviderTemplateMetadata' + promptTokens: + $ref: '#/components/schemas/ExtractionIdentifier' + completionTokens: + $ref: '#/components/schemas/ExtractionIdentifier' + totalTokens: + $ref: '#/components/schemas/ExtractionIdentifier' + remainingTokens: + $ref: '#/components/schemas/ExtractionIdentifier' + requestModel: + $ref: '#/components/schemas/ExtractionIdentifier' + responseModel: + $ref: '#/components/schemas/ExtractionIdentifier' + resourceMappings: + $ref: '#/components/schemas/LLMProviderTemplateResourceMappings' + LLMProviderTemplateListItem: type: object properties: id: type: string example: openai + groupId: + type: string + description: | + Stable identifier shared by every version of a template family + (defaults to the first version's handle). Read-only. + readOnly: true + maxLength: 40 + example: openai name: type: string example: OpenAI + managedBy: + type: string + description: Who manages the template ('wso2' for built-in, otherwise custom-defined). + example: wso2 description: type: string example: Default OpenAI template createdBy: type: string example: jane.doe + version: + type: string + description: Content version, matching the v. pattern (e.g. v1.0, v2.0). + example: v1.0 + isLatest: + type: boolean + description: Whether this is the latest version of the template. + example: true + enabled: + type: boolean + description: Whether this version is offered when creating providers. + example: true + logoUrl: + type: string + format: uri + description: URL of the provider logo + example: https://cdn.example.com/logos/openai.svg createdAt: type: string format: date-time diff --git a/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js index c0da568244..7e7fe40f51 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js @@ -31,6 +31,10 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { let authToken = ''; let organizationId = ''; + before(() => { + cy.sweepE2EProviders(); + }); + beforeEach(() => { cy.login(); cy.request({ @@ -62,13 +66,13 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { }); afterEach(() => { - if (!authToken || !organizationId) { - return; - } + const targeted = (authToken && organizationId) + ? deleteLinkedProxies(authToken, organizationId, createdProviderId) + .then(() => deleteProjectByName(authToken, projectName, cleanupProjectName)) + .then(() => deleteProvider(authToken, organizationId, createdProviderId)) + : cy.wrap(null); - return deleteLinkedProxies(authToken, organizationId, createdProviderId) - .then(() => deleteProjectByName(authToken, projectName, cleanupProjectName)) - .then(() => deleteProvider(authToken, organizationId, createdProviderId)); + return targeted.then(() => cy.sweepE2EProviders(authToken, organizationId)); }); it('creates and deletes an OpenAI provider and app llm proxy using only the UI', () => { @@ -102,10 +106,22 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { .should('be.visible') .click(); - cy.get('[data-cyid="provider-template-openai-card"]', { + cy.get('[data-cyid^="provider-template-openai"]', { timeout: 30000, }).should('be.visible').click(); + cy.get('body', { timeout: 30000 }).should(($body) => { + expect( + $body.find('[data-cyid="provider-name-input"] input:visible').length > 0 || + $body.find('[data-cyid="template-version-continue-button"]').length > 0 + ).to.eq(true); + }); + cy.get('body').then(($body) => { + if ($body.find('[data-cyid="template-version-continue-button"]').length) { + selectTemplateVersionAndContinue(); + } + }); + cy.get('[data-cyid="provider-name-input"] input:visible') .should('be.visible') .clear() @@ -184,6 +200,9 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { }); function deleteLinkedProxies(authToken, organizationId, providerId) { + if (!providerId) { + return cy.wrap(null); + } return requestWithAuth(authToken, { url: `/api-proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(organizationId)}`, failOnStatusCode: false, @@ -261,6 +280,9 @@ function deleteProject(authToken, projectId) { } function deleteProvider(authToken, organizationId, providerId) { + if (!providerId) { + return cy.wrap(null); + } return requestWithAuth(authToken, { method: 'DELETE', url: `/api-proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, @@ -289,3 +311,14 @@ function toSlug(value) { .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } + +function selectTemplateVersionAndContinue() { + // Wait for the dialog's own version fetch to resolve and explicitly pick a + // version rather than relying on its auto-selected default. + cy.get('[data-cyid^="template-version-option-"]', { timeout: 30000 }) + .first() + .click(); + cy.get('[data-cyid="template-version-continue-button"]', { timeout: 30000 }) + .should('not.be.disabled') + .click(); +} diff --git a/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js b/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js new file mode 100644 index 0000000000..1510304702 --- /dev/null +++ b/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +describe('AI Workspace - Custom LLM provider template lifecycle', () => { + const suffix = Date.now().toString().slice(-8); + const orgHandle = Cypress.env('ORG_HANDLE'); + const templateName = `E2E Custom Template ${suffix}`; + const templateV1Id = toTemplateId(`${templateName} v1.0`); + const templateV2Id = toTemplateId(`${templateName} v2.0`); + const providerName = `E2E Custom Template Provider ${suffix}`; + const providerId = toSlug(providerName); + let createdProviderId = ''; + let authToken = ''; + let organizationId = ''; + + before(() => { + cy.sweepE2EProviders(); + }); + + beforeEach(() => { + cy.login(); + cy.request({ + method: 'POST', + url: '/api-proxy/api/portal/v1/auth/login', + form: true, + body: { + username: Cypress.env('ADMIN_USER'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + }) + .then((response) => { + expect(response.status).to.eq(200); + authToken = response.body?.token ?? ''; + expect(authToken).to.not.equal(''); + + return cy.request({ + url: '/api-proxy/api/v0.9/organizations', + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + }) + .then((response) => { + expect(response.status).to.eq(200); + organizationId = response.body?.id ?? ''; + expect(organizationId).to.not.equal(''); + }); + }); + + afterEach(() => { + const targetProviderId = createdProviderId || providerId; + const targeted = (authToken && organizationId) + ? deleteProvider(authToken, organizationId, targetProviderId) + .then(() => waitForProviderGone(authToken, organizationId, targetProviderId)) + .then(() => deleteProviderTemplate(authToken, organizationId, templateV2Id)) + .then(() => deleteProviderTemplate(authToken, organizationId, templateV1Id)) + : cy.wrap(null); + + return targeted.then(() => cy.sweepE2EProviders(authToken, organizationId)); + }); + + it('creates a custom template, versions it, uses it for a provider, and blocks deletion while in use', () => { + // --- Create the custom template (v1.0) --------------------------------- + cy.contains('Settings', { timeout: 30000 }).should('be.visible').click(); + cy.contains('LLM Provider Templates', { timeout: 30000 }).should('be.visible'); + + cy.get('[data-cyid="add-provider-template-button"]', { timeout: 30000 }) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('input[placeholder="Enter template name"]', { timeout: 30000 }) + .should('be.visible') + .type(templateName); + cy.get('input[placeholder="https://api.openai.com"]').type( + 'https://api.e2e-custom-template.example.com' + ); + cy.get('[data-cyid="create-provider-template-submit"]') + .should('not.be.disabled') + .click(); + + cy.contains('LLM Provider Templates', { timeout: 30000 }).should('be.visible'); + cy.get(`[data-cyid="provider-template-card-${templateV1Id}"]`, { + timeout: 30000, + }) + .should('be.visible') + .click(); + cy.contains(templateName, { timeout: 30000 }).should('be.visible'); + cy.contains('button', 'v1.0', { timeout: 30000 }).should('be.visible'); + + // --- Create a second version (v2.0) of the same template family -------- + cy.contains('button', 'v1.0').click(); + cy.contains('Create new version', { timeout: 30000 }).click(); + + cy.get('input[placeholder="v2.0"]', { timeout: 30000 }) + .should('be.visible') + .clear() + .type('v2.0'); + cy.get('input[placeholder="https://api.openai.com"]').type( + 'https://api.e2e-custom-template.example.com' + ); + cy.get('[data-cyid="create-provider-template-version-submit"]') + .should('not.be.disabled') + .click(); + + cy.contains(templateName, { timeout: 30000 }).should('be.visible'); + cy.contains('button', 'v2.0', { timeout: 30000 }).should('be.visible'); + + // --- Use the template (now with 2 enabled versions) to create a provider --- + cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('[data-cyid="add-new-provider-button"]', { timeout: 30000 }) + .should('be.visible') + .click(); + + cy.get(`[data-cyid="provider-template-${templateV2Id}-card"]`, { + timeout: 30000, + }) + .should('be.visible') + .click(); + + cy.get('[data-cyid="template-version-continue-button"]', { + timeout: 45000, + }).should('be.visible'); + cy.get('[data-cyid="template-version-option-v2.0"]', { timeout: 30000 }).click(); + cy.get('[data-cyid="template-version-continue-button"]', { timeout: 30000 }) + .should('not.be.disabled') + .click(); + + cy.get('[data-cyid="provider-name-input"] input:visible', { + timeout: 30000, + }) + .should('be.visible') + .clear() + .type(providerName); + cy.get('[data-cyid="provider-context-input"] input:visible').should( + 'have.value', + `/${providerId}` + ); + cy.get('[data-cyid="provider-api-key-input"] input:visible').type( + 'sk-e2e-custom-template-provider-key' + ); + cy.get('[data-cyid="add-provider-button"]') + .should('not.be.disabled') + .click(); + + cy.location('pathname', { timeout: 30000 }) + .should( + 'match', + new RegExp(`^/organizations/${orgHandle}/service-provider/[^/]+$`) + ) + .then((pathname) => { + createdProviderId = pathname.split('/').pop() || ''; + expect(createdProviderId).to.not.equal(''); + }); + cy.contains(providerName, { timeout: 30000 }).should('be.visible'); + + // --- Deleting the version while a provider uses it must be blocked ----- + cy.contains('Settings', { timeout: 30000 }).should('be.visible').click(); + cy.get(`[data-cyid="provider-template-card-${templateV2Id}"]`, { + timeout: 30000, + }) + .should('be.visible') + .click(); + cy.contains('button', 'v2.0', { timeout: 30000 }).should('be.visible'); + + cy.get('[data-cyid="provider-template-delete-button"]', { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('[role="dialog"]').within(() => { + cy.contains('button', 'Delete').click(); + }); + cy.contains( + 'Cannot delete: one or more providers were created from this template.', + { timeout: 30000 } + ).should('be.visible'); + + // --- Remove the provider, then deletion is allowed ---------------------- + cy.then(() => deleteProvider(authToken, organizationId, createdProviderId)); + cy.then(() => waitForProviderGone(authToken, organizationId, createdProviderId)); + + cy.get('[data-cyid="provider-template-delete-button"]', { timeout: 30000 }) + .should('be.visible') + .click(); + cy.get('[role="dialog"]').within(() => { + cy.contains('button', 'Delete').click(); + }); + cy.contains('button', 'v1.0', { timeout: 30000 }).should('exist'); + cy.get('[data-cyid="provider-template-delete-button"]', { timeout: 30000 }) + .scrollIntoView() + .should('be.visible') + .click(); + cy.get('[role="dialog"]').within(() => { + cy.contains('button', 'Delete').click(); + }); + + // That was the only remaining version, so the whole template is gone. + cy.contains('LLM Provider Templates', { timeout: 30000 }).should('be.visible'); + cy.contains(templateName).should('not.exist'); + }); +}); + +function waitForProviderGone(authToken, organizationId, providerId) { + return requestWithAuth(authToken, { + url: `/api-proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(organizationId)}`, + }).then((res) => { + const stillThere = (res.body?.list ?? []).some((p) => p.id === providerId); + if (stillThere) { + cy.wait(500); + return waitForProviderGone(authToken, organizationId, providerId); + } + }); +} + + +function deleteProvider(authToken, organizationId, targetProviderId) { + if (!targetProviderId) { + return cy.wrap(null); + } + return requestWithAuth(authToken, { + method: 'DELETE', + url: `/api-proxy/api/v0.9/llm-providers/${encodeURIComponent(targetProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.be.oneOf([200, 204, 404]); + }); +} + +function deleteProviderTemplate(authToken, organizationId, templateId) { + return requestWithAuth(authToken, { + method: 'DELETE', + url: `/api-proxy/api/v0.9/llm-provider-templates/${encodeURIComponent(templateId)}?organizationId=${encodeURIComponent(organizationId)}`, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.be.oneOf([200, 204, 404, 409]); + }); +} + +function requestWithAuth(authToken, options) { + const headers = { + Authorization: `Bearer ${authToken}`, + ...(options.headers ?? {}), + }; + + return cy.request({ + ...options, + headers, + }); +} + +function toSlug(value) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function toTemplateId(value) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/portals/ai-workspace/cypress/support/commands.js b/portals/ai-workspace/cypress/support/commands.js index 8966aac2b0..10260664d2 100644 --- a/portals/ai-workspace/cypress/support/commands.js +++ b/portals/ai-workspace/cypress/support/commands.js @@ -43,3 +43,115 @@ Cypress.Commands.add('login', (username, password) => { cy.contains('Quick Start', { timeout: 30000 }).should('be.visible'); cy.contains('Projects').should('be.visible'); }); + +Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { + const PAGE_SIZE = 100; + const headersFor = (token) => ({ Authorization: `Bearer ${token}` }); + + // Collect every stale `E2E ` provider, paging until the API returns a short + // page. Collection finishes before any deletes so the offset window stays + // consistent. + const collectE2EProviders = (token, orgId, offset = 0, acc = []) => + cy + .request({ + method: 'GET', + url: `/api-proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(orgId)}&limit=${PAGE_SIZE}&offset=${offset}`, + headers: headersFor(token), + failOnStatusCode: false, + }) + .then((response) => { + // Cleanup runs in afterEach; a transient non-200 from the list endpoint + // must not hard-fail the spec it is cleaning up after. Skip this page. + if (response.status !== 200) return acc; + const page = response.body?.list ?? []; + const next = acc.concat( + page.filter( + (p) => typeof p.name === 'string' && p.name.startsWith('E2E ') + ) + ); + if (page.length < PAGE_SIZE) return next; + return collectE2EProviders(token, orgId, offset + PAGE_SIZE, next); + }); + + // A provider with linked proxies cannot be deleted directly, so clear those + // first to keep the sweep from silently leaving stale state behind. + const deleteLinkedProxies = (token, orgId, providerId) => + cy + .request({ + method: 'GET', + url: `/api-proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(orgId)}`, + headers: headersFor(token), + failOnStatusCode: false, + }) + .then((response) => { + if (response.status === 404) return; + const proxies = response.body?.list ?? []; + if (!proxies.length) return; + return cy.wrap(proxies).each((proxy) => + cy + .request({ + method: 'DELETE', + url: `/api-proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxy.id)}?organizationId=${encodeURIComponent(orgId)}`, + headers: headersFor(token), + failOnStatusCode: false, + }) + .then((deleteResponse) => { + expect(deleteResponse.status).to.be.oneOf([200, 204, 404]); + }) + ); + }); + + const doSweep = (token, orgId) => + collectE2EProviders(token, orgId).then((e2eProviders) => { + if (!e2eProviders.length) return; + return cy.wrap(e2eProviders).each((provider) => + deleteLinkedProxies(token, orgId, provider.id).then(() => + cy + .request({ + method: 'DELETE', + url: `/api-proxy/api/v0.9/llm-providers/${encodeURIComponent(provider.id)}?organizationId=${encodeURIComponent(orgId)}`, + headers: headersFor(token), + failOnStatusCode: false, + }) + .then((deleteResponse) => { + // Surface a failed delete so the sweep does not pass while leaving + // the next suite to start from dirty state. + expect(deleteResponse.status).to.be.oneOf([200, 204, 404]); + }) + ) + ); + }); + + if (authToken && organizationId) { + return doSweep(authToken, organizationId); + } + + return cy + .request({ + method: 'POST', + url: '/api-proxy/api/portal/v1/auth/login', + form: true, + body: { + username: Cypress.env('ADMIN_USER'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + failOnStatusCode: false, + }) + .then((loginResp) => { + if (loginResp.status !== 200) return; + const token = loginResp.body?.token; + if (!token) return; + return cy + .request({ + url: '/api-proxy/api/v0.9/organizations', + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }) + .then((orgResp) => { + if (orgResp.status !== 200) return; + const orgId = orgResp.body?.id; + if (!orgId) return; + return doSweep(token, orgId); + }); + }); +}); diff --git a/portals/ai-workspace/src/App.tsx b/portals/ai-workspace/src/App.tsx index c66edc3691..4e1a17ea6d 100644 --- a/portals/ai-workspace/src/App.tsx +++ b/portals/ai-workspace/src/App.tsx @@ -58,6 +58,10 @@ import EditLLMProxy from './pages/appShell/appShellPages/proxies/EditLLMProxy'; import ServiceProviderLayout from './pages/appShell/appShellPages/serviceProvider/ServiceProviderLayout'; import ServiceProviders from './pages/appShell/appShellPages/serviceProvider/ProvidersList'; import ServiceProviderNew from './pages/appShell/appShellPages/serviceProvider/ServiceProviderNew'; +import CreateProviderTemplate from './pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate'; +import ProviderTemplateOverview from './pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview'; +import EditProviderTemplate from './pages/appShell/appShellPages/providerTemplate/EditProviderTemplate'; +import CreateProviderTemplateVersion from './pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion'; import ServiceProviderOverview from './pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview'; import ServiceProviderDeploy from './pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy'; import EditServiceProvider from './pages/appShell/appShellPages/serviceProvider/EditServiceProvider'; @@ -67,6 +71,7 @@ import OrgRegisterPage from './pages/register/OrgRegisterPage'; import Insights from './pages/appShell/appShellPages/insights/Main'; import QuickStart from './pages/appShell/appShellPages/quickStart/Main'; import Settings from './pages/appShell/appShellPages/settings/Main'; +import ProviderTemplatesList from './pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList'; import ExternalServersList from './pages/appShell/appShellPages/externalServers/ExternalServersList'; import ExternalServersNew from './pages/appShell/appShellPages/externalServers/ExternalServersNew'; import ExternalServersOverview from './pages/appShell/appShellPages/externalServers/ExternalServersOverview'; @@ -525,14 +530,54 @@ export default function App() { } /> - - - - } - /> + }> + }> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + }> } /> } /> - - - - } - /> + }> + }> + + + + } + /> + + diff --git a/portals/ai-workspace/src/Components/ResourceView/ResourceRow.tsx b/portals/ai-workspace/src/Components/ResourceView/ResourceRow.tsx index 589303a8c8..2c33556f60 100644 --- a/portals/ai-workspace/src/Components/ResourceView/ResourceRow.tsx +++ b/portals/ai-workspace/src/Components/ResourceView/ResourceRow.tsx @@ -266,6 +266,9 @@ export default function ResourceRow({ display: 'inline-flex', alignItems: 'center', color: '#3b4151', + [DARK_MODE_SELECTOR]: { + color: '#fff !important', + }, '& .MuiIconButton-root': { borderRadius: 0.5, p: 0.35, diff --git a/portals/ai-workspace/src/apis/providerTemplateApis.ts b/portals/ai-workspace/src/apis/providerTemplateApis.ts index c17c097eaf..76d3048957 100644 --- a/portals/ai-workspace/src/apis/providerTemplateApis.ts +++ b/portals/ai-workspace/src/apis/providerTemplateApis.ts @@ -16,7 +16,7 @@ * under the License. */ -import { get, post, put, del } from '../clients/choreoApiClient'; +import { get, post, put, del, patch } from '../clients/choreoApiClient'; import { logger } from '../utils/logger'; // ============================================================================ @@ -125,9 +125,94 @@ export async function getProviderTemplate( } } +/** + * List all versions of a Provider Template (newest first). + * + * Templates are immutable per version — each edit creates a new version. This + * powers the version switcher on the overview page. + * + * @param templateId - The provider template ID (handle) + * @param organizationId - The organization ID + * @returns Promise with the list of versions (most recent first) + */ +export async function getProviderTemplateVersions( + templateId: string, + organizationId: string, + baseUrl: string +): Promise { + try { + const response = await get( + `/llm-provider-templates/${encodeURIComponent(templateId)}/versions?organizationId=${encodeURIComponent(organizationId)}`, + undefined, + baseUrl + ); + return Array.isArray(response) ? response : response.list ?? []; + } catch (error) { + logger.error(`Failed to fetch versions for provider template ${templateId}:`, error); + throw error; + } +} + +/** + * Fetch a specific immutable version of a Provider Template. + * + * @param templateId - The provider template ID (handle) + * @param version - The version to fetch (e.g. "v2") + * @param organizationId - The organization ID + * @returns Promise with that version's full template + */ +export async function getProviderTemplateVersion( + templateId: string, + version: string, + organizationId: string, + baseUrl: string +): Promise { + try { + const response = await get( + `/llm-provider-templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(version)}?organizationId=${encodeURIComponent(organizationId)}`, + undefined, + baseUrl + ); + return response; + } catch (error) { + logger.error(`Failed to fetch version ${version} of provider template ${templateId}:`, error); + throw error; + } +} + +/** + * Create a new version of an existing Provider Template. + * + * The supplied template must include a `version` (e.g. "v2.0") that is unique + * for the template; the new version becomes the latest. + * + * @param templateId - The provider template ID (handle) + * @param template - The new version's configuration (must include `version`) + * @param organizationId - The organization ID + * @returns Promise with the newly created version + */ +export async function createProviderTemplateVersion( + templateId: string, + template: ProviderTemplate, + organizationId: string, + baseUrl: string +): Promise { + try { + const response = await post( + `/llm-provider-templates/${encodeURIComponent(templateId)}/versions?organizationId=${encodeURIComponent(organizationId)}`, + template, + baseUrl + ); + return response; + } catch (error) { + logger.error(`Failed to create new version for provider template ${templateId}:`, error); + throw error; + } +} + /** * Update an existing Provider Template - * + * * @param templateId - The provider template ID * @param updates - The fields to update * @param organizationId - The organization ID @@ -142,6 +227,61 @@ export async function getProviderTemplate( * console.log(template); // { id: '...', name: 'OpenAI Template Updated', ... } * ``` */ +/** + * Enable or disable a specific version of a Provider Template. Disabled + * versions stay in the catalog but are hidden from the provider picker. + * + * @param templateId - The provider template ID (handle) + * @param version - The version to toggle (e.g. "v1.0") + * @param enabled - Whether the version should be enabled + * @returns Promise with the updated version + */ +export async function setProviderTemplateVersionEnabled( + templateId: string, + version: string, + enabled: boolean, + organizationId: string, + baseUrl: string +): Promise { + try { + const response = await patch( + `/llm-provider-templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(version)}?organizationId=${encodeURIComponent(organizationId)}`, + { enabled }, + baseUrl + ); + return response; + } catch (error) { + logger.error(`Failed to set enabled=${enabled} for ${templateId} ${version}:`, error); + throw error; + } +} + +/** + * Delete a single version of a Provider Template. If it was the only version + * the template is removed; otherwise the newest remaining version becomes the + * latest. + * + * @param templateId - The provider template ID (handle) + * @param version - The version to delete (e.g. "v2.0") + */ +export async function deleteProviderTemplateVersion( + templateId: string, + version: string, + organizationId: string, + baseUrl: string +): Promise { + try { + await del( + `/llm-provider-templates/${encodeURIComponent(templateId)}/versions/${encodeURIComponent(version)}?organizationId=${encodeURIComponent(organizationId)}`, + undefined, + baseUrl + ); + } catch (error) { + logger.error(`Failed to delete version ${version} of ${templateId}:`, error); + throw error; + } +} + export async function updateProviderTemplate( templateId: string, updates: UpdateProviderTemplateRequest, diff --git a/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplateContext.tsx b/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplateContext.tsx index e938f2ef9f..7092971af4 100644 --- a/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplateContext.tsx +++ b/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplateContext.tsx @@ -54,9 +54,10 @@ const ProviderTemplateContext = createContext({ interface ProviderTemplateProviderProps { children: React.ReactNode; templateId: string; + version?: string; } -export function ProviderTemplateProvider({ children, templateId }: ProviderTemplateProviderProps) { +export function ProviderTemplateProvider({ children, templateId, version }: ProviderTemplateProviderProps) { const { currentOrganization } = useAppShell(); const [template, setTemplate] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -64,7 +65,7 @@ export function ProviderTemplateProvider({ children, templateId }: ProviderTempl const organizationId = currentOrganization?.uuid ?? ''; - // Fetch single template + // Fetch single template (a specific version if requested, else latest) const fetchTemplate = useCallback(async () => { if (!templateId || !organizationId) { setTemplate(null); @@ -75,7 +76,9 @@ export function ProviderTemplateProvider({ children, templateId }: ProviderTempl try { setIsLoading(true); setError(null); - const fetchedTemplate = await providerTemplateApis.getProviderTemplate(templateId, organizationId, PLATFORM_API_BASE_URL); + const fetchedTemplate = version + ? await providerTemplateApis.getProviderTemplateVersion(templateId, version, organizationId, PLATFORM_API_BASE_URL) + : await providerTemplateApis.getProviderTemplate(templateId, organizationId, PLATFORM_API_BASE_URL); setTemplate(fetchedTemplate); } catch (err) { logger.error(`Failed to fetch provider template ${templateId}:`, err); @@ -84,7 +87,7 @@ export function ProviderTemplateProvider({ children, templateId }: ProviderTempl } finally { setIsLoading(false); } - }, [templateId, organizationId, PLATFORM_API_BASE_URL]); + }, [templateId, version, organizationId, PLATFORM_API_BASE_URL]); useEffect(() => { fetchTemplate(); diff --git a/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx b/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx index 4dd0d55e57..7e1e363e06 100644 --- a/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx +++ b/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx @@ -94,8 +94,18 @@ export function ProviderTemplatesProvider({ children }: ProviderTemplatesProvide setIsLoading(true); setError(null); const response = await providerTemplateApis.getProviderTemplates(organizationId, PLATFORM_API_BASE_URL); - // Store the full API response as-is - setTemplatesResponse(response as ProviderTemplatesResponse); + // Load latest-first by creation time. (Not updatedAt: the backend seeder + // re-touches built-in templates on every restart, which would otherwise + // float them to the top. createdAt also bumps when a new version is added.) + const ts = (t: ProviderTemplate) => + new Date(t.createdAt ?? t.updatedAt ?? 0).getTime(); + const sorted: ProviderTemplatesResponse = { + ...(response as ProviderTemplatesResponse), + list: [...((response as ProviderTemplatesResponse).list ?? [])].sort( + (a, b) => ts(b) - ts(a) + ), + }; + setTemplatesResponse(sorted); } catch (err) { logger.error('Failed to fetch provider templates:', err); setError(err instanceof Error ? err : new Error('Failed to fetch templates')); @@ -118,7 +128,7 @@ export function ProviderTemplatesProvider({ children }: ProviderTemplatesProvide setTemplatesResponse((prev) => ({ ...prev, count: prev.count + 1, - list: [newTemplate, ...prev.list], + list: [newTemplate, ...prev.list], //appear last created as first one pagination: { ...prev.pagination, total: prev.pagination.total + 1 }, })); return newTemplate; diff --git a/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx b/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx index 211afcd9cb..35d3091865 100644 --- a/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx +++ b/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx @@ -27,6 +27,7 @@ import { Layers, Network, Rocket, + Settings, Workflow, } from '@wso2/oxygen-ui-icons-react'; import McpMenuIcon from '../../assets/icons/McpMenuIcon'; @@ -302,25 +303,27 @@ export default function AppSidebar({ - {/* + - - - - - - Settings - - + {hasPermission(SCOPES.LLM_TEMPLATE_MANAGE) && ( + + + + + + Settings + + + )} - + {/* Help & Support - + */} - */} + ); } diff --git a/portals/ai-workspace/src/pages/appShell/appShellMain.tsx b/portals/ai-workspace/src/pages/appShell/appShellMain.tsx index 1e3eccf410..99ff987ab9 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellMain.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellMain.tsx @@ -173,6 +173,10 @@ export default function AppLayout(): JSX.Element { shellActions.setActiveMenuItem('service-provider'); return; } + if (tertiarySegment === 'provider-template') { + shellActions.setActiveMenuItem('provider-template'); + return; + } if (tertiarySegment === 'external-servers') { shellActions.setActiveMenuItem('external-servers'); return; @@ -210,6 +214,10 @@ export default function AppLayout(): JSX.Element { shellActions.setActiveMenuItem('service-provider'); return; } + if (primarySegment === 'provider-template') { + shellActions.setActiveMenuItem('provider-template'); + return; + } if (primarySegment === 'external-servers') { shellActions.setActiveMenuItem('external-servers'); return; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx new file mode 100644 index 0000000000..f0919c1fe6 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx @@ -0,0 +1,537 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useRef, useState } from 'react'; +import { useNavigate, Link as RouterLink } from 'react-router-dom'; +import YAML from 'yaml'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Divider, + FormControl, + FormLabel, + Grid, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronDown, ChevronLeft } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; +import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; +import type { + CreateProviderTemplateRequest, + TemplateMetadata, +} from '../../../../utils/types'; +import { + DEFAULT_AUTH_CONFIG, + DEFAULT_TOKEN_CONFIG, + fromTokenConfig, + isValidHttpUrl, + TOKEN_FIELDS, + TOKEN_LOCATIONS, + type TokenConfig, + type TokenFieldKey, +} from '../../../../utils/providerTemplateFields'; + +const MAX_NAME_LENGTH = 80; +const MAX_DESCRIPTION_LENGTH = 200; +const INITIAL_VERSION = 'v1.0'; + +function parseSpecServerUrl(text: string): string | null { + if (!text.trim()) return null; + let spec: { servers?: Array<{ url?: string }> } | null = null; + try { + spec = JSON.parse(text); + } catch { + try { + spec = YAML.parse(text) as { servers?: Array<{ url?: string }> }; + } catch { + return null; + } + } + const url = spec?.servers?.[0]?.url; + return typeof url === 'string' && url.trim() ? url.trim() : null; +} + +function isParseableSpec(text: string): boolean { + if (!text.trim()) return false; + let spec: unknown = null; + try { + spec = JSON.parse(text); + } catch { + try { + spec = YAML.parse(text); + } catch { + return false; + } + } + return ( + !!spec && + typeof spec === 'object' && + ('openapi' in spec || 'swagger' in spec || 'paths' in spec) + ); +} + +export default function CreateProviderTemplate() { + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + const { createTemplate } = useProviderTemplates(); + const showSnackbar = useAIWorkspaceSnackbar(); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [endpointUrl, setEndpointUrl] = useState(''); + const [openapiSpecUrl, setOpenapiSpecUrl] = useState(''); + const [isFetchingSpec, setIsFetchingSpec] = useState(false); + const [specFileName, setSpecFileName] = useState(''); + const [specContent, setSpecContent] = useState(''); + const [specFetched, setSpecFetched] = useState(false); + + const [tokenConfig, setTokenConfig] = useState(() => ({ + ...DEFAULT_TOKEN_CONFIG, + })); + const [nameTouched, setNameTouched] = useState(false); + const [specUrlTouched, setSpecUrlTouched] = useState(false); + const [endpointUrlTouched, setEndpointUrlTouched] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const updateToken = ( + field: TokenFieldKey, + key: 'identifier' | 'location', + value: string + ) => { + setTokenConfig((prev) => ({ + ...prev, + [field]: { ...prev[field], [key]: value }, + })); + }; + + const listPath = buildOrgPath(currentOrganization, '/settings'); + + const fetchSpecFromUrl = async () => { + const url = openapiSpecUrl.trim(); + if (!url) return; + if (!isValidHttpUrl(url)) { + showSnackbar('Enter a valid http or https URL.', 'error'); + return; + } + setIsFetchingSpec(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`); + const text = await res.text(); + if (!isParseableSpec(text)) { + showSnackbar('That URL did not return a valid OpenAPI specification.', 'error'); + return; + } + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(''); + setSpecContent(''); + setSpecFetched(true); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Could not fetch a specification from that URL.', 'error'); + } finally { + setIsFetchingSpec(false); + } + }; + + const handleSpecFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + if (!isParseableSpec(text)) { + showSnackbar('That file is not a valid OpenAPI specification (JSON or YAML).', 'error'); + return; + } + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(file.name); + setSpecContent(text); + setOpenapiSpecUrl(''); + setSpecFetched(true); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Failed to read the specification file.', 'error'); + } finally { + e.target.value = ''; + } + }; + + const toTemplateId = (value: string): string => + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + const normalizedTemplateId = toTemplateId(`${name} ${INITIAL_VERSION}`); + const isNameValid = name.trim().length > 0 && name.length <= MAX_NAME_LENGTH; + const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH; + const isEndpointValid = + endpointUrl.trim().length > 0 && isValidHttpUrl(endpointUrl); + const specUrlEntered = openapiSpecUrl.trim().length > 0; + const isSpecUrlValid = isValidHttpUrl(openapiSpecUrl); + const isSpecReady = !specUrlEntered || (isSpecUrlValid && specFetched); + const isFormValid = + isNameValid && + Boolean(normalizedTemplateId) && + isDescriptionValid && + isEndpointValid && + isSpecReady; + + const handleSubmit = async (event?: React.FormEvent) => { + if (event) event.preventDefault(); + if (!isFormValid || isSubmitting) return; + + const tokenFields = fromTokenConfig(tokenConfig); + const metadata: TemplateMetadata = {}; + + if (endpointUrl.trim()) metadata.endpointUrl = endpointUrl.trim(); + if (openapiSpecUrl.trim()) metadata.openapiSpecUrl = openapiSpecUrl.trim(); + + metadata.auth = { ...DEFAULT_AUTH_CONFIG }; + + const payload: CreateProviderTemplateRequest = { + id: normalizedTemplateId, + name: name.trim(), + version: INITIAL_VERSION, + description: description.trim() || undefined, + ...tokenFields, + metadata: Object.keys(metadata).length ? metadata : undefined, + + openapi: specContent.trim() ? specContent : undefined, + }; + + setIsSubmitting(true); + try { + await createTemplate(payload); + showSnackbar('Template created successfully.', 'success'); + navigate(listPath); // Go back to the list, where the new template appears. + } catch (err: any) { + showSnackbar(err?.message || 'Failed to create template.', 'error'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + + + + + + {/* component="form" makes Enter submit and groups inputs semantically. */} + + + + + + + + + setName(e.target.value)} + onBlur={() => setNameTouched(true)} + placeholder="Enter template name" + error={ + (nameTouched && name.trim().length === 0) || + name.length > MAX_NAME_LENGTH + } + helperText={ + nameTouched && name.trim().length === 0 + ? 'Name is required.' + : name.length > MAX_NAME_LENGTH + ? `Name must not exceed ${MAX_NAME_LENGTH} characters (${name.length}/${MAX_NAME_LENGTH})` + : '' + } + /> + + + + {/* A new template always starts at v1 and read-only*/} + + + + + + + + + + + + + + + setDescription(e.target.value)} + placeholder="Enter description" + multiline + minRows={3} + error={description.length > MAX_DESCRIPTION_LENGTH} + helperText={ + description.length > MAX_DESCRIPTION_LENGTH + ? `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters (${description.length}/${MAX_DESCRIPTION_LENGTH})` + : '' + } + /> + + + + + + + + + + + { + setOpenapiSpecUrl(e.target.value); + setSpecFetched(false); + setSpecUrlTouched(false); + }} + onBlur={() => setSpecUrlTouched(true)} + placeholder="https://api.openai.com/openapi.json" + error={specUrlTouched && specUrlEntered && (!isSpecUrlValid || !specFetched)} + helperText={ + specUrlTouched && specUrlEntered && !isSpecUrlValid + ? 'Enter a valid URL.' + : specUrlTouched && specUrlEntered && !specFetched + ? 'Fetch the specification to validate the URL.' + : '' + } + /> + + + Or + + + + + + + + + + + + { + setEndpointUrl(e.target.value); + setEndpointUrlTouched(false); + }} + onBlur={() => setEndpointUrlTouched(true)} + placeholder="https://api.openai.com" + error={endpointUrlTouched && endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl)} + helperText={ + endpointUrlTouched && endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl) + ? 'Enter a valid URL.' + : '' + } + /> + + + + + + {/* Advanced: token & model extraction mapping (collapsed by default, + pre-filled with the OpenAI defaults). */} + + }> + + Advanced + + Token and model mapping. Defaults to OpenAI. + + + + + + {TOKEN_FIELDS.map(({ key, label }) => ( + + + + {`${label} Identifier`} + + updateToken(key, 'identifier', e.target.value) + } + /> + + + + + {`${label} Location`} + + + + + ))} + + + + + + + + + + + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx new file mode 100644 index 0000000000..581a355108 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx @@ -0,0 +1,658 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useRef, useState } from 'react'; +import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom'; +import YAML from 'yaml'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + CircularProgress, + Divider, + FormControl, + FormLabel, + Grid, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronDown, ChevronLeft } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; +import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; +import * as providerTemplateApis from '../../../../apis/providerTemplateApis'; +import { PLATFORM_API_BASE_URL } from '../../../../config.env'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import type { + ProviderTemplate, + TemplateMetadata, +} from '../../../../utils/types'; +import { + fromTokenConfig, + isValidHttpUrl, + toTokenConfig, + TOKEN_FIELDS, + TOKEN_LOCATIONS, + type TokenConfig, + type TokenFieldKey, +} from '../../../../utils/providerTemplateFields'; + +const MAX_DESCRIPTION_LENGTH = 200; + +const VERSION_PATTERN = /^[vV]\d+\.\d+$/; + +function suggestNextVersion(current?: string): string { + const match = /^[vV](\d+)\.\d+$/.exec((current ?? '').trim()); + if (!match) return 'v2.0'; + const major = parseInt(match[1], 10); + return `v${major + 1}.0`; +} + +function toTemplateId(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Parse an OpenAPI spec (JSON or YAML) and return its first server URL. +function parseSpecServerUrl(text: string): string | null { + if (!text.trim()) return null; + let spec: { servers?: Array<{ url?: string }> } | null = null; + try { + spec = JSON.parse(text); + } catch { + try { + spec = YAML.parse(text) as { servers?: Array<{ url?: string }> }; + } catch { + return null; + } + } + const url = spec?.servers?.[0]?.url; + return typeof url === 'string' && url.trim() ? url.trim() : null; +} + +// True if the text parses (JSON or YAML) into a plausible OpenAPI document. +// Used to reject obviously-invalid specs on upload/fetch. +function isParseableSpec(text: string): boolean { + if (!text.trim()) return false; + let spec: unknown = null; + try { + spec = JSON.parse(text); + } catch { + try { + spec = YAML.parse(text); + } catch { + return false; + } + } + return ( + !!spec && + typeof spec === 'object' && + ('openapi' in spec || 'swagger' in spec || 'paths' in spec) + ); +} + +function CreateProviderTemplateVersionForm({ + template, +}: { + template: ProviderTemplate; +}) { + const templateId = template.id; + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + const { refreshTemplates } = useProviderTemplates(); + const showSnackbar = useAIWorkspaceSnackbar(); + + const overviewPath = `${buildOrgPath( + currentOrganization, + '/settings/llm-provider-templates' + )}/${templateId}`; + + const currentVersion = template.version; + const [version, setVersion] = useState(() => suggestNextVersion(template.version)); + +// Description is user-entered (prefilled from the source version) and optional, + const [description, setDescription] = useState(template.description ?? ''); + const [openapiSpecUrl, setOpenapiSpecUrl] = useState( + template.metadata?.openapiSpecUrl ?? '' + ); + const [endpointUrl, setEndpointUrl] = useState( + template.metadata?.endpointUrl ?? '' + ); + const [isFetchingSpec, setIsFetchingSpec] = useState(false); + const [specFileName, setSpecFileName] = useState(''); + const [specContent, setSpecContent] = useState(template.openapi ?? ''); + const [endpointUrlTouched, setEndpointUrlTouched] = useState(false); + const [specUrlTouched, setSpecUrlTouched] = useState(false); + const hasInheritedSpec = !specFileName && Boolean(template.openapi?.trim()); + const [tokenConfig, setTokenConfig] = useState(() => + toTokenConfig(template) + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const updateToken = ( + field: TokenFieldKey, + key: 'identifier' | 'location', + value: string + ) => { + setTokenConfig((prev) => ({ + ...prev, + [field]: { ...prev[field], [key]: value }, + })); + }; + + const fetchSpecFromUrl = async () => { + const url = openapiSpecUrl.trim(); + if (!url) return; + if (!isValidHttpUrl(url)) { + showSnackbar('Enter a valid http or https URL.', 'error'); + return; + } + setIsFetchingSpec(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`); + const text = await res.text(); + if (!isParseableSpec(text)) { + showSnackbar('That URL did not return a valid OpenAPI specification.', 'error'); + return; + } + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(''); + setSpecContent(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Could not fetch a specification from that URL.', 'error'); + } finally { + setIsFetchingSpec(false); + } + }; + + const handleSpecFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + if (!isParseableSpec(text)) { + showSnackbar('That file is not a valid OpenAPI specification (JSON or YAML).', 'error'); + return; + } + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(file.name); + setSpecContent(text); + setOpenapiSpecUrl(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Could not read that specification file.', 'error'); + } finally { + e.target.value = ''; + } + }; + + const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH; + const isEndpointValid = + endpointUrl.trim().length > 0 && isValidHttpUrl(endpointUrl); + const specUrlEntered = openapiSpecUrl.trim().length > 0; + const isSpecUrlValid = isValidHttpUrl(openapiSpecUrl); + const isVersionValid = VERSION_PATTERN.test(version.trim()); + const isFormValid = + isDescriptionValid && + isEndpointValid && + isVersionValid && + (!specUrlEntered || isSpecUrlValid); + + const handleSubmit = async (event?: React.FormEvent) => { + if (event) event.preventDefault(); + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId || !isFormValid || isSubmitting) return; + + const tokenFields = fromTokenConfig(tokenConfig); + + const metadata: TemplateMetadata = { ...(template.metadata ?? {}) }; + if (endpointUrl.trim()) metadata.endpointUrl = endpointUrl.trim(); + else delete metadata.endpointUrl; + if (openapiSpecUrl.trim()) metadata.openapiSpecUrl = openapiSpecUrl.trim(); + else delete metadata.openapiSpecUrl; + + const payload: ProviderTemplate = { + id: toTemplateId(`${template.name ?? ''} ${version.trim()}`), + name: template.name, + version: version.trim(), + description: description.trim() || undefined, + resourceMappings: template.resourceMappings, + ...tokenFields, + metadata: Object.keys(metadata).length ? metadata : undefined, + openapi: specContent.trim() ? specContent : undefined, + }; + + setIsSubmitting(true); + try { + const created = await providerTemplateApis.createProviderTemplateVersion( + templateId, + payload, + organizationId, + PLATFORM_API_BASE_URL + ); + await refreshTemplates(); + showSnackbar(`New version ${version.trim()} created successfully.`, 'success'); + const newVersionPath = created.id + ? `${buildOrgPath(currentOrganization, '/settings/llm-provider-templates')}/${created.id}` + : overviewPath; + navigate(newVersionPath); + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to create new version.', + 'error' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + + + + + + + + + + + + {/* Name is fixed — a version belongs to the same template. */} + + + + + + + + + + {/* Version is user-entered (prefilled with a suggested bump) and + must be unique, matching the v. pattern. */} + + + + + + setVersion(e.target.value)} + placeholder="v2.0" + error={version.trim().length > 0 && !isVersionValid} + helperText={ + version.trim().length > 0 && !isVersionValid + ? 'Expected: v.' + : '' + } + /> + + + + + + + + + setDescription(e.target.value)} + placeholder="Enter description" + multiline + minRows={3} + error={description.length > MAX_DESCRIPTION_LENGTH} + helperText={ + description.length > MAX_DESCRIPTION_LENGTH + ? `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters (${description.length}/${MAX_DESCRIPTION_LENGTH})` + : '' + } + /> + + + + + + + + + + + { + setOpenapiSpecUrl(e.target.value); + setSpecUrlTouched(false); + }} + onBlur={() => setSpecUrlTouched(true)} + placeholder="https://api.openai.com/openapi.json" + error={specUrlTouched && specUrlEntered && !isSpecUrlValid} + helperText={ + specUrlTouched && specUrlEntered && !isSpecUrlValid + ? 'Enter a valid URL.' + : '' + } + /> + + + Or + + + + + + + + + + + + { + setEndpointUrl(e.target.value); + setEndpointUrlTouched(false); + }} + onBlur={() => setEndpointUrlTouched(true)} + placeholder="https://api.openai.com" + error={ + endpointUrlTouched && + endpointUrl.trim().length > 0 && + !isValidHttpUrl(endpointUrl) + } + helperText={ + endpointUrlTouched && + endpointUrl.trim().length > 0 && + !isValidHttpUrl(endpointUrl) + ? 'Enter a valid URL.' + : '' + } + /> + + + + + {/* Advanced: token & model extraction mapping copied from the source + version (collapsed by default). */} + + }> + + Advanced + + Token and model mapping, copied from {currentVersion}. + + + + + + {TOKEN_FIELDS.map(({ key, label }) => ( + + + + {`${label} Identifier`} + + updateToken(key, 'identifier', e.target.value) + } + /> + + + + + {`${label} Location`} + + + + + ))} + + + + + + + + + + + + ); +} + +// Container: fetch the full (latest) template so token/resource mappings and +// metadata are available to copy into the new version. +export default function CreateProviderTemplateVersion() { + const { templateId } = useParams<{ templateId: string }>(); + const { currentOrganization } = useAppShell(); + const [template, setTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const listPath = buildOrgPath(currentOrganization, '/settings'); + + React.useEffect(() => { + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId) return; + + let isMounted = true; + setIsLoading(true); + setError(null); + providerTemplateApis + .getProviderTemplate(templateId, organizationId, PLATFORM_API_BASE_URL) + .then((full) => { + if (isMounted) setTemplate(full); + }) + .catch((err: unknown) => { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load template'); + } + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [templateId, currentOrganization?.uuid]); + + if (isLoading) { + return ( + + + + + + ); + } + + if (error || !template) { + return ( + + + + {error ? ( + error + ) : ( + + )} + + + ); + } + + return ( + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx new file mode 100644 index 0000000000..15b5cf4b56 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom'; +import { + Box, + Button, + Card, + CircularProgress, + FormControl, + FormLabel, + Grid, + PageContent, + PageTitle, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronLeft } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; +import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; +import * as providerTemplateApis from '../../../../apis/providerTemplateApis'; +import { PLATFORM_API_BASE_URL } from '../../../../config.env'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import type { + ProviderTemplate, + UpdateProviderTemplateRequest, +} from '../../../../utils/types'; + +const MAX_NAME_LENGTH = 80; +const MAX_DESCRIPTION_LENGTH = 200; + +function EditProviderTemplateForm({ template }: { template: ProviderTemplate }) { + const templateId = template.id; + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + const { updateTemplate } = useProviderTemplates(); + const showSnackbar = useAIWorkspaceSnackbar(); + + const overviewPath = `${buildOrgPath( + currentOrganization, + '/settings/llm-provider-templates' + )}/${templateId}`; + + const [name, setName] = useState(template.name ?? ''); + const [description, setDescription] = useState(template.description ?? ''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isNameValid = name.trim().length > 0 && name.length <= MAX_NAME_LENGTH; + const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH; + const isFormValid = isNameValid && isDescriptionValid; + + const handleSubmit = async (event?: React.FormEvent) => { + if (event) event.preventDefault(); + if (!templateId || !isFormValid || isSubmitting) return; + + const { createdAt, createdBy, updatedAt, ...rest } = template; + const payload: UpdateProviderTemplateRequest = { + ...rest, + id: templateId, + name: name.trim(), + description: description.trim() || undefined, + }; + + setIsSubmitting(true); + try { + await updateTemplate(templateId, payload); + showSnackbar('Template updated successfully.', 'success'); + navigate(overviewPath); + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to update template.', + 'error' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + + + + + + + + + + Details + + + + + Name + setName(e.target.value)} + placeholder="Enter template name" + error={name.length > MAX_NAME_LENGTH} + helperText={ + name.length > MAX_NAME_LENGTH + ? `Name must not exceed ${MAX_NAME_LENGTH} characters (${name.length}/${MAX_NAME_LENGTH})` + : 'Renaming updates every version of this template.' + } + /> + + + + + Description + setDescription(e.target.value)} + placeholder="Enter description" + multiline + minRows={3} + error={description.length > MAX_DESCRIPTION_LENGTH} + helperText={ + description.length > MAX_DESCRIPTION_LENGTH + ? `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters (${description.length}/${MAX_DESCRIPTION_LENGTH})` + : '' + } + /> + + + + + + + + + + + + + ); +} + +export default function EditProviderTemplate() { + const { templateId } = useParams<{ templateId: string }>(); + const { currentOrganization } = useAppShell(); + const [template, setTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const listPath = buildOrgPath(currentOrganization, '/settings'); + + useEffect(() => { + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId) return; + + let isMounted = true; + setIsLoading(true); + setError(null); + providerTemplateApis + .getProviderTemplate(templateId, organizationId, PLATFORM_API_BASE_URL) + .then((full) => { + if (isMounted) setTemplate(full); + }) + .catch((err: unknown) => { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load template'); + } + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [templateId, currentOrganization?.uuid]); + + if (isLoading) { + return ( + + + + + + ); + } + + if (error || !template) { + return ( + + + + {error ? ( + error + ) : ( + + )} + + + ); + } + + return ; +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx new file mode 100644 index 0000000000..e064ab14d4 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -0,0 +1,1193 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; +import YAML from 'yaml'; +import { + Avatar, + Box, + Button, + Card, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + FormLabel, + Grid, + IconButton, + Menu, + MenuItem, + PageContent, + Stack, + Switch, + Tab, + Tabs, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { Check, ChevronDown, ChevronLeft, Clock, Download, Edit, GitBranch, Lock, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; +import { formatRelativeTime } from '../../../../contexts/llmProvider'; +import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; +import * as providerTemplateApis from '../../../../apis/providerTemplateApis'; +import { getLLMProviders } from '../../../../apis/llmProviderApis'; +import { PLATFORM_API_BASE_URL } from '../../../../config.env'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import { + isBuiltInProviderTemplate, + truncateProviderDisplayName, +} from '../../../../utils/providerTemplateDisplay'; +import { + DEFAULT_AUTH_CONFIG, + fromTokenConfig, + isValidHttpUrl, + toTokenConfigWithDefaults, + type TokenConfig, + type TokenFieldKey, +} from '../../../../utils/providerTemplateFields'; +import type { + ProviderTemplate, + ResourceMapping, + TemplateMetadata, + TemplateMetadataAuth, + UpdateProviderTemplateRequest, +} from '../../../../utils/types'; +import { downloadTemplateYaml } from '../../../../utils/providerTemplateManifest'; +import SwaggerSpecViewer from '../../../../Components/SwaggerSpecViewer'; +import TemplateTokenMapping from './TemplateTokenMapping'; + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length === 0 || words[0] === '') return '??'; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[1][0]}`.toUpperCase(); +} + +type TabPanelProps = { + value: number; + index: number; + children: React.ReactNode; +}; + +function TabPanel({ value, index, children }: TabPanelProps) { + return ( + + ); +} + +const tabs = ['Overview', 'Connection', 'Token Mapping']; + +function parseOpenApiSpec(text: string): Record | null { + if (!text.trim()) return null; + try { + const json = JSON.parse(text); + return json && typeof json === 'object' ? (json as Record) : null; + } catch { + try { + const yaml = YAML.parse(text); + return yaml && typeof yaml === 'object' ? (yaml as Record) : null; + } catch { + return null; + } + } +} + +function specServerUrl(text: string): string | null { + const spec = parseOpenApiSpec(text) as { + servers?: Array<{ url?: string }>; + } | null; + const url = spec?.servers?.[0]?.url; + return typeof url === 'string' && url.trim() ? url.trim() : null; +} + +function isParseableSpec(text: string): boolean { + const spec = parseOpenApiSpec(text); + return !!spec && ('openapi' in spec || 'swagger' in spec || 'paths' in spec); +} + +export default function ProviderTemplateOverview() { + const { templateId } = useParams<{ templateId: string }>(); + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + const { updateTemplate, refreshTemplates } = useProviderTemplates(); + const showSnackbar = useAIWorkspaceSnackbar(); + const [tabIndex, setTabIndex] = useState(0); + + const [template, setTemplate] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [versions, setVersions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState(''); + const [versionMenuAnchor, setVersionMenuAnchor] = useState( + null + ); + + const [endpointUrl, setEndpointUrl] = useState(''); + const [provider, setProvider] = useState(''); + const [openapiSpecUrl, setOpenapiSpecUrl] = useState(''); + const [logoUrlField, setLogoUrlField] = useState(''); + const [authType, setAuthType] = useState(''); + const [authHeader, setAuthHeader] = useState(''); + const [valuePrefix, setValuePrefix] = useState(''); + const [defaultTokens, setDefaultTokens] = useState(() => + toTokenConfigWithDefaults(undefined) + ); + + const [resourceMappings, setResourceMappings] = useState([]); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [urlSpecText, setUrlSpecText] = useState(''); + const [isSpecLoading, setIsSpecLoading] = useState(false); + // Inline OpenAPI spec content (uploaded/pasted) — seeded from the template and + // editable on the Connection tab via URL fetch or file upload (same as create). + const [specContent, setSpecContent] = useState(''); + const [specFileName, setSpecFileName] = useState(''); + const [isFetchingSpec, setIsFetchingSpec] = useState(false); + const fileInputRef = React.useRef(null); + const [endpointUrlTouched, setEndpointUrlTouched] = useState(false); + const [openapiSpecUrlTouched, setOpenapiSpecUrlTouched] = useState(false); + const [logoUrlTouched, setLogoUrlTouched] = useState(false); + const [isTogglingEnabled, setIsTogglingEnabled] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const listPath = buildOrgPath(currentOrganization, '/settings/llm-provider-templates'); + + useEffect(() => { + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId) return; + + let isMounted = true; + setIsLoading(true); + setError(null); + providerTemplateApis + .getProviderTemplate(templateId, organizationId, PLATFORM_API_BASE_URL) + .then((full) => { + if (isMounted) setTemplate(full); + }) + .catch((err: unknown) => { + if (isMounted) { + setError(err instanceof Error ? err : new Error('Failed to load template')); + } + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [templateId, currentOrganization?.uuid]); + + useEffect(() => { + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId) return; + setVersions([]); + + let isMounted = true; + providerTemplateApis + .getProviderTemplateVersions(templateId, organizationId, PLATFORM_API_BASE_URL) + .then((list) => { + if (isMounted) { + setVersions(list); + } + }) + .catch(() => { + if (isMounted) setVersions([]); + }); + + return () => { + isMounted = false; + }; + }, [templateId, currentOrganization?.uuid]); + + useEffect(() => { + if (template?.version) setSelectedVersion(template.version); + }, [template?.version]); + + const handleSwitchVersion = async (version: string) => { + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId || version === selectedVersion) return; + setIsLoading(true); + try { + const full = await providerTemplateApis.getProviderTemplateVersion( + templateId, + version, + organizationId, + PLATFORM_API_BASE_URL + ); + setSelectedVersion(version); + setTemplate(full); + if (full.id && full.id !== templateId) { + navigate(`${listPath}/${full.id}`, { replace: true }); + } + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to load version.', + 'error' + ); + } finally { + setIsLoading(false); + } + }; + + const seedDrafts = React.useCallback((t: ProviderTemplate) => { + setEndpointUrl(t.metadata?.endpointUrl ?? ''); + setProvider( + (t.managedBy ?? t.provider)?.trim() || + (isBuiltInProviderTemplate(t.id) ? 'wso2' : 'customer') + ); + setOpenapiSpecUrl(t.metadata?.openapiSpecUrl ?? ''); + setLogoUrlField(t.metadata?.logoUrl ?? ''); + setAuthType(t.metadata?.auth?.type ?? DEFAULT_AUTH_CONFIG.type); + setAuthHeader(t.metadata?.auth?.header ?? DEFAULT_AUTH_CONFIG.header); + setValuePrefix(t.metadata?.auth?.valuePrefix ?? DEFAULT_AUTH_CONFIG.valuePrefix); + setDefaultTokens(toTokenConfigWithDefaults(t)); + setResourceMappings(t.resourceMappings?.resources ?? []); + setSpecContent(t.openapi ?? ''); + setSpecFileName(''); + setIsDirty(false); + setEndpointUrlTouched(false); + setOpenapiSpecUrlTouched(false); + setLogoUrlTouched(false); + }, []); + + const fetchSpecFromUrl = async () => { + const url = openapiSpecUrl.trim(); + if (!url) return; + setIsFetchingSpec(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`); + const text = await res.text(); + if (!isParseableSpec(text)) { + showSnackbar('That URL did not return a valid OpenAPI specification.', 'error'); + return; + } + setSpecFileName(''); + setSpecContent(''); + setUrlSpecText(text); + setIsDirty(true); + const server = specServerUrl(text); + if (server) { + setEndpointUrl(server); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Could not fetch a specification from that URL.', 'error'); + } finally { + setIsFetchingSpec(false); + } + }; + + // Upload a spec file: store its content inline (clears the URL reference). + const handleSpecFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + if (!isParseableSpec(text)) { + showSnackbar('That file is not a valid OpenAPI specification (JSON or YAML).', 'error'); + return; + } + setSpecFileName(file.name); + setSpecContent(text); + setOpenapiSpecUrl(''); + setIsDirty(true); + const server = specServerUrl(text); + if (server) { + setEndpointUrl(server); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); + } else { + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); + } + } catch { + showSnackbar('Failed to read the specification file.', 'error'); + } finally { + e.target.value = ''; + } + }; + + useEffect(() => { + if (template) seedDrafts(template); + }, [template, seedDrafts]); + + useEffect(() => { + const inline = template?.openapi?.trim(); + const specUrl = template?.metadata?.openapiSpecUrl?.trim(); + if (inline || !specUrl) { + setUrlSpecText(''); + setIsSpecLoading(false); + return; + } + let isMounted = true; + setIsSpecLoading(true); + fetch(specUrl) + .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`${res.status}`)))) + .then((text) => { + if (isMounted) setUrlSpecText(text); + }) + .catch(() => { + if (isMounted) setUrlSpecText(''); + }) + .finally(() => { + if (isMounted) setIsSpecLoading(false); + }); + return () => { + isMounted = false; + }; + }, [template?.openapi, template?.metadata?.openapiSpecUrl]); + + const updateToken = ( + field: TokenFieldKey, + key: 'identifier' | 'location', + value: string + ) => { + setDefaultTokens((prev) => ({ + ...prev, + [field]: { ...prev[field], [key]: value }, + })); + setIsDirty(true); + }; + + const handleSaveChanges = async () => { + if (!template?.id || isSaving) return; + if (!endpointUrl.trim()) { + showSnackbar('Endpoint URL is required.', 'error'); + return; + } + if ( + !isValidHttpUrl(endpointUrl) || + !isValidHttpUrl(openapiSpecUrl) || + !isValidHttpUrl(logoUrlField) + ) { + showSnackbar('Enter valid http(s) URLs for the endpoint, spec and logo.', 'error'); + return; + } + + const metadata: TemplateMetadata = {}; + if (endpointUrl.trim()) metadata.endpointUrl = endpointUrl.trim(); + if (logoUrlField.trim()) metadata.logoUrl = logoUrlField.trim(); + if (openapiSpecUrl.trim()) metadata.openapiSpecUrl = openapiSpecUrl.trim(); + const authObj: TemplateMetadataAuth = {} as TemplateMetadataAuth; + if (authType.trim()) authObj.type = authType.trim(); + if (authHeader.trim()) authObj.header = authHeader.trim(); + if (valuePrefix.trim()) authObj.valuePrefix = valuePrefix.trim(); + if (Object.keys(authObj).length) metadata.auth = authObj; + + const payload: UpdateProviderTemplateRequest = { + id: template.id, + name: template.name, + version: currentVersion, + managedBy: provider.trim() || 'customer', + description: template.description, + ...fromTokenConfig(defaultTokens), + metadata: Object.keys(metadata).length ? metadata : undefined, + resourceMappings: resourceMappings.length + ? { resources: resourceMappings } + : undefined, + // Inline spec content (uploaded/pasted); empty when referenced by URL. + openapi: specContent.trim() ? specContent : undefined, + }; + + setIsSaving(true); + try { + const updated = await updateTemplate(template.id, payload); + setTemplate(updated); + setIsDirty(false); + showSnackbar('Template updated successfully.', 'success'); + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to update template.', + 'error' + ); + } finally { + setIsSaving(false); + } + }; + + const handleCancelChanges = () => { + if (template) seedDrafts(template); + }; + + const parsedSpec = useMemo( + () => + parseOpenApiSpec( + specContent.trim() + ? specContent + : template?.openapi?.trim() + ? template.openapi + : urlSpecText + ), + [specContent, template?.openapi, urlSpecText] + ); + + const backButton = ( + + ); + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + {backButton} + + + + + {error.message} + + + ); + } + + if (!template) { + return ( + + {backButton} + + + + + ); + } + + const metadata = template.metadata; + const logoUrl = metadata?.logoUrl?.trim(); + const hasLogo = Boolean(logoUrl); + const description = template.description?.trim() || 'No description'; + const lastUpdated = template.updatedAt ?? template.createdAt; + + const currentVersion = selectedVersion || template.version || 'v1.0'; + const activeProvider = + (() => { + const v = versions.find((vv) => vv.version === currentVersion); + return (v?.managedBy ?? v?.provider) ?? (template.managedBy ?? template.provider); + })(); + const isBuiltIn = activeProvider === 'wso2'; + const isReadOnly = isBuiltIn; + const canEdit = !isReadOnly; + const canDelete = !isReadOnly; + const isEnabled = template.enabled !== false; + + const handleToggleEnabled = async (next: boolean) => { + const organizationId = currentOrganization?.uuid; + if (!template.id || !organizationId || isTogglingEnabled) return; + setIsTogglingEnabled(true); + try { + // Guard: a template that has providers created from it can't be disabled. + if (!next) { + const providers = await getLLMProviders( + organizationId, + PLATFORM_API_BASE_URL + ); + const inUse = (providers.list ?? []).some( + (p) => p.template === template.id + ); + if (inUse) { + showSnackbar( + 'Cannot disable: one or more providers were created from this template.', + 'error' + ); + return; + } + } + const updated = + await providerTemplateApis.setProviderTemplateVersionEnabled( + template.id, + currentVersion, + next, + organizationId, + PLATFORM_API_BASE_URL + ); + setTemplate(updated); + setVersions((prev) => + prev.map((v) => (v.version === updated.version ? updated : v)) + ); + await refreshTemplates(); + showSnackbar(next ? 'Version enabled.' : 'Version disabled.', 'success'); + } catch (err: unknown) { + const status = + err instanceof Error && 'status' in err + ? (err as { status?: number }).status + : (err as { response?: { status?: number } })?.response?.status; + if (status === 409) { + showSnackbar( + 'Cannot disable: one or more providers were created from this template version.', + 'error' + ); + } else { + showSnackbar('Failed to update the version.', 'error'); + } + } finally { + setIsTogglingEnabled(false); + } + }; + + const handleDelete = async () => { + const organizationId = currentOrganization?.uuid; + if (!template.id || !organizationId || isDeleting) return; + setIsDeleting(true); + try { + // Guard: a template that has providers created from it can't be deleted, + const providers = await getLLMProviders( + organizationId, + PLATFORM_API_BASE_URL + ); + const inUse = (providers.list ?? []).some( + (p) => p.template === template.id + ); + if (inUse) { + showSnackbar( + 'Cannot delete: one or more providers were created from this template.', + 'error' + ); + setDeleteOpen(false); + return; + } + + let allVersions = versions; + if (allVersions.length === 0) { + try { + allVersions = await providerTemplateApis.getProviderTemplateVersions( + template.id, + organizationId, + PLATFORM_API_BASE_URL + ); + } catch { + // keep empty; navigate to list as fallback + } + } + + await providerTemplateApis.deleteProviderTemplateVersion( + template.id, + currentVersion, + organizationId, + PLATFORM_API_BASE_URL + ); + await refreshTemplates(); + showSnackbar(`Version ${currentVersion} deleted.`, 'success'); + + // If that was the only version the template is gone; navigate to list. + // Otherwise navigate to a remaining version — using the deleted handle + // for data fetches would return 404. + const remaining = allVersions.filter((v) => v.version !== currentVersion); + setDeleteOpen(false); + if (remaining.length === 0) { + navigate(listPath); + return; + } + const nextVersion = remaining.find((v) => v.isLatest) ?? remaining[0]; + navigate(nextVersion?.id ? `${listPath}/${nextVersion.id}` : listPath, { replace: true }); + } catch { + showSnackbar('Failed to delete the version.', 'error'); + } finally { + setIsDeleting(false); + } + }; + + return ( + + {backButton} + + + {/* Header card */} + + + + + {!hasLogo ? getInitials(template.name) : null} + + + + + {truncateProviderDisplayName(template.name)} + + {canEdit && ( + + + + + + )} + {/* Version switcher — a pill that opens the versions menu. */} + + setVersionMenuAnchor(null)} + slotProps={{ paper: { sx: { minWidth: 260 } } }} + > + {(() => { + const allVersions = versions.length ? versions : [template]; + const parseVerNum = (s: string) => { + const [maj = 0, min = 0] = (s || 'v0').replace(/^v/i, '').split('.').map(Number); + return maj * 1000 + min; + }; + const visibleVersions = (isBuiltIn + ? allVersions.filter((v) => (v.managedBy ?? v.provider) === 'wso2') + : allVersions.filter((v) => (v.managedBy ?? v.provider) !== 'wso2') + ).sort((a, b) => parseVerNum(b.version || 'v0') - parseVerNum(a.version || 'v0')); + const sectionLabel = isBuiltIn ? 'Built-in Versions' : 'Custom Versions'; + + return ( + <> + + {sectionLabel} + + {visibleVersions.map((v) => { + const ver = v.version || 'v1'; + const isSelected = + ver === (selectedVersion || template.version || 'v1'); + return ( + { + setVersionMenuAnchor(null); + void handleSwitchVersion(ver); + }} + > + + + {ver} + + + {formatRelativeTime(v.createdAt ?? v.updatedAt)} + + + {isSelected ? : null} + + ); + })} + + setVersionMenuAnchor(null)} + sx={{ color: 'primary.main', gap: 1 }} + > + + Create new version + + + ); + })()} + + + + {description === 'No description' + ? description + : truncateProviderDisplayName(description, 70)} + + + + + + + + {lastUpdated ? formatRelativeTime(lastUpdated) : '—'} + + + + + + + {!isBuiltIn && ( + + )} + {isReadOnly && ( + + + {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 && ( + + )} + + + + + {/* Built-in base version: read-only notice. */} + {isReadOnly && ( + + + + + Built-in version. You can only enable or disable it. + + + + )} + + {/* Tabbed card */} + + setTabIndex(value)} + variant="scrollable" + allowScrollButtonsMobile + > + {tabs.map((label) => ( + + ))} + + + + {/* Overview */} + + + {!isReadOnly && ( + <> + {/* Logo URL */} + + + Logo URL + { + setLogoUrlField(e.target.value); + setIsDirty(true); + setLogoUrlTouched(false); + }} + onBlur={() => setLogoUrlTouched(true)} + placeholder="https://cdn.example.com/logos/openai.svg" + error={logoUrlTouched && !isValidHttpUrl(logoUrlField)} + helperText={ + logoUrlTouched && !isValidHttpUrl(logoUrlField) + ? 'Enter a valid URL.' + : '' + } + /> + + + + {/* OpenAPI Specification source */} + + + OpenAPI Specification + + + + + { + // Typing a URL must NOT discard an uploaded spec; + // only a successful fetch replaces it. + setOpenapiSpecUrl(e.target.value); + setIsDirty(true); + setOpenapiSpecUrlTouched(false); + }} + onBlur={() => setOpenapiSpecUrlTouched(true)} + placeholder="https://api.openai.com/openapi.json" + error={openapiSpecUrlTouched && !isValidHttpUrl(openapiSpecUrl)} + helperText={ + openapiSpecUrlTouched && !isValidHttpUrl(openapiSpecUrl) + ? 'Enter a valid URL.' + : '' + } + /> + + + Or + + + + + + + )} + + {/* OpenAPI Resources viewer */} + + OpenAPI Resources + + {!specContent.trim() && + !urlSpecText && + !template.openapi?.trim() && + !template.metadata?.openapiSpecUrl?.trim() ? ( + + No available resources. Add an OpenAPI specification above to see resources. + + ) : isSpecLoading && !specContent.trim() ? ( + + + + ) : !parsedSpec ? ( + + Failed to load the OpenAPI specification + {template.metadata?.openapiSpecUrl?.trim() + ? ' from the configured URL.' + : '.'} + + ) : ( + + + + )} + + + + + + + + + Endpoint URL + { + setEndpointUrl(e.target.value); + setIsDirty(true); + setEndpointUrlTouched(false); + }} + onBlur={() => setEndpointUrlTouched(true)} + placeholder="https://api.openai.com" + error={endpointUrlTouched && (!endpointUrl.trim() || !isValidHttpUrl(endpointUrl))} + helperText={ + endpointUrlTouched && !endpointUrl.trim() + ? 'Endpoint URL is required.' + : endpointUrlTouched && !isValidHttpUrl(endpointUrl) + ? 'Enter a valid URL.' + : '' + } + /> + + + + + Auth Type + { + setAuthType(e.target.value); + setIsDirty(true); + }} + placeholder="bearer" + /> + + + + + Auth Header + { + setAuthHeader(e.target.value); + setIsDirty(true); + }} + placeholder="Authorization" + /> + + + + + Value Prefix + { + setValuePrefix(e.target.value); + setIsDirty(true); + }} + placeholder="Bearer " + /> + + + + + + + + + { + setResourceMappings(next); + setIsDirty(true); + }} + spec={parsedSpec} + hidePerResource={isReadOnly} + /> + + + + + + {/* Save/cancel bar — hidden for read-only built-in versions. */} + {!isReadOnly && ( + + + + + {isDirty ? 'You have unsaved changes.' : ''} + + + + + + + + + )} + + + setDeleteOpen(false)}> + + Delete version {currentVersion} of{' '} + '{template.name}'? + + + + {versions.length <= 1 + ? 'This is the only version, so the template will be removed. This action is irreversible.' + : 'Only this version is removed; other versions remain. This action is irreversible.'} + + + + + + + + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx new file mode 100644 index 0000000000..a673282c95 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx @@ -0,0 +1,552 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Avatar, + Box, + Button, + Card, + Chip, + Divider, + IconButton, + InputAdornment, + PageContent, + PageTitle, + Skeleton, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronLeft, ChevronRight, Clock, Cpu, Plus, Search } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; +import { formatRelativeTime } from '../../../../contexts/llmProvider'; +import { useProviderTemplates } from '../../../../contexts/llmProvider/providerTemplate'; +import { useAppShell } from '../../../../contexts/AppShellContext'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import ErrorAlert from '../../../../Components/common/ErrorAlert'; +import { truncateProviderDisplayName } from '../../../../utils/providerTemplateDisplay'; +import type { ProviderTemplate } from '../../../../utils/types'; +import AnthropicLogo from '../../../../assets/brands/Anthropic.jpg'; +import AWSBedrockLogo from '../../../../assets/brands/AWSBedrock.webp'; +import AzureLogo from '../../../../assets/brands/Azure.png'; +import GoogleVertexLogo from '../../../../assets/brands/GoogleVertex.png'; +import GoogleGeminiLogo from '../../../../assets/brands/googlegemini.png'; +import MistralAILogo from '../../../../assets/brands/mistralai.png'; +import OpenAILogo from '../../../../assets/brands/openAI.png'; + +const PROVIDER_LOGO_MAP: Record = { + openai: OpenAILogo, + anthropic: AnthropicLogo, + 'azure-openai': AzureLogo, + 'azureai-foundry': AzureLogo, + 'aws-bedrock': AWSBedrockLogo, + awsbedrock: AWSBedrockLogo, + 'google-vertex': GoogleVertexLogo, + gemini: GoogleGeminiLogo, + mistralai: MistralAILogo, + mistral: MistralAILogo, +}; + +function resolveTemplateLogo(template: ProviderTemplate): string | undefined { + const fromMeta = template.metadata?.logoUrl?.trim() || template.logoUrl?.trim(); + if (fromMeta) return fromMeta; + const id = template.id.toLowerCase(); + if (PROVIDER_LOGO_MAP[id]) return PROVIDER_LOGO_MAP[id]; + const matchedKey = Object.keys(PROVIDER_LOGO_MAP) + .sort((a, b) => b.length - a.length) + .find((key) => id.startsWith(key)); + return matchedKey ? PROVIDER_LOGO_MAP[matchedKey] : undefined; +} + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length === 0 || words[0] === '') return '??'; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return `${words[0][0]}${words[1][0]}`.toUpperCase(); +} + +export default function ProviderTemplatesList({ + embedded = false, +}: { + embedded?: boolean; +} = {}) { + const navigate = useNavigate(); + const { currentOrganization } = useAppShell(); + + const { templatesResponse, isLoading, error, refreshTemplates } = + useProviderTemplates(); + + const [searchQuery, setSearchQuery] = useState(''); + + const templates = useMemo( + () => + templatesResponse.list.filter( + (template) => (template.managedBy ?? template.provider) !== 'wso2' + ), + [templatesResponse.list] + ); + + const templatesBase = buildOrgPath( + currentOrganization, + '/settings/llm-provider-templates' + ); + const newTemplatePath = `${templatesBase}/new`; + const filteredTemplates = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return templates; + return templates.filter( + (template) => + template.name.toLowerCase().includes(query) || + (template.description ?? '').toLowerCase().includes(query) + ); + }, [templates, searchQuery]); + + // Paginate the custom templates ~2 rows at a time. Clamp the page so it stays + // valid when the filtered list shrinks (e.g. while searching). + const CUSTOM_PAGE_SIZE = 6; + const [customPage, setCustomPage] = useState(1); + const customTotalPages = Math.max( + 1, + Math.ceil(filteredTemplates.length / CUSTOM_PAGE_SIZE) + ); + const currentCustomPage = Math.min(customPage, customTotalPages); + const pagedCustomTemplates = filteredTemplates.slice( + (currentCustomPage - 1) * CUSTOM_PAGE_SIZE, + currentCustomPage * CUSTOM_PAGE_SIZE + ); + + const builtInTemplates = useMemo( + () => + templatesResponse.list.filter( + (template) => (template.managedBy ?? template.provider) === 'wso2' + ), + [templatesResponse.list] + ); + const filteredBuiltIn = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return builtInTemplates; + return builtInTemplates.filter( + (template) => + template.name.toLowerCase().includes(query) || + (template.description ?? '').toLowerCase().includes(query) + ); + }, [builtInTemplates, searchQuery]); + + const renderCard = (template: ProviderTemplate) => { + const templateId = template.id; + const overviewPath = `${templatesBase}/${templateId}`; + const logoSrc = resolveTemplateLogo(template); + const hasLogo = Boolean(logoSrc); + return ( + navigate(overviewPath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigate(overviewPath); + } + }} + sx={{ + height: '100%', + width: '100%', + cursor: 'pointer', + // Disabled templates are dimmed but still clickable (to re-enable). + opacity: template.enabled === false ? 0.55 : 1, + transition: 'box-shadow 0.2s ease, opacity 0.2s ease', + '&.MuiCard-root:hover': { boxShadow: 3 }, + '&:focus-visible': { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: '2px', + }, + }} + > + + + + {!hasLogo ? getInitials(template.name) : null} + + + + {truncateProviderDisplayName(template.name)} + + + {template.description?.trim() + ? truncateProviderDisplayName(template.description, 70) + : 'No description'} + + + + + + + + {formatRelativeTime(template.createdAt ?? template.updatedAt)} + + + {template.enabled === false ? ( + + ) : null} + + + + ); + }; + + const cardGridSx = { + display: 'grid', + gap: 2, + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', + } as const; + + const Wrapper: React.ElementType = embedded ? React.Fragment : PageContent; + return ( + + + + + + + + + + + + {templates.length > 0 && ( + + )} + + + {isLoading && ( + + {[0, 1, 2].map((key) => ( + + ))} + + )} + + {error && !isLoading && ( + + + + )} + + {!isLoading && + !error && + templates.length === 0 && + builtInTemplates.length === 0 && ( + + + + + + + + + + + + )} + + {!isLoading && + !error && + (templates.length > 0 || builtInTemplates.length > 0) && ( + <> + {templates.length > 0 && ( + + { + setSearchQuery(event.target.value); + setCustomPage(1); + }} + data-cyid="provider-template-search-input" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + )} + + + Custom LLM Providers + + + Templates you create to connect your own LLM providers. + + + {filteredTemplates.length > 0 ? ( + <> + + {pagedCustomTemplates.map((template) => + renderCard(template) + )} + + {customTotalPages > 1 && ( + + setCustomPage(currentCustomPage - 1)} + aria-label="Previous page" + > + + + + Page {currentCustomPage} of {customTotalPages} + + = customTotalPages} + onClick={() => setCustomPage(currentCustomPage + 1)} + aria-label="Next page" + > + + + + )} + + ) : templates.length === 0 ? ( + + + + `color-mix(in srgb, ${theme.palette.primary.main} 12%, transparent)`, + border: '1px solid', + borderColor: (theme) => + `color-mix(in srgb, ${theme.palette.primary.main} 30%, transparent)`, + }} + > + + + + + + + + + + + + ) : ( + + No custom templates match your search. + + )} + + {builtInTemplates.length > 0 && ( + <> + + + Built-in Templates + + + Built-in LLM providers, included by default. + + {filteredBuiltIn.length > 0 ? ( + + {filteredBuiltIn.map((template) => renderCard(template))} + + ) : ( + + No built-in templates match your search. + + )} + + )} + + )} + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx new file mode 100644 index 0000000000..911b331773 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, useState } from 'react'; +import { + Box, + Collapse, + FormControl, + FormControlLabel, + FormLabel, + IconButton, + MenuItem, + Select, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronDown, ChevronUp, Info } from '@wso2/oxygen-ui-icons-react'; +import { ResourceRow } from '../../../../Components/ResourceView'; +import type { ResourceMapping } from '../../../../utils/types'; +import { + fromTokenConfig, + toTokenConfig, + TOKEN_FIELDS, + TOKEN_LOCATIONS, + type TokenConfig, + type TokenFieldKey, +} from '../../../../utils/providerTemplateFields'; + +interface DiscoveredResource { + method: string; + path: string; + summary?: string; +} + +function extractResources(spec: Record | null): DiscoveredResource[] { + const paths = (spec?.paths ?? null) as Record< + string, + Record + > | null; + if (!paths || typeof paths !== 'object') return []; + const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + const all: DiscoveredResource[] = []; + Object.keys(paths).forEach((path) => { + const ops = paths[path]; + if (!ops || typeof ops !== 'object') return; + Object.keys(ops).forEach((m) => { + if (!METHODS.has(m.toLowerCase())) return; + all.push({ + method: m.toUpperCase(), + path, + summary: ops[m]?.summary || ops[m]?.description || undefined, + }); + }); + }); + all.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); + // Resource overrides are path-scoped on the backend; deduplicate to one row per path. + const seen = new Set(); + return all.filter((r) => { + if (seen.has(r.path)) return false; + seen.add(r.path); + return true; + }); +} + +function TokenFieldsEditor({ + tokens, + onChange, +}: { + tokens: TokenConfig; + onChange: (field: TokenFieldKey, key: 'identifier' | 'location', value: string) => void; +}) { + return ( + + {TOKEN_FIELDS.map(({ key, label }) => ( + + {label} + + onChange(key, 'identifier', e.target.value)} + /> + + + + ))} + + ); +} + +interface TemplateTokenMappingProps { + defaultTokens: TokenConfig; + onChangeDefaultToken: ( + field: TokenFieldKey, + key: 'identifier' | 'location', + value: string + ) => void; + resourceMappings: ResourceMapping[]; + onChangeResourceMappings: (next: ResourceMapping[]) => void; + spec: Record | null; + hidePerResource?: boolean; +} + +export default function TemplateTokenMapping({ + defaultTokens, + onChangeDefaultToken, + resourceMappings, + onChangeResourceMappings, + spec, + hidePerResource = false, +}: TemplateTokenMappingProps) { + const [scope, setScope] = useState<'default' | 'resource'>('default'); + const [search, setSearch] = useState(''); + const [openKey, setOpenKey] = useState(null); + + const resources = useMemo(() => extractResources(spec), [spec]); + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return resources; + return resources.filter((r) => + `${r.method} ${r.path} ${r.summary ?? ''}`.toLowerCase().includes(q) + ); + }, [resources, search]); + + const mappingFor = (path: string) => + resourceMappings.find((m) => m.resource === path); + + const isOverridden = (path: string) => Boolean(mappingFor(path)); + + const toggleOverride = (path: string, rowKey: string, enabled: boolean) => { + if (enabled) { + if (!isOverridden(path)) { + const seeded: ResourceMapping = { + resource: path, + ...fromTokenConfig(defaultTokens), + }; + onChangeResourceMappings([...resourceMappings, seeded]); + } + setOpenKey(rowKey); + } else { + onChangeResourceMappings(resourceMappings.filter((m) => m.resource !== path)); + setOpenKey((prev) => (prev === rowKey ? null : prev)); + } + }; + + // Update a single token field of a resource override. + const updateResourceToken = ( + path: string, + field: TokenFieldKey, + key: 'identifier' | 'location', + value: string + ) => { + const existing = mappingFor(path); + const cfg = toTokenConfig(existing); + cfg[field] = { ...cfg[field], [key]: value }; + const updated: ResourceMapping = { resource: path, ...fromTokenConfig(cfg) }; + onChangeResourceMappings( + resourceMappings.some((m) => m.resource === path) + ? resourceMappings.map((m) => (m.resource === path ? updated : m)) + : [...resourceMappings, updated] + ); + }; + + const effectiveScope = hidePerResource ? 'default' : scope; + + return ( + + {!hidePerResource && ( + v && setScope(v)} + sx={{ mb: 2 }} + > + Global + Per Resource + + )} + + {effectiveScope === 'default' ? ( + <> + + + + Applies to all resources unless overridden per resource. + + + + + ) : ( + + + + + Turn on a resource to give it its own mapping. + + + + setSearch(e.target.value)} + /> + + {filtered.length === 0 ? ( + + {resources.length === 0 + ? 'No resources found. Add an OpenAPI specification on the Connection tab.' + : 'No resources match your search.'} + + ) : ( + + {filtered.map((r) => { + const key = `${r.method}-${r.path}`; + const overridden = isOverridden(r.path); + const isOpen = openKey === key; + return ( + + setOpenKey(isOpen ? null : key)} + trailing={ + + e.stopPropagation()} + control={ + + toggleOverride(r.path, key, e.target.checked) + } + /> + } + /> + { + e.stopPropagation(); + setOpenKey(isOpen ? null : key); + }} + > + {isOpen ? : } + + + } + /> + + + {overridden ? ( + + updateResourceToken(r.path, field, k, value) + } + /> + ) : ( + + Uses the default mapping. Turn on{' '} + Override to change it. + + )} + + + + ); + })} + + )} + + )} + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDefinitionTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDefinitionTab.tsx index 9ef616bc43..eb9f1b8bae 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDefinitionTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyDefinitionTab.tsx @@ -277,7 +277,7 @@ export default function LLMProxyDefinitionTab() { Import Specification @@ -290,24 +290,28 @@ export default function LLMProxyDefinitionTab() { }} placeholder="Paste OpenAPI URL or upload a file" /> - - - + + Or + - Or -