From 4d81b1301d9e9f08ef79450d55b918e6402f7d4a Mon Sep 17 00:00:00 2001 From: Tharanidk Date: Sun, 21 Jun 2026 20:52:08 +0530 Subject: [PATCH 01/20] Add LLM provider template implementations --- platform-api/src/api/generated.go | 23 +- platform-api/src/internal/constants/error.go | 1 + .../src/internal/database/schema.postgres.sql | 5 +- platform-api/src/internal/database/schema.sql | 5 +- .../src/internal/database/schema.sqlite.sql | 5 +- platform-api/src/internal/handler/llm.go | 111 ++- platform-api/src/internal/model/llm.go | 3 + .../src/internal/repository/interfaces.go | 6 + platform-api/src/internal/repository/llm.go | 230 ++++- platform-api/src/internal/service/llm.go | 159 +++- platform-api/src/resources/openapi.yaml | 184 +++- portals/ai-workspace/src/App.tsx | 81 +- .../src/apis/providerTemplateApis.ts | 87 +- .../ProviderTemplateContext.tsx | 11 +- .../ProviderTemplatesContext.tsx | 2 +- .../src/pages/appShell/AppSidebar.tsx | 9 +- .../src/pages/appShell/appShellMain.tsx | 8 + .../CreateProviderTemplate.tsx | 478 +++++++++++ .../CreateProviderTemplateVersion.tsx | 624 ++++++++++++++ .../providerTemplate/EditProviderTemplate.tsx | 290 +++++++ .../ProviderTemplateOverview.tsx | 803 ++++++++++++++++++ .../ProviderTemplatesList.tsx | 483 +++++++++++ .../providerTemplate/TemplateTokenMapping.tsx | 317 +++++++ .../appShellPages/proxies/LLMProxyNew.tsx | 2 +- .../proxies/LLMProxyProviderTab.tsx | 2 +- .../AddNewProvider/TemplateVersionDialog.tsx | 163 ++++ .../serviceProvider/ProvidersList.tsx | 4 +- .../serviceProvider/ServiceProviderNew.tsx | 53 +- .../ServiceProvidersSummaryCard.tsx | 2 +- .../appShell/appShellPages/settings/Main.tsx | 95 ++- .../src/utils/providerTemplateDisplay.ts | 18 + .../src/utils/providerTemplateFields.ts | 135 +++ .../src/utils/providerTemplateManifest.ts | 92 ++ portals/ai-workspace/src/utils/types.ts | 36 +- 34 files changed, 4415 insertions(+), 112 deletions(-) create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx create mode 100644 portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx create mode 100644 portals/ai-workspace/src/utils/providerTemplateFields.ts create mode 100644 portals/ai-workspace/src/utils/providerTemplateManifest.ts diff --git a/platform-api/src/api/generated.go b/platform-api/src/api/generated.go index 49d7e2af23..6e3c85e8e3 100644 --- a/platform-api/src/api/generated.go +++ b/platform-api/src/api/generated.go @@ -1762,11 +1762,17 @@ type LLMProviderTemplate struct { Description *string `json:"description,omitempty" yaml:"description,omitempty"` // Id Unique handle for the template - Id string `binding:"required" json:"id" yaml:"id"` + 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"` 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 (JSON or YAML) content stored on the template + 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 +1782,9 @@ type LLMProviderTemplate struct { // UpdatedAt Timestamp when the resource was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // Version Content version, e.g. v1.0. Defaults to v1.0 on create. Editing a template updates it in place; supply a new unique version only when creating a new version via POST /llm-provider-templates/{id}/versions. + Version *string `json:"version,omitempty" yaml:"version,omitempty"` } // LLMProviderTemplateAuth defines model for LLMProviderTemplateAuth. @@ -1796,8 +1805,14 @@ type LLMProviderTemplateListItem struct { 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"` + + // IsLatest Whether this is the latest version of the template. + IsLatest *bool `json:"isLatest,omitempty" yaml:"isLatest,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + + // Version Content version, matching the v. pattern (e.g. v1.0, v2.0). + Version *string `json:"version,omitempty" yaml:"version,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` } // LLMProviderTemplateListResponse defines model for LLMProviderTemplateListResponse. diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 23c093b6a3..87f771c244 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -150,6 +150,7 @@ 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") 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/schema.postgres.sql b/platform-api/src/internal/database/schema.postgres.sql index 0df6cd668a..6362e32879 100644 --- a/platform-api/src/internal/database/schema.postgres.sql +++ b/platform-api/src/internal/database/schema.postgres.sql @@ -308,10 +308,13 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, + openapi_spec TEXT, + version VARCHAR(40) NOT NULL DEFAULT 'v1.0', + is_latest BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle) + UNIQUE(organization_uuid, handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/database/schema.sql b/platform-api/src/internal/database/schema.sql index 1a57075c0d..f22267f5bf 100644 --- a/platform-api/src/internal/database/schema.sql +++ b/platform-api/src/internal/database/schema.sql @@ -303,10 +303,13 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, + openapi_spec TEXT, + version VARCHAR(40) NOT NULL DEFAULT 'v1.0', + is_latest BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle) + UNIQUE(organization_uuid, handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/database/schema.sqlite.sql b/platform-api/src/internal/database/schema.sqlite.sql index 08d28d9531..265b18e373 100644 --- a/platform-api/src/internal/database/schema.sqlite.sql +++ b/platform-api/src/internal/database/schema.sqlite.sql @@ -306,10 +306,13 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, + openapi_spec TEXT, + version VARCHAR(40) NOT NULL DEFAULT 'v1.0', + is_latest BOOLEAN NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle) + UNIQUE(organization_uuid, handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/handler/llm.go b/platform-api/src/internal/handler/llm.go index 9f929affb1..3a89750901 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,9 @@ 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.PUT("/llm-provider-templates/:id", h.UpdateLLMProviderTemplate) v1.DELETE("/llm-provider-templates/:id", h.DeleteLLMProviderTemplate) @@ -134,7 +138,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 +175,109 @@ 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") + + var req api.LLMProviderTemplate + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid request body")) + return + } + if req.Id == "" { + req.Id = id + } + + created, err := h.templateService.CreateVersion(orgID, id, &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", "A version with this number already exists")) + 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) +} + func (h *LLMHandler) UpdateLLMProviderTemplate(c *gin.Context) { orgID, ok := middleware.GetOrganizationFromContext(c) if !ok { diff --git a/platform-api/src/internal/model/llm.go b/platform-api/src/internal/model/llm.go index efe8916ba2..cbd4a5635a 100644 --- a/platform-api/src/internal/model/llm.go +++ b/platform-api/src/internal/model/llm.go @@ -165,6 +165,8 @@ type LLMProviderTemplate struct { Name string `json:"name" db:"name"` Description string `json:"description,omitempty" db:"description"` CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + Version string `json:"version" db:"version"` + IsLatest bool `json:"isLatest" db:"is_latest"` Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" db:"-"` PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" db:"-"` CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" db:"-"` @@ -173,6 +175,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..361f20c90d 100644 --- a/platform-api/src/internal/repository/interfaces.go +++ b/platform-api/src/internal/repository/interfaces.go @@ -227,10 +227,16 @@ 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 Delete(templateID, orgUUID string) error Exists(templateID, orgUUID string) (bool, error) diff --git a/platform-api/src/internal/repository/llm.go b/platform-api/src/internal/repository/llm.go index 7955244d92..99905bda3d 100644 --- a/platform-api/src/internal/repository/llm.go +++ b/platform-api/src/internal/repository/llm.go @@ -73,32 +73,112 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { return err } + if t.Version == "" { + t.Version = "v1.0" + } + t.IsLatest = true + query := ` INSERT INTO llm_provider_templates ( uuid, organization_uuid, handle, name, description, created_by, - configuration, created_at, updated_at + configuration, openapi_spec, version, is_latest, 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), + string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, t.CreatedAt, t.UpdatedAt, ) return err } +func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) error { + configJSON, err := json.Marshal(&llmProviderTemplateConfig{ + 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() }() + + var total, sameVersion int + var createdBy sql.NullString + err = tx.QueryRow(r.db.Rebind(` + SELECT + (SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ?), + (SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? AND version = ?), + (SELECT created_by FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? AND is_latest = ? LIMIT 1) + `), t.ID, t.OrganizationUUID, t.ID, t.OrganizationUUID, t.Version, t.ID, t.OrganizationUUID, true).Scan(&total, &sameVersion, &createdBy) + if err != nil { + return err + } + if total == 0 { + return sql.ErrNoRows + } + if sameVersion > 0 { + return constants.ErrLLMProviderTemplateVersionExists + } + + // Demote the current latest. + if _, err = tx.Exec(r.db.Rebind(` + UPDATE llm_provider_templates SET is_latest = ? WHERE handle = ? AND organization_uuid = ? AND is_latest = ? + `), false, t.ID, t.OrganizationUUID, true); 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.CreatedBy = createdBy.String + t.CreatedAt = time.Now() + t.UpdatedAt = time.Now() + + if _, err = tx.Exec(r.db.Rebind(` + INSERT INTO llm_provider_templates ( + uuid, organization_uuid, handle, name, description, created_by, + configuration, openapi_spec, version, is_latest, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + t.UUID, t.OrganizationUUID, t.ID, t.Name, t.Description, t.CreatedBy, + string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, + t.CreatedAt, t.UpdatedAt, + ); err != nil { + return err + } + + return tx.Commit() +} + 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, name, description, created_by, configuration, openapi_spec, version, is_latest, created_at, updated_at FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? - `), templateID, orgUUID) + WHERE handle = ? AND organization_uuid = ? AND is_latest = ? + `), templateID, orgUUID, true) var t model.LLMProviderTemplate var configJSON sql.NullString + var openapiSpec sql.NullString if err := row.Scan( &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, + &openapiSpec, &t.Version, &t.IsLatest, &t.CreatedAt, &t.UpdatedAt, ); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -106,6 +186,7 @@ func (r *LLMProviderTemplateRepo) GetByID(templateID, orgUUID string) (*model.LL } return nil, err } + t.OpenAPISpec = openapiSpec.String if configJSON.Valid && configJSON.String != "" { var cfg llmProviderTemplateConfig @@ -127,23 +208,66 @@ func (r *LLMProviderTemplateRepo) GetByID(templateID, orgUUID string) (*model.LL 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 + SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, 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) { + row := r.db.QueryRow(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, created_at, updated_at + FROM llm_provider_templates + WHERE handle = ? AND organization_uuid = ? AND version = ? + `), templateID, orgUUID, version) + return scanTemplateRow(row) +} + +func (r *LLMProviderTemplateRepo) ListVersions(templateID, orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { + rows, err := r.db.Query(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, created_at, updated_at + FROM llm_provider_templates + WHERE handle = ? AND organization_uuid = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), templateID, orgUUID, limit, offset) + 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) + } + return res, rows.Err() +} +func (r *LLMProviderTemplateRepo) CountVersions(templateID, orgUUID string) (int, error) { + var count int + if err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ?`), templateID, 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( + var openapiSpec sql.NullString + if err := s.Scan( &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, + &openapiSpec, &t.Version, &t.IsLatest, &t.CreatedAt, &t.UpdatedAt, ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } return nil, err } - + t.OpenAPISpec = openapiSpec.String if configJSON.Valid && configJSON.String != "" { var cfg llmProviderTemplateConfig if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { @@ -158,19 +282,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) { + // Catalog lists one entry per template — the latest version only. 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, name, description, created_by, configuration, openapi_spec, version, is_latest, created_at, updated_at FROM llm_provider_templates - WHERE organization_uuid = ? + WHERE organization_uuid = ? 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, true}, pageArgs...)...) if err != nil { return nil, err } @@ -178,34 +313,47 @@ 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) { + rows, err := r.db.Query(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, created_at, updated_at + FROM llm_provider_templates + WHERE organization_uuid = ? + ORDER BY handle ASC, created_at DESC + LIMIT ? OFFSET ? + `), orgUUID, limit, offset) + 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() @@ -225,14 +373,14 @@ func (r *LLMProviderTemplateRepo) Update(t *model.LLMProviderTemplate) error { query := ` UPDATE llm_provider_templates - SET name = ?, description = ?, configuration = ?, updated_at = ? - WHERE handle = ? AND organization_uuid = ? + SET name = ?, description = ?, configuration = ?, openapi_spec = ?, updated_at = ? + WHERE handle = ? AND organization_uuid = ? AND is_latest = ? ` result, err := r.db.Exec(r.db.Rebind(query), t.Name, t.Description, - string(configJSON), + string(configJSON), t.OpenAPISpec, t.UpdatedAt, - t.ID, t.OrganizationUUID, + t.ID, t.OrganizationUUID, true, ) if err != nil { return err @@ -274,7 +422,7 @@ 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 is_latest = ?`), orgUUID, true).Scan(&count); err != nil { return 0, err } return count, nil diff --git a/platform-api/src/internal/service/llm.go b/platform-api/src/internal/service/llm.go index 0202bcf8d7..1e7aa02210 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" @@ -130,6 +131,7 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. Name: req.Name, Description: utils.ValueOrEmpty(req.Description), CreatedBy: createdBy, + OpenAPISpec: utils.ValueOrEmpty(req.Openapi), Metadata: mapTemplateMetadataAPI(req.Metadata), PromptTokens: mapExtractionIdentifierAPI(req.PromptTokens), CompletionTokens: mapExtractionIdentifierAPI(req.CompletionTokens), @@ -154,12 +156,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 +181,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 } @@ -219,6 +216,7 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM ID: handle, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), + OpenAPISpec: utils.ValueOrEmpty(req.Openapi), Metadata: mapTemplateMetadataAPI(req.Metadata), PromptTokens: mapExtractionIdentifierAPI(req.PromptTokens), CompletionTokens: mapExtractionIdentifierAPI(req.CompletionTokens), @@ -233,6 +231,7 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM } m.ResourceMappings = resourceMappings + // Editing a template updates the latest version in place (no new version). if err := s.repo.Update(m); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, constants.ErrLLMProviderTemplateNotFound @@ -250,6 +249,114 @@ 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 (s *LLMProviderTemplateService) CreateVersion(orgUUID, handle string, req *api.LLMProviderTemplate) (*api.LLMProviderTemplate, error) { + if handle == "" || req == nil { + return nil, constants.ErrInvalidInput + } + if req.Id != "" && req.Id != handle { + return nil, constants.ErrInvalidInput + } + if req.Name == "" { + return nil, constants.ErrInvalidInput + } + version, ok := normalizeTemplateVersion(utils.ValueOrEmpty(req.Version)) + if !ok { + return nil, constants.ErrInvalidInput + } + + m := &model.LLMProviderTemplate{ + OrganizationUUID: orgUUID, + ID: handle, + Name: req.Name, + Description: utils.ValueOrEmpty(req.Description), + 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 +} + func (s *LLMProviderTemplateService) Delete(orgUUID, handle string) error { if handle == "" { return constants.ErrInvalidInput @@ -1508,15 +1615,37 @@ func mapResourceWiseRateLimitingAPIToModel(in *api.ResourceWiseRateLimitingConfi } } +func templateListItem(t *model.LLMProviderTemplate) api.LLMProviderTemplateListItem { + id := t.ID + name := t.Name + version := t.Version + isLatest := t.IsLatest + return api.LLMProviderTemplateListItem{ + Id: &id, + Name: &name, + Description: utils.StringPtrIfNotEmpty(t.Description), + CreatedBy: utils.StringPtrIfNotEmpty(t.CreatedBy), + Version: &version, + IsLatest: &isLatest, + CreatedAt: utils.TimePtr(t.CreatedAt), + UpdatedAt: utils.TimePtr(t.UpdatedAt), + } +} + func mapTemplateModelToAPI(m *model.LLMProviderTemplate) *api.LLMProviderTemplate { if m == nil { return nil } + version := m.Version + isLatest := m.IsLatest return &api.LLMProviderTemplate{ Id: m.ID, Name: m.Name, Description: utils.StringPtrIfNotEmpty(m.Description), CreatedBy: utils.StringPtrIfNotEmpty(m.CreatedBy), + Version: &version, + IsLatest: &isLatest, + Openapi: utils.StringPtrIfNotEmpty(m.OpenAPISpec), Metadata: mapTemplateMetadataModelToAPI(m.Metadata), PromptTokens: mapExtractionIdentifierModelToAPI(m.PromptTokens), CompletionTokens: mapExtractionIdentifierModelToAPI(m.CompletionTokens), diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index f1f96fd212..c1a7b8d188 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: @@ -2824,6 +2837,142 @@ paths: '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 handle is taken from the path; the body's `id` is optional + and defaults to the path value (a mismatching id is rejected). + 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/LLMProviderTemplate' + 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' + /llm-providers: post: summary: Create a new LLM provider @@ -9852,6 +10001,31 @@ components: description: Username of the creator readOnly: true example: jane.doe + version: + type: string + description: | + Content version, e.g. v1.0. Defaults to v1.0 on create. Editing a + template updates it in place; supply a new unique version only when + creating a new version via POST /llm-provider-templates/{id}/versions. + pattern: '^[vV]\d+\.\d+$' + example: v1.0 + isLatest: + type: boolean + description: Whether this is the latest version of the template. + 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: @@ -9896,6 +10070,14 @@ components: 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 createdAt: type: string format: date-time 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/apis/providerTemplateApis.ts b/portals/ai-workspace/src/apis/providerTemplateApis.ts index c17c097eaf..6510f48966 100644 --- a/portals/ai-workspace/src/apis/providerTemplateApis.ts +++ b/portals/ai-workspace/src/apis/providerTemplateApis.ts @@ -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 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..b0e013df85 100644 --- a/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx +++ b/portals/ai-workspace/src/contexts/llmProvider/providerTemplate/ProviderTemplatesContext.tsx @@ -118,7 +118,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..5c398669dc 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,7 +303,7 @@ export default function AppSidebar({ - {/* + @@ -313,14 +314,14 @@ export default function AppSidebar({ - + {/* 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..811c621c1d --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx @@ -0,0 +1,478 @@ +/* + * 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, + InputAdornment, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronDown, ChevronLeft, Tag } 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, + 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; +} + +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 [tokenConfig, setTokenConfig] = useState(() => ({ + ...DEFAULT_TOKEN_CONFIG, + })); + 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; + setIsFetchingSpec(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`); + const text = await res.text(); + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(''); + setSpecContent(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + } + } catch { + showSnackbar('Failed to fetch 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(); + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(file.name); + setSpecContent(text); + setOpenapiSpecUrl(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification uploaded. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Read the spec, but no server URL was found — enter the endpoint 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 isNameValid = name.trim().length > 0 && name.length <= MAX_NAME_LENGTH; + const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH; + const isEndpointValid = endpointUrl.trim().length > 0; + const isFormValid = isNameValid && isDescriptionValid && isEndpointValid; + + 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: toTemplateId(name), + name: name.trim(), + description: description.trim() || undefined, + ...tokenFields, + metadata: Object.keys(metadata).length ? metadata : undefined, + // Uploaded spec content (empty when a spec URL is used instead). + 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)} + 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})` + : '' + } + /> + + + + {/* A new template always starts at v1 and read-only*/} + + + + + + + + + ), + }, + }} + helperText="Initial version" + /> + + + + + + + + + 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); + setSpecFileName(''); + setSpecContent(''); + }} + placeholder="https://api.openai.com/openapi.json" + /> + + + Or + + + + + + + + + + + + + setEndpointUrl(e.target.value)} + placeholder="https://api.openai.com" + /> + + + + + {/* Advanced: token & model extraction mapping (collapsed by default, + pre-filled with the OpenAI defaults). */} + + }> + + Advanced + + Token & model mapping — defaults to OpenAI values; change if + your provider differs. + + + + + + {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..810bb40976 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx @@ -0,0 +1,624 @@ +/* + * 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, + Alert, + Box, + Button, + CircularProgress, + Divider, + FormControl, + FormLabel, + Grid, + InputAdornment, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { + ChevronDown, + ChevronLeft, + GitBranch, + Tag, +} 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, + toTokenConfig, + TOKEN_FIELDS, + TOKEN_LOCATIONS, + type TokenConfig, + type TokenFieldKey, +} from '../../../../utils/providerTemplateFields'; + +const MAX_DESCRIPTION_LENGTH = 200; + +// Versions follow the v. pattern (e.g. v1.0). +const VERSION_PATTERN = /^[vV]\d+\.\d+$/; + +// Suggest the next version by bumping the major of the current version +// (v1.0 -> v2.0). Falls back to v2.0 when the current can't be parsed. The +// suggestion is only a default — the field is editable. +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`; +} + +// 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; +} + +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}`; + + // The current latest version; the new version is user-entered (prefilled with + // a suggested bump) and must be unique. + const currentVersion = template.version ?? 'v1.0'; + const [version, setVersion] = useState(() => suggestNextVersion(template.version)); + + // Description and the OpenAPI spec are the things a new version typically + // changes; pre-fill from the source version. + 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 ?? ''); + // Token & model mappings copied from the source version (adjust as needed). + 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; + setIsFetchingSpec(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`); + const text = await res.text(); + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(''); + setSpecContent(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + } + } catch { + showSnackbar('Failed to fetch 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(); + const serverUrl = parseSpecServerUrl(text); + setSpecFileName(file.name); + setSpecContent(text); + setOpenapiSpecUrl(''); + if (serverUrl) { + setEndpointUrl(serverUrl); + showSnackbar('Specification uploaded. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Read the spec, but no server URL was found — enter the endpoint manually.', 'info'); + } + } catch { + showSnackbar('Failed to read the specification file.', 'error'); + } finally { + e.target.value = ''; + } + }; + + const isDescriptionValid = description.length <= MAX_DESCRIPTION_LENGTH; + const isEndpointValid = endpointUrl.trim().length > 0; + const isVersionValid = VERSION_PATTERN.test(version.trim()); + const isFormValid = isDescriptionValid && isEndpointValid && isVersionValid; + + const handleSubmit = async (event?: React.FormEvent) => { + if (event) event.preventDefault(); + const organizationId = currentOrganization?.uuid; + if (!templateId || !organizationId || !isFormValid || isSubmitting) return; + + const tokenFields = fromTokenConfig(tokenConfig); + + // Preserve connection metadata (endpoint + spec URL); the new version keeps + // the source's auth/logo and updates endpoint/spec from this form. + 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; + + // POST /{id}/versions creates a NEW version with the supplied version + // string. Carry forward resource mappings; override the fields edited here. + const payload: ProviderTemplate = { + id: templateId, + 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 { + await providerTemplateApis.createProviderTemplateVersion( + templateId, + payload, + organizationId, + PLATFORM_API_BASE_URL + ); + await refreshTemplates(); + showSnackbar(`New version ${version.trim()} created successfully.`, 'success'); + navigate(overviewPath); + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to create new version.', + 'error' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + + + + + + + + + + } sx={{ mb: 2 }}> + {template.name} }} + /> + + + + + {/* 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} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + helperText={ + version.trim().length > 0 && !isVersionValid + ? 'Use the v. format, e.g. v2.0' + : `Latest is ${currentVersion}` + } + /> + + + + + + + + + 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); + setSpecFileName(''); + setSpecContent(''); + }} + placeholder="https://api.openai.com/openapi.json" + /> + + + Or + + + + + + + + + + + + + setEndpointUrl(e.target.value)} + placeholder="https://api.openai.com" + /> + + + + + {/* Advanced: token & model extraction mapping copied from the source + version (collapsed by default). */} + + }> + + Advanced + + Token & model mapping — copied from {currentVersion}; change + if this version differs. + + + + + + {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..8cbec738f8 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx @@ -0,0 +1,290 @@ +/* + * 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 ( + + + + + + + + + {template.id} + + + + + + + + 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})` + : '' + } + /> + + + + + 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..5f16f67845 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -0,0 +1,803 @@ +/* + * 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, useParams } from 'react-router-dom'; +import YAML from 'yaml'; +import { + Avatar, + Box, + Button, + Card, + CircularProgress, + Divider, + FormControl, + FormLabel, + Grid, + IconButton, + Menu, + MenuItem, + PageContent, + Stack, + Tab, + Tabs, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { Check, ChevronDown, ChevronLeft, Clock, Download, Edit, GitBranch } 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 { PLATFORM_API_BASE_URL } from '../../../../config.env'; +import { buildOrgPath } from '../../../../utils/projectRouting'; +import { + DEFAULT_AUTH_CONFIG, + fromTokenConfig, + 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 DetailRow({ label, value }: { label: string; value?: string }) { + return ( + + + {label} + + + {value?.trim() ? value : '—'} + + + ); +} + +export default function ProviderTemplateOverview() { + const { templateId } = useParams<{ templateId: string }>(); + const { currentOrganization } = useAppShell(); + const { updateTemplate } = 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 [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); + + 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; + + let isMounted = true; + providerTemplateApis + .getProviderTemplateVersions(templateId, organizationId, PLATFORM_API_BASE_URL) + .then((list) => { + if (isMounted && list.length) setVersions(list); + }) + .catch(() => { + /* switcher gracefully degrades to the single current version */ + }); + + 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; + setSelectedVersion(version); + setIsLoading(true); + try { + const isLatest = versions.find((v) => v.version === version)?.isLatest; + const full = isLatest + ? await providerTemplateApis.getProviderTemplate( + templateId, + organizationId, + PLATFORM_API_BASE_URL + ) + : await providerTemplateApis.getProviderTemplateVersion( + templateId, + version, + organizationId, + PLATFORM_API_BASE_URL + ); + setTemplate(full); + } catch (err) { + showSnackbar( + err instanceof Error ? err.message : 'Failed to load version.', + 'error' + ); + } finally { + setIsLoading(false); + } + }; + + // Seed (and reset) the editable drafts whenever the loaded template changes. + const seedDrafts = React.useCallback((t: ProviderTemplate) => { + setEndpointUrl(t.metadata?.endpointUrl ?? ''); + 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 ?? []); + setIsDirty(false); + }, []); + + 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; + } + + 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, + description: template.description, + ...fromTokenConfig(defaultTokens), + metadata: Object.keys(metadata).length ? metadata : undefined, + resourceMappings: resourceMappings.length + ? { resources: resourceMappings } + : undefined, + openapi: template.openapi, + }; + + 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( + template?.openapi?.trim() ? template.openapi : urlSpecText + ), + [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 createdTime = template.createdAt ?? template.updatedAt; + + return ( + + {backButton} + + + {/* Header card */} + + + + + {!hasLogo ? getInitials(template.name) : null} + + + + {template.name} + + + + + + {/* Version switcher — a pill that opens the versions menu. */} + + setVersionMenuAnchor(null)} + slotProps={{ paper: { sx: { minWidth: 260 } } }} + > + + Versions + + {(versions.length ? versions : [template]).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} + + + {template.createdBy ? ( + + + + ) : null} + + + + {formatRelativeTime(createdTime)} + + + + + + + + + + + {/* Tabbed card */} + + setTabIndex(value)} + variant="scrollable" + allowScrollButtonsMobile + > + {tabs.map((label) => ( + + ))} + + + + {/* Overview */} + + } spacing={0.5}> + + + + + + + + + OpenAPI Resources + + {!template.openapi?.trim() && + !template.metadata?.openapiSpecUrl?.trim() ? ( + + No available resources. Add an OpenAPI specification (content + or URL) on the Connection tab to see resources. + + ) : isSpecLoading ? ( + + + + ) : !parsedSpec ? ( + + Failed to load the OpenAPI specification + {template.metadata?.openapiSpecUrl?.trim() + ? ' from the configured URL.' + : '.'} + + ) : ( + + + + )} + + + + + + + + Endpoint URL + { + setEndpointUrl(e.target.value); + setIsDirty(true); + }} + placeholder="https://api.openai.com" + error={!endpointUrl.trim()} + helperText={ + !endpointUrl.trim() ? 'Endpoint URL is required.' : '' + } + /> + + + + + OpenAPI Spec URL + { + setOpenapiSpecUrl(e.target.value); + setIsDirty(true); + }} + placeholder="https://api.openai.com/openapi.json" + /> + {template.openapi?.trim() ? ( + + An OpenAPI spec is stored inline (uploaded) —{' '} + {(template.openapi.length / 1024).toFixed(1)} KB. It powers + the resources above. Setting a URL here references a spec by + link instead. + + ) : null} + + + + + Logo URL + { + setLogoUrlField(e.target.value); + setIsDirty(true); + }} + placeholder="https://cdn.example.com/logos/openai.svg" + /> + + + + + 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} + /> + + + + + + + + + {isDirty ? 'You have unsaved changes.' : ''} + + + + + + + + + + + ); +} 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..587afb34d3 --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx @@ -0,0 +1,483 @@ +/* + * 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, + Divider, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + PageContent, + PageTitle, + Skeleton, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { Clock, Plus, Search, 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 { buildOrgPath } from '../../../../utils/projectRouting'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; +import ErrorAlert from '../../../../Components/common/ErrorAlert'; +import { isBuiltInProviderTemplate } 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(); + if (fromMeta) return fromMeta; + const id = (template.id ?? '').toLowerCase(); + return PROVIDER_LOGO_MAP[id]; +} + +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, + deleteTemplate, + refreshTemplates, + } = useProviderTemplates(); + + const showSnackbar = useAIWorkspaceSnackbar(); + const [searchQuery, setSearchQuery] = useState(''); + const [deleteTarget, setDeleteTarget] = useState<{ + id: string; + name: string; + } | null>(null); + + const templates = useMemo( + () => + templatesResponse.list.filter( + (template) => !isBuiltInProviderTemplate(template.id) + ), + [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]); + + const builtInTemplates = useMemo( + () => + templatesResponse.list.filter((template) => + isBuiltInProviderTemplate(template.id) + ), + [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 handleDeleteConfirm = async () => { + if (!deleteTarget) return; + try { + await deleteTemplate(deleteTarget.id); + showSnackbar('Template deleted successfully.', 'success'); + setDeleteTarget(null); + } catch { + showSnackbar('Failed to delete template. Please try again.', 'error'); + } + }; + + const renderCard = (template: ProviderTemplate, deletable: boolean) => { + const templateId = template.id ?? template.name; + 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', + transition: 'box-shadow 0.2s ease', + '&.MuiCard-root:hover': { boxShadow: 3 }, + '&:focus-visible': { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: '2px', + }, + }} + > + + + + {!hasLogo ? getInitials(template.name) : null} + + + + {template.name} + + + {template.description?.trim() || 'No description'} + + + + + + + + {formatRelativeTime(template.createdAt ?? template.updatedAt)} + + + + {deletable && ( + { + event.stopPropagation(); + setDeleteTarget({ id: templateId, name: template.name }); + }} + aria-label={`Delete ${template.name}`} + data-cyid="delete-provider-template-button" + > + + + )} + + + + ); + }; + + 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) && ( + <> + + setSearchQuery(event.target.value)} + data-cyid="provider-template-search-input" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + {filteredTemplates.length > 0 ? ( + + {filteredTemplates.map((template) => renderCard(template, true))} + + ) : ( + + {templates.length === 0 + ? 'No custom templates yet.' + : 'No custom templates match your search.'} + + )} + + {builtInTemplates.length > 0 && ( + <> + + + Built-in templates + + + Provided out of the box. View their details, or base a provider + on them. + + {filteredBuiltIn.length > 0 ? ( + + {filteredBuiltIn.map((template) => renderCard(template, false))} + + ) : ( + + No built-in templates match your search. + + )} + + )} + + )} + + setDeleteTarget(null)} + > + + Are you sure you want to delete the template{' '} + '{deleteTarget?.name ?? ''}'? + + + + This action is irreversible. + + + + + + + + + ); +} 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..7f520e3eba --- /dev/null +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx @@ -0,0 +1,317 @@ +/* + * 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; +} + +// Extract method/path/summary entries from a parsed OpenAPI spec. +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 out: 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; + out.push({ + method: m.toUpperCase(), + path, + summary: ops[m]?.summary || ops[m]?.description || undefined, + }); + }); + }); + out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); + return out; +} + +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; +} + +export default function TemplateTokenMapping({ + defaultTokens, + onChangeDefaultToken, + resourceMappings, + onChangeResourceMappings, + spec, +}: 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, enabled: boolean) => { + if (enabled) { + if (isOverridden(path)) return; + const seeded: ResourceMapping = { + resource: path, + ...fromTokenConfig(defaultTokens), + }; + onChangeResourceMappings([...resourceMappings, seeded]); + setOpenKey(path); + } else { + onChangeResourceMappings(resourceMappings.filter((m) => m.resource !== path)); + } + }; + + // 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] + ); + }; + + return ( + + v && setScope(v)} + sx={{ mb: 2 }} + > + Default + Per Resource + + + {scope === 'default' ? ( + <> + + + + Default token & model extraction applied to all resources unless + overridden under Per Resource. Defaults follow OpenAI. + + + + + ) : ( + + + + + Toggle a resource on to override its token & model extraction. + Resources left off inherit the default 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, e.target.checked) + } + /> + } + /> + { + e.stopPropagation(); + setOpenKey(isOpen ? null : key); + }} + > + {isOpen ? : } + + + } + /> + + + {overridden ? ( + + updateResourceToken(r.path, field, k, value) + } + /> + ) : ( + + Inherits the default token mapping. Turn on{' '} + Override to customize it for this + resource. + + )} + + + + ); + })} + + )} + + )} + + ); +} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx index 6627228868..05466fbf78 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyNew.tsx @@ -593,7 +593,7 @@ function LLMProxyNewContent({ setEndpointUrl(e.target.value)} placeholder="https://api.openai.com" + error={endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl)} + helperText={ + endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl) + ? 'Enter a valid http(s) URL.' + : '' + } /> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx index 5f16f67845..ff8d98d0d7 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -49,9 +49,11 @@ 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 { truncateProviderDisplayName } from '../../../../utils/providerTemplateDisplay'; import { DEFAULT_AUTH_CONFIG, fromTokenConfig, + isValidHttpUrl, toTokenConfigWithDefaults, type TokenConfig, type TokenFieldKey, @@ -105,21 +107,17 @@ function parseOpenApiSpec(text: string): Record | null { } } -function DetailRow({ label, value }: { label: string; value?: string }) { - return ( - - - {label} - - - {value?.trim() ? value : '—'} - - - ); +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() { @@ -154,6 +152,12 @@ export default function ProviderTemplateOverview() { 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 listPath = buildOrgPath(currentOrganization, '/settings/llm-provider-templates'); @@ -191,7 +195,12 @@ export default function ProviderTemplateOverview() { providerTemplateApis .getProviderTemplateVersions(templateId, organizationId, PLATFORM_API_BASE_URL) .then((list) => { - if (isMounted && list.length) setVersions(list); + if (isMounted && list.length) { + setVersions(list); + // Default the switcher to the latest version. + const latest = list.find((v) => v.isLatest) ?? list[0]; + if (latest?.version) setSelectedVersion(latest.version); + } }) .catch(() => { /* switcher gracefully degrades to the single current version */ @@ -246,9 +255,70 @@ export default function ProviderTemplateOverview() { setValuePrefix(t.metadata?.auth?.valuePrefix ?? DEFAULT_AUTH_CONFIG.valuePrefix); setDefaultTokens(toTokenConfigWithDefaults(t)); setResourceMappings(t.resourceMappings?.resources ?? []); + setSpecContent(t.openapi ?? ''); + setSpecFileName(''); setIsDirty(false); }, []); + // Fetch & validate a spec from the entered URL; fills the endpoint from its + // servers. URL mode references the spec by link (clears inline content). + 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(''); + setIsDirty(true); + const server = specServerUrl(text); + if (server) { + setEndpointUrl(server); + showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + } + } catch { + showSnackbar('Failed to fetch 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. Endpoint URL filled from servers.', 'success'); + } else { + showSnackbar('Read the spec, but no server URL was found — enter the endpoint manually.', 'info'); + } + } catch { + showSnackbar('Failed to read the specification file.', 'error'); + } finally { + e.target.value = ''; + } + }; + useEffect(() => { if (template) seedDrafts(template); }, [template, seedDrafts]); @@ -297,6 +367,14 @@ export default function ProviderTemplateOverview() { 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(); @@ -317,7 +395,8 @@ export default function ProviderTemplateOverview() { resourceMappings: resourceMappings.length ? { resources: resourceMappings } : undefined, - openapi: template.openapi, + // Inline spec content (uploaded/pasted); empty when referenced by URL. + openapi: specContent.trim() ? specContent : undefined, }; setIsSaving(true); @@ -414,7 +493,7 @@ export default function ProviderTemplateOverview() { const logoUrl = metadata?.logoUrl?.trim(); const hasLogo = Boolean(logoUrl); const description = template.description?.trim() || 'No description'; - const createdTime = template.createdAt ?? template.updatedAt; + const lastUpdated = template.updatedAt ?? template.createdAt; return ( @@ -453,7 +532,9 @@ export default function ProviderTemplateOverview() { - {template.name} + + {truncateProviderDisplayName(template.name)} + @@ -523,25 +604,26 @@ export default function ProviderTemplateOverview() { - - {description} + + {description === 'No description' + ? description + : truncateProviderDisplayName(description, 70)} - - {template.createdBy ? ( - - - - ) : null} - - - - {formatRelativeTime(createdTime)} - - + + + + + + + {lastUpdated ? formatRelativeTime(lastUpdated) : '—'} + @@ -578,20 +660,7 @@ export default function ProviderTemplateOverview() { {/* Overview */} - } spacing={0.5}> - - - - - - - + OpenAPI Resources @@ -655,35 +724,89 @@ export default function ProviderTemplateOverview() { setIsDirty(true); }} placeholder="https://api.openai.com" - error={!endpointUrl.trim()} + error={!endpointUrl.trim() || !isValidHttpUrl(endpointUrl)} helperText={ - !endpointUrl.trim() ? 'Endpoint URL is required.' : '' + !endpointUrl.trim() + ? 'Endpoint URL is required.' + : !isValidHttpUrl(endpointUrl) + ? 'Enter a valid URL.' + : '' } /> - OpenAPI Spec URL - { - setOpenapiSpecUrl(e.target.value); - setIsDirty(true); - }} - placeholder="https://api.openai.com/openapi.json" + OpenAPI Specification + + { + setOpenapiSpecUrl(e.target.value); + setSpecContent(''); + setSpecFileName(''); + setIsDirty(true); + }} + placeholder="https://api.openai.com/openapi.json" + error={!isValidHttpUrl(openapiSpecUrl)} + helperText={ + !isValidHttpUrl(openapiSpecUrl) + ? 'Enter a valid URL.' + : '' + } + /> + + + Or + + + + - {template.openapi?.trim() ? ( + {specContent.trim() ? ( - An OpenAPI spec is stored inline (uploaded) —{' '} - {(template.openapi.length / 1024).toFixed(1)} KB. It powers - the resources above. Setting a URL here references a spec by - link instead. + An OpenAPI spec is stored inline + {specFileName ? ` (${specFileName})` : ''} —{' '} + {(specContent.length / 1024).toFixed(1)} KB. It powers the + resources on the Overview tab. Setting a URL here references + a spec by link instead. ) : null} @@ -699,6 +822,12 @@ export default function ProviderTemplateOverview() { setIsDirty(true); }} placeholder="https://cdn.example.com/logos/openai.svg" + error={!isValidHttpUrl(logoUrlField)} + helperText={ + !isValidHttpUrl(logoUrlField) + ? 'Enter a valid URL.' + : '' + } /> @@ -788,7 +917,14 @@ export default function ProviderTemplateOverview() { + + + {/* Only the read-only built-in v1.0 gets the enable/disable toggle + (it can't be deleted). New versions behave like custom + templates: editable + deletable, no toggle. */} + {isReadOnly && ( + + + 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 — read-only. You can enable or + disable it, but its configuration can't be edited or deleted. + + + + )} + {/* Tabbed card */} - + {/* Built-in v1.0 is read-only — block all edits on this tab. */} + + Endpoint URL @@ -873,24 +1042,35 @@ export default function ProviderTemplateOverview() { /> - + + - { - setResourceMappings(next); - setIsDirty(true); - }} - spec={parsedSpec} - /> + + { + setResourceMappings(next); + setIsDirty(true); + }} + spec={parsedSpec} + /> + + {/* Save/cancel bar — hidden for read-only built-in versions. */} + {!isReadOnly && ( + )} + + 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 index e1812e1366..81d4417901 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx @@ -23,11 +23,8 @@ import { Box, Button, Card, + Chip, Divider, - Dialog, - DialogActions, - DialogContent, - DialogTitle, IconButton, InputAdornment, PageContent, @@ -37,13 +34,12 @@ import { TextField, Typography, } from '@wso2/oxygen-ui'; -import { ChevronLeft, ChevronRight, Clock, Plus, Search, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { ChevronLeft, ChevronRight, Clock, 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 useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; import ErrorAlert from '../../../../Components/common/ErrorAlert'; import { isBuiltInProviderTemplate, @@ -93,20 +89,10 @@ export default function ProviderTemplatesList({ const navigate = useNavigate(); const { currentOrganization } = useAppShell(); - const { - templatesResponse, - isLoading, - error, - deleteTemplate, - refreshTemplates, - } = useProviderTemplates(); + const { templatesResponse, isLoading, error, refreshTemplates } = + useProviderTemplates(); - const showSnackbar = useAIWorkspaceSnackbar(); const [searchQuery, setSearchQuery] = useState(''); - const [deleteTarget, setDeleteTarget] = useState<{ - id: string; - name: string; - } | null>(null); const templates = useMemo( () => @@ -162,18 +148,7 @@ export default function ProviderTemplatesList({ ); }, [builtInTemplates, searchQuery]); - const handleDeleteConfirm = async () => { - if (!deleteTarget) return; - try { - await deleteTemplate(deleteTarget.id); - showSnackbar('Template deleted successfully.', 'success'); - setDeleteTarget(null); - } catch { - showSnackbar('Failed to delete template. Please try again.', 'error'); - } - }; - - const renderCard = (template: ProviderTemplate, deletable: boolean) => { + const renderCard = (template: ProviderTemplate) => { const templateId = template.id ?? template.name; const overviewPath = `${templatesBase}/${templateId}`; const logoSrc = resolveTemplateLogo(template); @@ -195,7 +170,9 @@ export default function ProviderTemplatesList({ height: '100%', width: '100%', cursor: 'pointer', - transition: 'box-shadow 0.2s ease', + // 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', @@ -269,22 +246,9 @@ export default function ProviderTemplatesList({ {formatRelativeTime(template.createdAt ?? template.updatedAt)} - - {deletable && ( - { - event.stopPropagation(); - setDeleteTarget({ id: templateId, name: template.name }); - }} - aria-label={`Delete ${template.name}`} - data-cyid="delete-provider-template-button" - > - - - )} + {template.enabled === false ? ( + + ) : null} @@ -446,7 +410,7 @@ export default function ProviderTemplatesList({ <> {pagedCustomTemplates.map((template) => - renderCard(template, true) + renderCard(template) )} {customTotalPages > 1 && ( @@ -499,7 +463,7 @@ export default function ProviderTemplatesList({ {filteredBuiltIn.length > 0 ? ( - {filteredBuiltIn.map((template) => renderCard(template, false))} + {filteredBuiltIn.map((template) => renderCard(template))} ) : ( @@ -510,43 +474,6 @@ export default function ProviderTemplatesList({ )} )} - - setDeleteTarget(null)} - > - - Are you sure you want to delete the template{' '} - '{deleteTarget?.name ?? ''}'? - - - - This action is irreversible. - - - - - - - ); } diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateSelector.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateSelector.tsx index 83efb7c3a1..d9471bc3b1 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateSelector.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/ProviderTemplateSelector.tsx @@ -27,8 +27,10 @@ import { FormLabel, Grid, Stack, + Tooltip, Typography, } from '@wso2/oxygen-ui'; +import { ChevronDown, ChevronUp } from '@wso2/oxygen-ui-icons-react'; import type { ProviderTemplate, ProviderTemplatesResponse, @@ -76,6 +78,7 @@ type ProviderTemplateSelectorProps = { templatesError: Error | null; templatesResponse: ProviderTemplatesResponse; selectedTemplateId: string | null; + selectedTemplateVersion?: string; onSelectTemplate: (template: ProviderTemplate) => void; onRetryTemplates: () => void | Promise; }; @@ -85,9 +88,23 @@ export default function ProviderTemplateSelector({ templatesError, templatesResponse, selectedTemplateId, + selectedTemplateVersion, onSelectTemplate, onRetryTemplates, }: ProviderTemplateSelectorProps) { + // Collapse the grid to two rows (8 cards) with a See more / Show less toggle. + const COLLAPSED_COUNT = 8; + const [showAll, setShowAll] = React.useState(false); + const enabledTemplates = (templatesResponse?.list ?? []).filter( + (template) => template.enabled !== false + ); + const hasMore = enabledTemplates.length > COLLAPSED_COUNT; + const hiddenCount = enabledTemplates.length - COLLAPSED_COUNT; + const visibleTemplates = + hasMore && !showAll + ? enabledTemplates.slice(0, COLLAPSED_COUNT) + : enabledTemplates; + return ( @@ -126,7 +143,7 @@ export default function ProviderTemplateSelector({ }, }} > - {templatesResponse?.list?.map((template) => { + {visibleTemplates.map((template) => { const templateId = (template.id ?? '').toLowerCase(); const isComingSoon = COMING_SOON_TEMPLATE_IDS.has(templateId); const isSelected = !isComingSoon && selectedTemplateId === template.id; @@ -194,21 +211,26 @@ export default function ProviderTemplateSelector({ )} - + {truncateProviderDisplayName(template.name)} - {template.description && ( - - {truncateProviderDisplayName(template.description, 70)} - - )} + {/* Version chip only on the selected card, in the right corner. */} + {isSelected && selectedTemplateVersion ? ( + + + + ) : null} {isComingSoon ? ( ); })} + + {hasMore && ( + setShowAll((prev) => !prev)} + data-cyid="provider-template-see-more" + sx={{ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 1, + py: 1, + border: '1px dashed', + borderColor: 'divider', + cursor: 'pointer', + }} + > + {!showAll && ( + + )} + + {showAll ? 'Show less' : 'See more'} + + {showAll ? : } + + )} )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx index 6e4e019ce2..30d24af461 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx @@ -71,9 +71,11 @@ export default function TemplateVersionDialog({ .getProviderTemplateVersions(templateId, organizationId, PLATFORM_API_BASE_URL) .then((list) => { if (!isMounted) return; - setVersions(list); + // Only offer enabled versions (disabled ones are hidden everywhere). + const enabled = list.filter((v) => v.enabled !== false); + setVersions(enabled); // Default to the latest version, else the first returned. - const latest = list.find((v) => v.isLatest) ?? list[0]; + const latest = enabled.find((v) => v.isLatest) ?? enabled[0]; setSelected(latest?.version ?? ''); }) .catch((err: unknown) => { diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx index 613b4b5446..41668591a9 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx @@ -38,6 +38,8 @@ import { ChevronLeft } from '@wso2/oxygen-ui-icons-react'; import { useAppAuth } from '../../../../contexts/AppAuthContext'; import { SCOPES } from '../../../../auth/permissions'; import { useAppShell } from '../../../../contexts/AppShellContext'; +import * as providerTemplateApis from '../../../../apis/providerTemplateApis'; +import { PLATFORM_API_BASE_URL } from '../../../../config.env'; import { buildOrgPath, buildProjectPath, @@ -392,6 +394,47 @@ export default function ServiceProviderNew() { } }; + // Apply a chosen template + version to the form. + const applyTemplateSelection = ( + template: ProviderTemplate, + version: string + ) => { + setSelectedTemplateId(template.id ?? null); + setSelectedTemplateVersion(version); + setFormState((prev) => ({ ...prev, providerType: template.name })); + setOpenapiSpec(''); + setVersionDialogOpen(false); + setPendingTemplate(null); + }; + + // When a template is picked, only prompt for a version if more than one + // enabled version exists — otherwise auto-select the single version. + const handleSelectTemplate = async (template: ProviderTemplate) => { + const organizationId = currentOrganization?.uuid; + if (!template.id || !organizationId) return; + try { + const enabledVersions = ( + await providerTemplateApis.getProviderTemplateVersions( + template.id, + organizationId, + PLATFORM_API_BASE_URL + ) + ).filter((v) => v.enabled !== false); + if (enabledVersions.length <= 1) { + const only = enabledVersions[0]; + applyTemplateSelection( + template, + only?.version ?? template.version ?? 'v1.0' + ); + return; + } + } catch { + // Couldn't load versions — fall back to the picker. + } + setPendingTemplate(template); + setVersionDialogOpen(true); + }; + return ( diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx index 49a6131f84..9ad9cacf3b 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderNew.tsx @@ -351,9 +351,6 @@ export default function ServiceProviderNew() { version: formState.version.trim(), context: formState.context.trim() || '/', template: selectedTemplateId, - ...(selectedTemplateVersion - ? { templateVersion: selectedTemplateVersion } - : {}), openapi: openapiSpec, upstream, globalPolicies: [ @@ -428,7 +425,14 @@ export default function ServiceProviderNew() { PLATFORM_API_BASE_URL ) ).filter((v) => v.enabled !== false); - if (enabledVersions.length <= 1) { + if (enabledVersions.length === 0) { + showSnackbar( + 'No enabled versions are available for this template.', + 'error' + ); + return; + } + if (enabledVersions.length === 1) { const only = enabledVersions[0]; applyTemplateSelection( template, From e8c9d4888f838bbc91ef1b27a69e28d14c88fe89 Mon Sep 17 00:00:00 2001 From: Tharanidk Date: Wed, 24 Jun 2026 01:35:58 +0530 Subject: [PATCH 07/20] Add ui improvemets and bug fixing --- platform-api/src/api/compat.go | 97 ++++ platform-api/src/api/generated.go | 93 +--- platform-api/src/internal/constants/error.go | 1 + .../src/internal/database/schema.postgres.sql | 4 +- platform-api/src/internal/database/schema.sql | 4 +- .../src/internal/database/schema.sqlite.sql | 4 +- platform-api/src/internal/handler/llm.go | 9 + platform-api/src/internal/model/llm.go | 1 + .../src/internal/repository/interfaces.go | 3 + platform-api/src/internal/repository/llm.go | 271 +++++++---- platform-api/src/internal/service/llm.go | 89 +++- .../internal/service/llm_template_seeder.go | 2 + .../utils/llm_provider_template_loader.go | 12 +- .../anthropic-template.yaml | 1 + .../awsbedrock-template.yaml | 1 + .../azureaifoundry-template.yaml | 1 + .../azureopenai-template.yaml | 1 + .../gemini-template.yaml | 1 + .../mistral-template.yaml | 1 + .../openai-template.yaml | 1 + platform-api/src/resources/openapi.yaml | 24 +- .../Components/ResourceView/ResourceRow.tsx | 3 + .../CreateProviderTemplate.tsx | 150 +++---- .../CreateProviderTemplateVersion.tsx | 179 ++++---- .../providerTemplate/EditProviderTemplate.tsx | 3 +- .../ProviderTemplateOverview.tsx | 424 +++++++++--------- .../ProviderTemplatesList.tsx | 155 +++++-- .../providerTemplate/TemplateTokenMapping.tsx | 67 ++- .../proxies/LLMProxyDefinitionTab.tsx | 38 +- .../AddNewProvider/GuardrailsSection.tsx | 5 +- .../ProviderTemplateSelector.tsx | 27 +- .../AddNewProvider/TemplateVersionDialog.tsx | 7 +- .../serviceProvider/ServiceProviderNew.tsx | 51 +-- .../src/utils/providerTemplateDisplay.ts | 16 +- .../src/utils/providerTemplateManifest.ts | 5 +- portals/ai-workspace/src/utils/types.ts | 2 + 36 files changed, 1030 insertions(+), 723 deletions(-) create mode 100644 platform-api/src/api/compat.go diff --git a/platform-api/src/api/compat.go b/platform-api/src/api/compat.go new file mode 100644 index 0000000000..e2642d5a4b --- /dev/null +++ b/platform-api/src/api/compat.go @@ -0,0 +1,97 @@ +/* + * 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 api + +import ( + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// This file holds component types that are valid schemas in openapi.yaml but are +// dropped by oapi-codegen v2.5.1 on this OpenAPI 3.1 spec (it prints a 3.1 +// warning during `make generate`). Keeping them here — outside the generated +// file — keeps `make generate` reproducible: regenerating drops them from +// generated.go, and these hand-maintained definitions keep the package +// compiling. Remove this file once the codegen fully supports OpenAPI 3.1. + +// ApiIdentifierQ API Identifier query parameter. +type ApiIdentifierQ = string + +// GatewayStatusResponse defines model for GatewayStatusResponse. +type GatewayStatusResponse struct { + // Id Unique identifier for the gateway + Id *openapi_types.UUID `json:"id,omitempty" yaml:"id,omitempty"` + + // IsActive Indicates if the gateway is currently connected to the platform via WebSocket + IsActive *bool `json:"isActive,omitempty" yaml:"isActive,omitempty"` + + // IsCritical Whether the gateway is critical for production + IsCritical *bool `json:"isCritical,omitempty" yaml:"isCritical,omitempty"` + + // Name URL-friendly gateway identifier + Name *string `json:"name,omitempty" yaml:"name,omitempty"` +} + +// GatewayStatusListResponse defines model for GatewayStatusListResponse. +type GatewayStatusListResponse struct { + // Count Number of items in current response + Count int `binding:"required" json:"count" yaml:"count"` + List []GatewayStatusResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// RESTAPIValidationResponse defines model for RESTAPIValidationResponse. +type RESTAPIValidationResponse struct { + // Error Error details if validation fails + Error *struct { + // Code Error code indicating the type of validation failure + Code string `json:"code" yaml:"code"` + + // Message Human-readable error message + Message string `json:"message" yaml:"message"` + } `binding:"required" json:"error" yaml:"error"` + + // Valid Whether the API identifier or name-version combination is valid (not already in use) in the organization + Valid bool `binding:"required" json:"valid" yaml:"valid"` +} + +// 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 + +// ValidateRESTAPIParams defines parameters for ValidateRESTAPI. +type ValidateRESTAPIParams struct { + // Identifier **API Identifier** to check for existence within the organization. + Identifier *ApiIdentifierQ `form:"identifier,omitempty" json:"identifier,omitempty" yaml:"identifier,omitempty"` + + // Name **API Name** to check for existence within the organization. + Name *ApiNameQ `form:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` + + // Version **API Version** to check for existence within the organization. + Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` +} diff --git a/platform-api/src/api/generated.go b/platform-api/src/api/generated.go index ef26f719c2..9a0c4ef4e8 100644 --- a/platform-api/src/api/generated.go +++ b/platform-api/src/api/generated.go @@ -1769,7 +1769,8 @@ type LLMProviderTemplate struct { // Enabled Whether this version is offered when creating providers. Disabled // versions stay in the catalog but are hidden from the provider picker. - // Toggle via PATCH /llm-provider-templates/{id}/versions/{version}. + // 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"` // Id Unique handle for the template @@ -1790,6 +1791,8 @@ type LLMProviderTemplate struct { // Provider Identifies the origin of the template. Built-in templates use 'wso2'; // custom templates default to 'other' and may be set to any value. + // Optional on create/update — the server defaults it to 'other' when + // omitted, so a request/YAML without a provider is accepted. Provider *string `json:"provider,omitempty" yaml:"provider,omitempty"` RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" yaml:"remainingTokens,omitempty"` RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" yaml:"requestModel,omitempty"` @@ -1800,10 +1803,11 @@ type LLMProviderTemplate struct { // UpdatedAt Timestamp when the resource was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` - // Version Content version, e.g. v1.0. Defaults to v1.0 on create. Editing a - // template updates it in place; supply a new unique version only when - // creating a new version via POST /llm-provider-templates/{id}/versions. - Version *string `json:"version,omitempty" yaml:"version,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. @@ -1829,8 +1833,11 @@ type LLMProviderTemplateListItem struct { 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"` - Name *string `json:"name,omitempty" yaml:"name,omitempty"` + IsLatest *bool `json:"isLatest,omitempty" yaml:"isLatest,omitempty"` + + // LogoUrl URL of the provider logo + LogoUrl *string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` // Provider Origin of the template ('wso2' for built-in, otherwise custom-defined). Provider *string `json:"provider,omitempty" yaml:"provider,omitempty"` @@ -3828,18 +3835,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) @@ -3885,18 +3880,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) @@ -3933,18 +3916,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) @@ -3993,18 +3964,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) @@ -4075,18 +4034,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"` @@ -4110,18 +4057,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"` diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 16931e1a67..d433a75612 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -152,6 +152,7 @@ var ( 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") 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/schema.postgres.sql b/platform-api/src/internal/database/schema.postgres.sql index e6aaa6f1e7..6c38097a8f 100644 --- a/platform-api/src/internal/database/schema.postgres.sql +++ b/platform-api/src/internal/database/schema.postgres.sql @@ -304,7 +304,9 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, + base_handle VARCHAR(255) NOT NULL, name VARCHAR(253) NOT NULL, + provider VARCHAR(253) NOT NULL DEFAULT 'other', description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, @@ -315,7 +317,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle, version) + UNIQUE(organization_uuid, base_handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/database/schema.sql b/platform-api/src/internal/database/schema.sql index 35de7927a2..240824ab8a 100644 --- a/platform-api/src/internal/database/schema.sql +++ b/platform-api/src/internal/database/schema.sql @@ -299,7 +299,9 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, + base_handle VARCHAR(255) NOT NULL, name VARCHAR(253) NOT NULL, + provider VARCHAR(253) NOT NULL DEFAULT 'other', description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, @@ -310,7 +312,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle, version) + UNIQUE(organization_uuid, base_handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/database/schema.sqlite.sql b/platform-api/src/internal/database/schema.sqlite.sql index bc418f06e6..261f327b02 100644 --- a/platform-api/src/internal/database/schema.sqlite.sql +++ b/platform-api/src/internal/database/schema.sqlite.sql @@ -302,7 +302,9 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, + base_handle VARCHAR(255) NOT NULL, name VARCHAR(253) NOT NULL, + provider VARCHAR(253) NOT NULL DEFAULT 'other', description VARCHAR(1023), created_by VARCHAR(255), configuration TEXT NOT NULL, @@ -313,7 +315,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle, version) + UNIQUE(organization_uuid, base_handle, version) ); -- LLM Providers table diff --git a/platform-api/src/internal/handler/llm.go b/platform-api/src/internal/handler/llm.go index 4dd4be36df..037b378831 100644 --- a/platform-api/src/internal/handler/llm.go +++ b/platform-api/src/internal/handler/llm.go @@ -337,6 +337,9 @@ 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.ErrInvalidInput): c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid input")) return @@ -365,6 +368,9 @@ func (h *LLMHandler) DeleteLLMProviderTemplate(c *gin.Context) { 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 @@ -396,6 +402,9 @@ func (h *LLMHandler) DeleteLLMProviderTemplateVersion(c *gin.Context) { 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 diff --git a/platform-api/src/internal/model/llm.go b/platform-api/src/internal/model/llm.go index 431897b6c4..a4ffa6ff37 100644 --- a/platform-api/src/internal/model/llm.go +++ b/platform-api/src/internal/model/llm.go @@ -162,6 +162,7 @@ type LLMProviderTemplate struct { UUID string `json:"uuid" db:"uuid"` OrganizationUUID string `json:"organizationId" db:"organization_uuid"` ID string `json:"id" db:"handle"` + BaseHandle string `json:"baseHandle,omitempty" db:"base_handle"` Name string `json:"name" db:"name"` Description string `json:"description,omitempty" db:"description"` Provider string `json:"provider,omitempty" db:"-"` diff --git a/platform-api/src/internal/repository/interfaces.go b/platform-api/src/internal/repository/interfaces.go index 88d83bd089..4833b85d8d 100644 --- a/platform-api/src/internal/repository/interfaces.go +++ b/platform-api/src/internal/repository/interfaces.go @@ -238,10 +238,13 @@ type LLMProviderTemplateRepository interface { 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) + GetBaseHandle(handle, orgUUID string) (string, error) + ProviderForHandle(handle, orgUUID string) (string, error) CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error) } diff --git a/platform-api/src/internal/repository/llm.go b/platform-api/src/internal/repository/llm.go index f3013bbd67..01b0b40975 100644 --- a/platform-api/src/internal/repository/llm.go +++ b/platform-api/src/internal/repository/llm.go @@ -79,18 +79,24 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { if t.Version == "" { t.Version = "v1.0" } + if t.Provider == "" { + t.Provider = "other" + } + if t.BaseHandle == "" { + t.BaseHandle = t.ID + } t.IsLatest = true t.Enabled = true query := ` INSERT INTO llm_provider_templates ( - uuid, organization_uuid, handle, name, description, created_by, + uuid, organization_uuid, handle, base_handle, name, provider, description, created_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, + t.UUID, t.OrganizationUUID, t.ID, t.BaseHandle, t.Name, t.Provider, t.Description, t.CreatedBy, string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, t.Enabled, t.CreatedAt, t.UpdatedAt, ) @@ -123,10 +129,10 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) var createdBy sql.NullString err = tx.QueryRow(r.db.Rebind(` SELECT - (SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ?), - (SELECT COUNT(*) FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? AND version = ?), - (SELECT created_by FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? AND is_latest = ? LIMIT 1) - `), t.ID, t.OrganizationUUID, t.ID, t.OrganizationUUID, t.Version, t.ID, t.OrganizationUUID, true).Scan(&total, &sameVersion, &createdBy) + (SELECT COUNT(*) FROM llm_provider_templates WHERE base_handle = ? AND organization_uuid = ?), + (SELECT COUNT(*) FROM llm_provider_templates WHERE base_handle = ? AND organization_uuid = ? AND version = ?), + (SELECT created_by FROM llm_provider_templates WHERE base_handle = ? AND organization_uuid = ? AND is_latest = ? LIMIT 1) + `), t.BaseHandle, t.OrganizationUUID, t.BaseHandle, t.OrganizationUUID, t.Version, t.BaseHandle, t.OrganizationUUID, true).Scan(&total, &sameVersion, &createdBy) if err != nil { return err } @@ -137,10 +143,10 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) return constants.ErrLLMProviderTemplateVersionExists } - // Demote the current latest. + // Demote the current latest within this family (same base_handle). if _, err = tx.Exec(r.db.Rebind(` - UPDATE llm_provider_templates SET is_latest = ? WHERE handle = ? AND organization_uuid = ? AND is_latest = ? - `), false, t.ID, t.OrganizationUUID, true); err != nil { + UPDATE llm_provider_templates SET is_latest = ? WHERE base_handle = ? AND organization_uuid = ? AND is_latest = ? + `), false, t.BaseHandle, t.OrganizationUUID, true); err != nil { return err } @@ -157,12 +163,12 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) if _, err = tx.Exec(r.db.Rebind(` INSERT INTO llm_provider_templates ( - uuid, organization_uuid, handle, name, description, created_by, + uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), - t.UUID, t.OrganizationUUID, t.ID, t.Name, t.Description, t.CreatedBy, + t.UUID, t.OrganizationUUID, t.ID, t.BaseHandle, t.Name, t.Provider, t.Description, t.CreatedBy, string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, t.Enabled, t.CreatedAt, t.UpdatedAt, ); err != nil { @@ -172,50 +178,57 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) return tx.Commit() } -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, openapi_spec, version, is_latest, enabled, created_at, updated_at - FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? AND is_latest = ? - `), templateID, orgUUID, true) - - var t model.LLMProviderTemplate - var configJSON sql.NullString - var openapiSpec sql.NullString - if err := row.Scan( - &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, - &openapiSpec, &t.Version, &t.IsLatest, &t.Enabled, - &t.CreatedAt, &t.UpdatedAt, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, err +// familyBaseHandle resolves the template family (grouping) key — its base_handle +// — from any version handle in that family. Handles are version-specific, but all +// versions of a template share one immutable base_handle, so it is the stable key +// used to list, count, promote, and delete across a template's versions. Returns +// "" if no template with the given handle exists in the organization. +func (r *LLMProviderTemplateRepo) familyBaseHandle(handle, orgUUID string) (string, error) { + var base string + err := r.db.QueryRow(r.db.Rebind(` + SELECT base_handle FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? LIMIT 1 + `), handle, orgUUID).Scan(&base) + if errors.Is(err, sql.ErrNoRows) { + return "", nil } - t.OpenAPISpec = openapiSpec.String + if err != nil { + return "", err + } + return base, nil +} - if configJSON.Valid && configJSON.String != "" { - var cfg llmProviderTemplateConfig - if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { - return nil, err - } - t.Provider = cfg.Provider - 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 +// GetBaseHandle exposes the family base_handle for a given (possibly non-latest) +// version handle, so callers can derive sibling-version handles and group a family. +func (r *LLMProviderTemplateRepo) GetBaseHandle(handle, orgUUID string) (string, error) { + return r.familyBaseHandle(handle, orgUUID) +} + +func (r *LLMProviderTemplateRepo) ProviderForHandle(handle, orgUUID string) (string, error) { + var provider string + err := r.db.QueryRow(r.db.Rebind(` + SELECT provider FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? LIMIT 1 + `), handle, orgUUID).Scan(&provider) + if errors.Is(err, sql.ErrNoRows) { + return "", nil } + if err != nil { + return "", err + } + return provider, nil +} - return &t, nil +func (r *LLMProviderTemplateRepo) GetByID(templateID, orgUUID string) (*model.LLMProviderTemplate, error) { + row := r.db.QueryRow(r.db.Rebind(` + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_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) } 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, openapi_spec, version, is_latest, enabled, created_at, updated_at + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates WHERE uuid = ? AND organization_uuid = ? `), uuid, orgUUID) @@ -223,22 +236,36 @@ func (r *LLMProviderTemplateRepo) GetByUUID(uuid, orgUUID string) (*model.LLMPro } func (r *LLMProviderTemplateRepo) GetByVersion(templateID, orgUUID, version string) (*model.LLMProviderTemplate, error) { + base, err := r.familyBaseHandle(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, name, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? AND version = ? - `), templateID, orgUUID, version) + WHERE base_handle = ? AND organization_uuid = ? AND version = ? + `), base, orgUUID, version) return scanTemplateRow(row) } func (r *LLMProviderTemplateRepo) ListVersions(templateID, orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { + base, err := r.familyBaseHandle(templateID, orgUUID) + if err != nil { + return nil, err + } + if base == "" { + return nil, nil + } rows, err := r.db.Query(r.db.Rebind(` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? + WHERE base_handle = ? AND organization_uuid = ? ORDER BY created_at DESC LIMIT ? OFFSET ? - `), templateID, orgUUID, limit, offset) + `), base, orgUUID, limit, offset) if err != nil { return nil, err } @@ -256,8 +283,15 @@ func (r *LLMProviderTemplateRepo) ListVersions(templateID, orgUUID string, limit } func (r *LLMProviderTemplateRepo) CountVersions(templateID, orgUUID string) (int, error) { + base, err := r.familyBaseHandle(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 handle = ? AND organization_uuid = ?`), templateID, orgUUID).Scan(&count); err != nil { + if err := r.db.QueryRow(r.db.Rebind(`SELECT COUNT(*) FROM llm_provider_templates WHERE base_handle = ? AND organization_uuid = ?`), base, orgUUID).Scan(&count); err != nil { return 0, err } return count, nil @@ -268,7 +302,7 @@ func scanTemplate(s rowScanner) (*model.LLMProviderTemplate, error) { var configJSON sql.NullString var openapiSpec sql.NullString if err := s.Scan( - &t.UUID, &t.OrganizationUUID, &t.ID, &t.Name, &t.Description, &t.CreatedBy, &configJSON, + &t.UUID, &t.OrganizationUUID, &t.ID, &t.BaseHandle, &t.Name, &t.Provider, &t.Description, &t.CreatedBy, &configJSON, &openapiSpec, &t.Version, &t.IsLatest, &t.Enabled, &t.CreatedAt, &t.UpdatedAt, ); err != nil { @@ -280,7 +314,6 @@ func scanTemplate(s rowScanner) (*model.LLMProviderTemplate, error) { if err := json.Unmarshal([]byte(configJSON.String), &cfg); err != nil { return nil, err } - t.Provider = cfg.Provider t.Metadata = cfg.Metadata t.PromptTokens = cfg.PromptTokens t.CompletionTokens = cfg.CompletionTokens @@ -308,9 +341,10 @@ func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*mo // Catalog lists one entry per template — the latest version only. pageClause, pageArgs := r.db.PaginationClause(limit, offset) query := ` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates - WHERE organization_uuid = ? AND is_latest = ? + WHERE organization_uuid = ? + AND (provider = 'wso2' OR (provider != 'wso2' AND is_latest = ?)) ORDER BY created_at DESC ` + pageClause rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID, true}, pageArgs...)...) @@ -332,10 +366,10 @@ func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*mo func (r *LLMProviderTemplateRepo) ListAllVersions(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { rows, err := r.db.Query(r.db.Rebind(` - SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at + SELECT uuid, organization_uuid, handle, base_handle, name, provider, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at FROM llm_provider_templates WHERE organization_uuid = ? - ORDER BY handle ASC, created_at DESC + ORDER BY name ASC, created_at DESC LIMIT ? OFFSET ? `), orgUUID, limit, offset) if err != nil { @@ -380,16 +414,13 @@ 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 = ?, openapi_spec = ?, updated_at = ? - WHERE handle = ? AND organization_uuid = ? AND is_latest = ? - ` - result, err := r.db.Exec(r.db.Rebind(query), - t.Name, t.Description, - string(configJSON), t.OpenAPISpec, - t.UpdatedAt, - t.ID, t.OrganizationUUID, true, + WHERE handle = ? AND organization_uuid = ? + `), + t.Name, t.Description, string(configJSON), t.OpenAPISpec, t.UpdatedAt, + t.ID, t.OrganizationUUID, ) if err != nil { return err @@ -405,8 +436,33 @@ 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 base_handle = ? AND organization_uuid = ? AND provider != ? + `), 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.familyBaseHandle(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 base_handle = ? AND organization_uuid = ? AND provider != ? + `), base, orgUUID, "wso2") if err != nil { return err } @@ -417,14 +473,41 @@ func (r *LLMProviderTemplateRepo) Delete(templateID, orgUUID string) error { if affected == 0 { return sql.ErrNoRows } - return nil + + var remaining, latestCount int + if err := tx.QueryRow(r.db.Rebind(` + SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest THEN 1 ELSE 0 END), 0) + FROM llm_provider_templates WHERE base_handle = ? 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 base_handle = ? AND organization_uuid = ? + ORDER BY created_at DESC LIMIT 1 + ) + `), true, base, orgUUID); err != nil { + return err + } + } + return tx.Commit() } func (r *LLMProviderTemplateRepo) SetEnabled(templateID, orgUUID, version string, enabled bool) error { + base, err := r.familyBaseHandle(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 handle = ? AND organization_uuid = ? AND version = ? - `), enabled, time.Now(), templateID, orgUUID, version) + WHERE base_handle = ? AND organization_uuid = ? AND version = ? + `), enabled, time.Now(), base, orgUUID, version) if err != nil { return err } @@ -439,6 +522,14 @@ func (r *LLMProviderTemplateRepo) SetEnabled(templateID, orgUUID, version string } func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version string) error { + base, err := r.familyBaseHandle(templateID, orgUUID) + if err != nil { + return err + } + if base == "" { + return sql.ErrNoRows + } + tx, err := r.db.Begin() if err != nil { return err @@ -447,8 +538,8 @@ func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version str result, err := tx.Exec(r.db.Rebind(` DELETE FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? AND version = ? - `), templateID, orgUUID, version) + WHERE base_handle = ? AND organization_uuid = ? AND version = ? + `), base, orgUUID, version) if err != nil { return err } @@ -460,12 +551,11 @@ func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version str return sql.ErrNoRows } - // If we removed the latest and other versions remain, promote the newest. var remaining, latestCount int if err := tx.QueryRow(r.db.Rebind(` SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest THEN 1 ELSE 0 END), 0) - FROM llm_provider_templates WHERE handle = ? AND organization_uuid = ? - `), templateID, orgUUID).Scan(&remaining, &latestCount); err != nil { + FROM llm_provider_templates WHERE base_handle = ? AND organization_uuid = ? + `), base, orgUUID).Scan(&remaining, &latestCount); err != nil { return err } if remaining > 0 && latestCount == 0 { @@ -473,10 +563,10 @@ func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version str UPDATE llm_provider_templates SET is_latest = ? WHERE uuid = ( SELECT uuid FROM llm_provider_templates - WHERE handle = ? AND organization_uuid = ? + WHERE base_handle = ? AND organization_uuid = ? ORDER BY created_at DESC LIMIT 1 ) - `), true, templateID, orgUUID); err != nil { + `), true, base, orgUUID); err != nil { return err } } @@ -494,20 +584,29 @@ 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 = ? AND is_latest = ?`), orgUUID, true).Scan(&count); err != nil { + if err := r.db.QueryRow(r.db.Rebind(` + SELECT COUNT(*) FROM llm_provider_templates + WHERE organization_uuid = ? + AND (provider = 'wso2' OR (provider != 'wso2' AND is_latest = ?)) + `), orgUUID, true).Scan(&count); err != nil { return 0, err } return count, nil } - -// CountProvidersUsingTemplate counts how many LLM providers are using a specific template. func (r *LLMProviderTemplateRepo) CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error) { + base, err := r.familyBaseHandle(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.handle = ? AND t.organization_uuid = ?` - args := []interface{}{templateID, orgUUID} + WHERE t.base_handle = ? AND t.organization_uuid = ?` + args := []interface{}{base, orgUUID} if strings.TrimSpace(version) != "" { query += ` AND t.version = ?` args = append(args, version) diff --git a/platform-api/src/internal/service/llm.go b/platform-api/src/internal/service/llm.go index e4aad40abd..45b2d24e87 100644 --- a/platform-api/src/internal/service/llm.go +++ b/platform-api/src/internal/service/llm.go @@ -113,11 +113,25 @@ 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) + + exists, err := s.repo.Exists(handle, orgUUID) if err != nil { return nil, fmt.Errorf("failed to check template exists: %w", err) } @@ -127,7 +141,9 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. m := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, - ID: req.Id, + ID: handle, + BaseHandle: baseHandle, + Version: version, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), Provider: defaultTemplateProvider(req.Provider), @@ -212,6 +228,17 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM return nil, constants.ErrInvalidInput } + provider, err := s.repo.ProviderForHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to resolve template: %w", err) + } + if provider == "" { + return nil, constants.ErrLLMProviderTemplateNotFound + } + if provider == "wso2" { + return nil, constants.ErrLLMProviderTemplateReadOnly + } + m := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, ID: handle, @@ -233,7 +260,6 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM } m.ResourceMappings = resourceMappings - // Editing a template updates the latest version in place (no new version). if err := s.repo.Update(m); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, constants.ErrLLMProviderTemplateNotFound @@ -241,6 +267,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.GetBaseHandle(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) @@ -260,28 +292,37 @@ func normalizeTemplateVersion(v string) (string, bool) { } 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 string, req *api.LLMProviderTemplate) (*api.LLMProviderTemplate, error) { if handle == "" || req == nil { return nil, constants.ErrInvalidInput } - if req.Id != "" && req.Id != handle { - return nil, constants.ErrInvalidInput - } if req.Name == "" { return nil, constants.ErrInvalidInput } - version, ok := normalizeTemplateVersion(utils.ValueOrEmpty(req.Version)) + version, ok := normalizeTemplateVersion(req.Version) if !ok { return nil, constants.ErrInvalidInput } + baseHandle, err := s.repo.GetBaseHandle(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: handle, + ID: makeTemplateHandle(baseHandle, version), + BaseHandle: baseHandle, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), - Provider: defaultTemplateProvider(req.Provider), + Provider: "other", Version: version, OpenAPISpec: utils.ValueOrEmpty(req.Openapi), Metadata: mapTemplateMetadataAPI(req.Metadata), @@ -389,6 +430,16 @@ func (s *LLMProviderTemplateService) Delete(orgUUID, handle string) error { if handle == "" { return constants.ErrInvalidInput } + provider, err := s.repo.ProviderForHandle(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 { @@ -414,6 +465,16 @@ func (s *LLMProviderTemplateService) DeleteVersion(orgUUID, handle, version stri 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.Provider == "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 { @@ -1686,6 +1747,10 @@ func templateListItem(t *model.LLMProviderTemplate) api.LLMProviderTemplateListI 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, @@ -1695,6 +1760,7 @@ func templateListItem(t *model.LLMProviderTemplate) api.LLMProviderTemplateListI Version: &version, IsLatest: &isLatest, Enabled: &enabled, + LogoUrl: utils.StringPtrIfNotEmpty(logoURL), CreatedAt: utils.TimePtr(t.CreatedAt), UpdatedAt: utils.TimePtr(t.UpdatedAt), } @@ -1704,7 +1770,6 @@ func mapTemplateModelToAPI(m *model.LLMProviderTemplate) *api.LLMProviderTemplat if m == nil { return nil } - version := m.Version isLatest := m.IsLatest enabled := m.Enabled return &api.LLMProviderTemplate{ @@ -1713,7 +1778,7 @@ func mapTemplateModelToAPI(m *model.LLMProviderTemplate) *api.LLMProviderTemplat Description: utils.StringPtrIfNotEmpty(m.Description), Provider: utils.StringPtrIfNotEmpty(m.Provider), CreatedBy: utils.StringPtrIfNotEmpty(m.CreatedBy), - Version: &version, + Version: m.Version, IsLatest: &isLatest, Enabled: &enabled, Openapi: utils.StringPtrIfNotEmpty(m.OpenAPISpec), diff --git a/platform-api/src/internal/service/llm_template_seeder.go b/platform-api/src/internal/service/llm_template_seeder.go index 0677a962a1..97281e3f68 100644 --- a/platform-api/src/internal/service/llm_template_seeder.go +++ b/platform-api/src/internal/service/llm_template_seeder.go @@ -97,6 +97,8 @@ func (s *LLMTemplateSeeder) SeedForOrg(orgUUID string) error { toCreate := &model.LLMProviderTemplate{ OrganizationUUID: orgUUID, ID: tpl.ID, + BaseHandle: tpl.BaseHandle, + Version: tpl.Version, Name: tpl.Name, Description: tpl.Description, Provider: tpl.Provider, 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 93693d0b03..65560b3421 100644 --- a/platform-api/src/internal/utils/llm_provider_template_loader.go +++ b/platform-api/src/internal/utils/llm_provider_template_loader.go @@ -55,6 +55,7 @@ type llmProviderTemplateYAML struct { Spec struct { DisplayName string `yaml:"displayName"` Provider string `yaml:"provider"` + Version string `yaml:"version"` Metadata *llmProviderTemplateMetadataYAML `yaml:"metadata"` PromptTokens *extractionIdentifierYAML `yaml:"promptTokens"` CompletionTokens *extractionIdentifierYAML `yaml:"completionTokens"` @@ -124,8 +125,17 @@ func LoadLLMProviderTemplatesFromDirectory(dirPath string) ([]*model.LLMProvider provider = "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, + ID: handle, + BaseHandle: baseHandle, + Version: seedVersion, Name: doc.Spec.DisplayName, Provider: provider, Metadata: mapTemplateMetadata(doc.Spec.Metadata), 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 a5d39fff52..5b8d817e03 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: Anthropic provider: 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 6973d58e83..df25eac33d 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: AWS Bedrock provider: 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 52ebc7ff6e..616fc1a17b 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: Azure AI Foundry provider: 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 ac6dd62a01..19e3ca3fce 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: Azure OpenAI provider: 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 262cb00537..a16950fc40 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: Gemini provider: 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 1ad072392f..9f3b5e900a 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: Mistral provider: 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 590dba92ae..be1104e3ea 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 @@ -23,6 +23,7 @@ metadata: spec: displayName: OpenAI provider: 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 5f48795b85..093e70bd5f 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -2894,8 +2894,8 @@ paths: must be supplied in the body, match the v. pattern, and be unique for the template. The new version becomes the latest. - The template handle is taken from the path; the body's `id` is optional - and defaults to the path value (a mismatching id is rejected). + The template handle is taken from the path; the body's `id` must match it + (a mismatching id is rejected). operationId: createLLMProviderTemplateVersion security: - OAuth2Security: @@ -10075,6 +10075,7 @@ components: required: - id - name + - version properties: id: type: string @@ -10091,6 +10092,9 @@ components: description: | Identifies the origin of the template. Built-in templates use 'wso2'; custom templates default to 'other' and may be set to any value. + Optional on create/update — the server defaults it to 'other' when + omitted, so a request/YAML without a provider is accepted. + default: other maxLength: 253 example: wso2 description: @@ -10106,9 +10110,10 @@ components: version: type: string description: | - Content version, e.g. v1.0. Defaults to v1.0 on create. Editing a - template updates it in place; supply a new unique version only when - creating a new version via POST /llm-provider-templates/{id}/versions. + 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+$' example: v1.0 isLatest: @@ -10121,7 +10126,9 @@ components: description: | Whether this version is offered when creating providers. Disabled versions stay in the catalog but are hidden from the provider picker. - Toggle via PATCH /llm-provider-templates/{id}/versions/{version}. + 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 @@ -10195,6 +10202,11 @@ components: 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/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/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx index fd54fdc87d..8d61207b07 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx @@ -29,7 +29,6 @@ import { FormControl, FormLabel, Grid, - InputAdornment, MenuItem, PageContent, PageTitle, @@ -38,7 +37,7 @@ import { TextField, Typography, } from '@wso2/oxygen-ui'; -import { ChevronDown, ChevronLeft, Tag } from '@wso2/oxygen-ui-icons-react'; +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'; @@ -79,8 +78,6 @@ function parseSpecServerUrl(text: string): string | null { 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; @@ -113,13 +110,14 @@ export default function CreateProviderTemplate() { const [isFetchingSpec, setIsFetchingSpec] = useState(false); const [specFileName, setSpecFileName] = useState(''); const [specContent, setSpecContent] = useState(''); - // Whether the entered spec URL has been fetched (and validated). Required - // before create so a URL can't be saved without confirming it resolves. 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); @@ -154,12 +152,12 @@ export default function CreateProviderTemplate() { setSpecFetched(true); if (serverUrl) { setEndpointUrl(serverUrl); - showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); } else { - showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); } } catch { - showSnackbar('Failed to fetch specification from that URL.', 'error'); + showSnackbar('Could not fetch a specification from that URL.', 'error'); } finally { setIsFetchingSpec(false); } @@ -181,9 +179,9 @@ export default function CreateProviderTemplate() { setSpecFetched(true); if (serverUrl) { setEndpointUrl(serverUrl); - showSnackbar('Specification uploaded. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); } else { - showSnackbar('Read the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); } } catch { showSnackbar('Failed to read the specification file.', 'error'); @@ -199,14 +197,13 @@ export default function CreateProviderTemplate() { .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); - const normalizedTemplateId = toTemplateId(name); + 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); - // A spec URL, if provided, must be a valid URL AND fetched before creating. const isSpecReady = !specUrlEntered || (isSpecUrlValid && specFetched); const isFormValid = isNameValid && @@ -230,10 +227,11 @@ export default function CreateProviderTemplate() { const payload: CreateProviderTemplateRequest = { id: normalizedTemplateId, name: name.trim(), + version: INITIAL_VERSION, description: description.trim() || undefined, ...tokenFields, metadata: Object.keys(metadata).length ? metadata : undefined, - // Uploaded spec content (empty when a spec URL is used instead). + openapi: specContent.trim() ? specContent : undefined, }; @@ -291,12 +289,18 @@ export default function CreateProviderTemplate() { required value={name} onChange={(e) => setName(e.target.value)} + onBlur={() => setNameTouched(true)} placeholder="Enter template name" - error={name.length > MAX_NAME_LENGTH} - helperText={ + error={ + (nameTouched && name.trim().length === 0) || name.length > MAX_NAME_LENGTH - ? `Name must not exceed ${MAX_NAME_LENGTH} characters (${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})` + : '' } /> @@ -315,16 +319,6 @@ export default function CreateProviderTemplate() { fullWidth value={INITIAL_VERSION} disabled - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - helperText="Initial version" /> @@ -362,64 +356,51 @@ export default function CreateProviderTemplate() { defaultMessage={'OpenAPI Specification'} /> - - { - setOpenapiSpecUrl(e.target.value); - setSpecFileName(''); - setSpecContent(''); - setSpecFetched(false); - }} - placeholder="https://api.openai.com/openapi.json" - error={specUrlEntered && (!isSpecUrlValid || !specFetched)} - /> + + + { + 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 - - Or - - - {/* Reserved fixed-height line so the message doesn't shift the - row above it. Shown in red since fetching is mandatory. */} - - {specUrlEntered && !isSpecUrlValid ? ( - - Enter a valid URL. - - ) : specUrlEntered && !specFetched ? ( - - Click 'Fetch specification' to validate the URL - before creating. - - ) : null} - setEndpointUrl(e.target.value)} + onChange={(e) => { + setEndpointUrl(e.target.value); + setEndpointUrlTouched(false); + }} + onBlur={() => setEndpointUrlTouched(true)} placeholder="https://api.openai.com" - error={endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl)} + error={endpointUrlTouched && endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl)} helperText={ - endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl) - ? 'Enter a valid http(s) URL.' + endpointUrlTouched && endpointUrl.trim().length > 0 && !isValidHttpUrl(endpointUrl) + ? 'Enter a valid URL.' : '' } /> @@ -466,8 +451,7 @@ export default function CreateProviderTemplate() { Advanced - Token & model mapping — defaults to OpenAI values; change if - your provider differs. + Token and model mapping. Defaults to OpenAI. diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx index 3a7870ac94..bbb82e7a60 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx @@ -23,7 +23,6 @@ import { Accordion, AccordionDetails, AccordionSummary, - Alert, Box, Button, CircularProgress, @@ -31,7 +30,6 @@ import { FormControl, FormLabel, Grid, - InputAdornment, MenuItem, PageContent, PageTitle, @@ -40,12 +38,7 @@ import { TextField, Typography, } from '@wso2/oxygen-ui'; -import { - ChevronDown, - ChevronLeft, - GitBranch, - Tag, -} from '@wso2/oxygen-ui-icons-react'; +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'; @@ -59,6 +52,7 @@ import type { } from '../../../../utils/types'; import { fromTokenConfig, + isValidHttpUrl, toTokenConfig, TOKEN_FIELDS, TOKEN_LOCATIONS, @@ -68,12 +62,8 @@ import { const MAX_DESCRIPTION_LENGTH = 200; -// Versions follow the v. pattern (e.g. v1.0). const VERSION_PATTERN = /^[vV]\d+\.\d+$/; -// Suggest the next version by bumping the major of the current version -// (v1.0 -> v2.0). Falls back to v2.0 when the current can't be parsed. The -// suggestion is only a default — the field is editable. function suggestNextVersion(current?: string): string { const match = /^[vV](\d+)\.\d+$/.exec((current ?? '').trim()); if (!match) return 'v2.0'; @@ -81,6 +71,14 @@ function suggestNextVersion(current?: string): string { 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; @@ -135,13 +133,10 @@ function CreateProviderTemplateVersionForm({ '/settings/llm-provider-templates' )}/${templateId}`; - // The current latest version; the new version is user-entered (prefilled with - // a suggested bump) and must be unique. const currentVersion = template.version ?? 'v1.0'; const [version, setVersion] = useState(() => suggestNextVersion(template.version)); - // Description and the OpenAPI spec are the things a new version typically - // changes; pre-fill from the source 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 ?? '' @@ -152,7 +147,9 @@ function CreateProviderTemplateVersionForm({ const [isFetchingSpec, setIsFetchingSpec] = useState(false); const [specFileName, setSpecFileName] = useState(''); const [specContent, setSpecContent] = useState(template.openapi ?? ''); - // Token & model mappings copied from the source version (adjust as needed). + const [endpointUrlTouched, setEndpointUrlTouched] = useState(false); + const [specUrlTouched, setSpecUrlTouched] = useState(false); + const hasInheritedSpec = !specFileName && Boolean(template.openapi?.trim()); const [tokenConfig, setTokenConfig] = useState(() => toTokenConfig(template) ); @@ -187,12 +184,12 @@ function CreateProviderTemplateVersionForm({ setSpecContent(''); if (serverUrl) { setEndpointUrl(serverUrl); - showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); } else { - showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); } } catch { - showSnackbar('Failed to fetch specification from that URL.', 'error'); + showSnackbar('Could not fetch a specification from that URL.', 'error'); } finally { setIsFetchingSpec(false); } @@ -213,21 +210,28 @@ function CreateProviderTemplateVersionForm({ setOpenapiSpecUrl(''); if (serverUrl) { setEndpointUrl(serverUrl); - showSnackbar('Specification uploaded. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); } else { - showSnackbar('Read the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); } } catch { - showSnackbar('Failed to read the specification file.', 'error'); + 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; + 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; + const isFormValid = + isDescriptionValid && + isEndpointValid && + isVersionValid && + (!specUrlEntered || isSpecUrlValid); const handleSubmit = async (event?: React.FormEvent) => { if (event) event.preventDefault(); @@ -236,18 +240,14 @@ function CreateProviderTemplateVersionForm({ const tokenFields = fromTokenConfig(tokenConfig); - // Preserve connection metadata (endpoint + spec URL); the new version keeps - // the source's auth/logo and updates endpoint/spec from this form. 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; - // POST /{id}/versions creates a NEW version with the supplied version - // string. Carry forward resource mappings; override the fields edited here. const payload: ProviderTemplate = { - id: templateId, + id: toTemplateId(`${template.name ?? ''} ${version.trim()}`), name: template.name, provider: template.provider, version: version.trim(), @@ -260,7 +260,7 @@ function CreateProviderTemplateVersionForm({ setIsSubmitting(true); try { - await providerTemplateApis.createProviderTemplateVersion( + const created = await providerTemplateApis.createProviderTemplateVersion( templateId, payload, organizationId, @@ -268,7 +268,10 @@ function CreateProviderTemplateVersionForm({ ); await refreshTemplates(); showSnackbar(`New version ${version.trim()} created successfully.`, 'success'); - navigate(overviewPath); + 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.', @@ -305,7 +308,7 @@ function CreateProviderTemplateVersionForm({ @@ -313,16 +316,6 @@ function CreateProviderTemplateVersionForm({ - } sx={{ mb: 2 }}> - {template.name} }} - /> - - {/* Name is fixed — a version belongs to the same template. */} @@ -354,19 +347,10 @@ function CreateProviderTemplateVersionForm({ onChange={(e) => setVersion(e.target.value)} placeholder="v2.0" error={version.trim().length > 0 && !isVersionValid} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} helperText={ version.trim().length > 0 && !isVersionValid - ? 'Use the v. format, e.g. v2.0' - : `Latest is ${currentVersion}` + ? 'Use the format v., e.g. v2.0' + : '' } /> @@ -405,44 +389,46 @@ function CreateProviderTemplateVersionForm({ defaultMessage={'OpenAPI Specification'} /> - - { - setOpenapiSpecUrl(e.target.value); - setSpecFileName(''); - setSpecContent(''); - }} - placeholder="https://api.openai.com/openapi.json" - /> + + + { + 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 - - Or - - setEndpointUrl(e.target.value)} + onChange={(e) => { + 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.' + : '' + } /> @@ -484,8 +486,7 @@ function CreateProviderTemplateVersionForm({ Advanced - Token & model mapping — copied from {currentVersion}; change - if this version differs. + Token and model mapping, copied from {currentVersion}. diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx index 8cbec738f8..d28cc2d098 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/EditProviderTemplate.tsx @@ -117,7 +117,6 @@ function EditProviderTemplateForm({ template }: { template: ProviderTemplate }) defaultMessage={'Edit LLM Provider Template'} /> - {template.id} @@ -141,7 +140,7 @@ function EditProviderTemplateForm({ template }: { template: ProviderTemplate }) 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.' } /> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx index 375c1814ca..32b72ad448 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplateOverview.tsx @@ -24,7 +24,6 @@ import { Box, Button, Card, - Chip, CircularProgress, Dialog, DialogActions, @@ -170,6 +169,9 @@ export default function ProviderTemplateOverview() { 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); @@ -212,9 +214,6 @@ export default function ProviderTemplateOverview() { .then((list) => { if (isMounted && list.length) { setVersions(list); - // Default the switcher to the latest version. - const latest = list.find((v) => v.isLatest) ?? list[0]; - if (latest?.version) setSelectedVersion(latest.version); } }) .catch(() => { @@ -235,21 +234,17 @@ export default function ProviderTemplateOverview() { if (!templateId || !organizationId || version === selectedVersion) return; setIsLoading(true); try { - const isLatest = versions.find((v) => v.version === version)?.isLatest; - const full = isLatest - ? await providerTemplateApis.getProviderTemplate( - templateId, - organizationId, - PLATFORM_API_BASE_URL - ) - : await providerTemplateApis.getProviderTemplateVersion( - templateId, - version, - organizationId, - PLATFORM_API_BASE_URL - ); + 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.', @@ -260,7 +255,6 @@ export default function ProviderTemplateOverview() { } }; - // Seed (and reset) the editable drafts whenever the loaded template changes. const seedDrafts = React.useCallback((t: ProviderTemplate) => { setEndpointUrl(t.metadata?.endpointUrl ?? ''); setProvider( @@ -277,10 +271,11 @@ export default function ProviderTemplateOverview() { setSpecContent(t.openapi ?? ''); setSpecFileName(''); setIsDirty(false); + setEndpointUrlTouched(false); + setOpenapiSpecUrlTouched(false); + setLogoUrlTouched(false); }, []); - // Fetch & validate a spec from the entered URL; fills the endpoint from its - // servers. URL mode references the spec by link (clears inline content). const fetchSpecFromUrl = async () => { const url = openapiSpecUrl.trim(); if (!url) return; @@ -299,12 +294,12 @@ export default function ProviderTemplateOverview() { const server = specServerUrl(text); if (server) { setEndpointUrl(server); - showSnackbar('Specification fetched. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification fetched and endpoint URL added.', 'success'); } else { - showSnackbar('Fetched the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification fetched. Add the endpoint URL manually.', 'info'); } } catch { - showSnackbar('Failed to fetch specification from that URL.', 'error'); + showSnackbar('Could not fetch a specification from that URL.', 'error'); } finally { setIsFetchingSpec(false); } @@ -327,9 +322,9 @@ export default function ProviderTemplateOverview() { const server = specServerUrl(text); if (server) { setEndpointUrl(server); - showSnackbar('Specification uploaded. Endpoint URL filled from servers.', 'success'); + showSnackbar('Specification uploaded and endpoint URL added.', 'success'); } else { - showSnackbar('Read the spec, but no server URL was found — enter the endpoint manually.', 'info'); + showSnackbar('Specification uploaded. Add the endpoint URL manually.', 'info'); } } catch { showSnackbar('Failed to read the specification file.', 'error'); @@ -408,6 +403,7 @@ export default function ProviderTemplateOverview() { const payload: UpdateProviderTemplateRequest = { id: template.id, name: template.name, + version: currentVersion, provider: provider.trim() || 'other', description: template.description, ...fromTokenConfig(defaultTokens), @@ -516,14 +512,12 @@ export default function ProviderTemplateOverview() { const lastUpdated = template.updatedAt ?? template.createdAt; const currentVersion = selectedVersion || template.version || 'v1.0'; - const isBuiltIn = isBuiltInProviderTemplate(template.id); - // The seeded built-in version (v1.0) is read-only; user-created versions - // (v2.0+) of a built-in template can still be edited. - const isBaseVersion = currentVersion === 'v1.0' || currentVersion === 'v1'; - const isReadOnly = isBuiltIn && isBaseVersion; + const activeProvider = + versions.find((v) => v.version === currentVersion)?.provider ?? + template.provider; + const isBuiltIn = activeProvider === 'wso2'; + const isReadOnly = isBuiltIn; const canEdit = !isReadOnly; - // Built-in v1.0 is read-only (toggle only); every other version (built-in - // v2.0+ and all custom versions) can be deleted, one version at a time. const canDelete = !isReadOnly; const isEnabled = template.enabled !== false; @@ -694,52 +688,59 @@ export default function ProviderTemplateOverview() { onClose={() => setVersionMenuAnchor(null)} slotProps={{ paper: { sx: { minWidth: 260 } } }} > - - Versions - - {(versions.length ? versions : [template]).map((v) => { - const ver = v.version || 'v1'; - const isSelected = - ver === (selectedVersion || template.version || 'v1'); + {(() => { + const allVersions = versions.length ? versions : [template]; + const visibleVersions = isBuiltIn + ? allVersions.filter((v) => v.provider === 'wso2') + : allVersions.filter((v) => v.provider !== 'wso2'); + const sectionLabel = isBuiltIn ? 'Built-in Versions' : 'Custom Versions'; + return ( - { - setVersionMenuAnchor(null); - void handleSwitchVersion(ver); - }} - > - - - {ver} - - - {formatRelativeTime(v.createdAt ?? v.updatedAt)} - - - {isSelected ? : null} - + <> + + {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 + + ); - })} - - setVersionMenuAnchor(null)} - sx={{ color: 'primary.main', gap: 1 }} - > - - Create new version - + })()} - - - {/* Only the read-only built-in v1.0 gets the enable/disable toggle - (it can't be deleted). New versions behave like custom - templates: editable + deletable, no toggle. */} + + {!isBuiltIn && ( + + )} {isReadOnly && ( - + + {isEnabled ? 'Enabled' : 'Disabled'} + } onClick={() => setDeleteOpen(true)} + sx={{ minWidth: 'auto', p: '6px' }} > - Delete + )} @@ -820,8 +817,7 @@ export default function ProviderTemplateOverview() { - Built-in version — read-only. You can enable or - disable it, but its configuration can't be edited or deleted. + Built-in version. You can only enable or disable it. @@ -844,14 +840,107 @@ export default function ProviderTemplateOverview() { {/* 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 {!template.openapi?.trim() && !template.metadata?.openapiSpecUrl?.trim() ? ( - No available resources. Add an OpenAPI specification (content - or URL) on the Connection tab to see resources. + No available resources. Add an OpenAPI specification above to see resources. ) : isSpecLoading ? ( @@ -894,7 +983,6 @@ export default function ProviderTemplateOverview() { - {/* Built-in v1.0 is read-only — block all edits on this tab. */} { setEndpointUrl(e.target.value); setIsDirty(true); + setEndpointUrlTouched(false); }} + onBlur={() => setEndpointUrlTouched(true)} placeholder="https://api.openai.com" - error={!endpointUrl.trim() || !isValidHttpUrl(endpointUrl)} + error={endpointUrlTouched && (!endpointUrl.trim() || !isValidHttpUrl(endpointUrl))} helperText={ - !endpointUrl.trim() + endpointUrlTouched && !endpointUrl.trim() ? 'Endpoint URL is required.' - : !isValidHttpUrl(endpointUrl) - ? 'Enter a valid URL.' - : '' - } - /> - - - - - Provider - { - setProvider(e.target.value); - setIsDirty(true); - }} - placeholder="other" - helperText={ - isBuiltIn - ? "Built-in templates are owned by 'wso2'." - : "Identifies who owns this template. Defaults to 'other'." - } - /> - - - - - OpenAPI Specification - - { - setOpenapiSpecUrl(e.target.value); - setSpecContent(''); - setSpecFileName(''); - setIsDirty(true); - }} - placeholder="https://api.openai.com/openapi.json" - error={!isValidHttpUrl(openapiSpecUrl)} - helperText={ - !isValidHttpUrl(openapiSpecUrl) + : endpointUrlTouched && !isValidHttpUrl(endpointUrl) ? 'Enter a valid URL.' : '' - } - /> - - - Or - - - - - {specContent.trim() ? ( - - An OpenAPI spec is stored inline - {specFileName ? ` (${specFileName})` : ''} —{' '} - {(specContent.length / 1024).toFixed(1)} KB. It powers the - resources on the Overview tab. Setting a URL here references - a spec by link instead. - - ) : null} - - - - - Logo URL - { - setLogoUrlField(e.target.value); - setIsDirty(true); - }} - placeholder="https://cdn.example.com/logos/openai.svg" - error={!isValidHttpUrl(logoUrlField)} - helperText={ - !isValidHttpUrl(logoUrlField) - ? 'Enter a valid URL.' - : '' } /> @@ -1104,6 +1079,7 @@ export default function ProviderTemplateOverview() { setIsDirty(true); }} spec={parsedSpec} + hidePerResource={isReadOnly} /> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx index 96d505800e..7a7924317c 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/ProviderTemplatesList.tsx @@ -34,17 +34,14 @@ import { TextField, Typography, } from '@wso2/oxygen-ui'; -import { ChevronLeft, ChevronRight, Clock, Plus, Search } from '@wso2/oxygen-ui-icons-react'; +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 { - isBuiltInProviderTemplate, - truncateProviderDisplayName, -} from '../../../../utils/providerTemplateDisplay'; +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'; @@ -68,10 +65,14 @@ const PROVIDER_LOGO_MAP: Record = { }; function resolveTemplateLogo(template: ProviderTemplate): string | undefined { - const fromMeta = template.metadata?.logoUrl?.trim(); + const fromMeta = template.metadata?.logoUrl?.trim() || template.logoUrl?.trim(); if (fromMeta) return fromMeta; const id = (template.id ?? '').toLowerCase(); - return PROVIDER_LOGO_MAP[id]; + 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 { @@ -96,9 +97,7 @@ export default function ProviderTemplatesList({ const templates = useMemo( () => - templatesResponse.list.filter( - (template) => !isBuiltInProviderTemplate(template.id) - ), + templatesResponse.list.filter((template) => template.provider !== 'wso2'), [templatesResponse.list] ); @@ -133,8 +132,8 @@ export default function ProviderTemplatesList({ const builtInTemplates = useMemo( () => - templatesResponse.list.filter((template) => - isBuiltInProviderTemplate(template.id) + templatesResponse.list.filter( + (template) => template.provider === 'wso2' ), [templatesResponse.list] ); @@ -290,7 +289,7 @@ export default function ProviderTemplatesList({ - {(templates.length > 0 || builtInTemplates.length > 0) && ( + {templates.length > 0 && ( + + ) : ( - {templates.length === 0 - ? 'No custom templates yet.' - : 'No custom templates match your search.'} + No custom templates match your search. )} @@ -455,11 +527,10 @@ export default function ProviderTemplatesList({ <> - Built-in templates + Built-in Templates - Ready-made templates for popular LLM providers, included by - default. + Built-in LLM providers, included by default. {filteredBuiltIn.length > 0 ? ( diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx index 02abce3bcd..4acb177d21 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/TemplateTokenMapping.tsx @@ -46,12 +46,11 @@ import { } from '../../../../utils/providerTemplateFields'; interface DiscoveredResource { - methods: string[]; + method: string; path: string; summary?: string; } -// Extract path/methods/summary entries from a parsed OpenAPI spec, one per path. function extractResources(spec: Record | null): DiscoveredResource[] { const paths = (spec?.paths ?? null) as Record< string, @@ -63,18 +62,16 @@ function extractResources(spec: Record | null): DiscoveredResou Object.keys(paths).forEach((path) => { const ops = paths[path]; if (!ops || typeof ops !== 'object') return; - const methods: string[] = []; - let summary: string | undefined; Object.keys(ops).forEach((m) => { if (!METHODS.has(m.toLowerCase())) return; - methods.push(m.toUpperCase()); - if (!summary) summary = ops[m]?.summary || ops[m]?.description || undefined; + out.push({ + method: m.toUpperCase(), + path, + summary: ops[m]?.summary || ops[m]?.description || undefined, + }); }); - if (methods.length === 0) return; - methods.sort(); - out.push({ methods, path, summary }); }); - out.sort((a, b) => a.path.localeCompare(b.path)); + out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); return out; } @@ -132,6 +129,7 @@ interface TemplateTokenMappingProps { resourceMappings: ResourceMapping[]; onChangeResourceMappings: (next: ResourceMapping[]) => void; spec: Record | null; + hidePerResource?: boolean; } export default function TemplateTokenMapping({ @@ -140,6 +138,7 @@ export default function TemplateTokenMapping({ resourceMappings, onChangeResourceMappings, spec, + hidePerResource = false, }: TemplateTokenMappingProps) { const [scope, setScope] = useState<'default' | 'resource'>('default'); const [search, setSearch] = useState(''); @@ -150,7 +149,7 @@ export default function TemplateTokenMapping({ const q = search.trim().toLowerCase(); if (!q) return resources; return resources.filter((r) => - `${r.methods.join(' ')} ${r.path} ${r.summary ?? ''}`.toLowerCase().includes(q) + `${r.method} ${r.path} ${r.summary ?? ''}`.toLowerCase().includes(q) ); }, [resources, search]); @@ -191,26 +190,29 @@ export default function TemplateTokenMapping({ ); }; + const effectiveScope = hidePerResource ? 'default' : scope; + return ( - v && setScope(v)} - sx={{ mb: 2 }} - > - Default - Per Resource - + {!hidePerResource && ( + v && setScope(v)} + sx={{ mb: 2 }} + > + Global + Per Resource + + )} - {scope === 'default' ? ( + {effectiveScope === 'default' ? ( <> - Default token & model extraction applied to all resources unless - overridden under Per Resource. Defaults follow OpenAI. + Applies to all resources unless overridden per resource. @@ -220,8 +222,8 @@ export default function TemplateTokenMapping({ - Toggle a resource on to override its token & model extraction. - Resources left off inherit the default mapping. + Turn on a resource to give it its own mapping. The rest use the + default. @@ -242,17 +244,13 @@ export default function TemplateTokenMapping({ ) : ( {filtered.map((r) => { - const key = r.path; + const key = `${r.method}-${r.path}`; const overridden = isOverridden(r.path); const isOpen = openKey === key; return ( setOpenKey(isOpen ? null : key)} trailing={ @@ -304,9 +302,8 @@ export default function TemplateTokenMapping({ /> ) : ( - Inherits the default token mapping. Turn on{' '} - Override to customize it for this - resource. + 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 - { @@ -92,18 +95,26 @@ export default function ProviderTemplateSelector({ onSelectTemplate, onRetryTemplates, }: ProviderTemplateSelectorProps) { - // Collapse the grid to two rows (8 cards) with a See more / Show less toggle. const COLLAPSED_COUNT = 8; const [showAll, setShowAll] = React.useState(false); const enabledTemplates = (templatesResponse?.list ?? []).filter( (template) => template.enabled !== false ); - const hasMore = enabledTemplates.length > COLLAPSED_COUNT; - const hiddenCount = enabledTemplates.length - COLLAPSED_COUNT; + + const familyMap = new Map(); + for (const t of enabledTemplates) { + const key = t.name.toLowerCase(); + const existing = familyMap.get(key); + if (!existing || t.isLatest) familyMap.set(key, t); + } + const deduplicatedTemplates = Array.from(familyMap.values()); + + const hasMore = deduplicatedTemplates.length > COLLAPSED_COUNT; + const hiddenCount = deduplicatedTemplates.length - COLLAPSED_COUNT; const visibleTemplates = hasMore && !showAll - ? enabledTemplates.slice(0, COLLAPSED_COUNT) - : enabledTemplates; + ? deduplicatedTemplates.slice(0, COLLAPSED_COUNT) + : deduplicatedTemplates; return ( @@ -145,7 +156,9 @@ export default function ProviderTemplateSelector({ > {visibleTemplates.map((template) => { const templateId = (template.id ?? '').toLowerCase(); - const isComingSoon = COMING_SOON_TEMPLATE_IDS.has(templateId); + const isComingSoon = COMING_SOON_TEMPLATE_IDS.has( + familyHandle(templateId) + ); const isSelected = !isComingSoon && selectedTemplateId === template.id; const logo = getLogoForTemplate(template.name); const shortName = getShortNameForTemplate(template.name); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx index 60834c1209..f087f835f1 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx @@ -44,7 +44,7 @@ interface TemplateVersionDialogProps { templateId: string; templateName: string; onClose: () => void; - onConfirm: (version: string) => void; + onConfirm: (versionTemplate: ProviderTemplate) => void; } export default function TemplateVersionDialog({ @@ -156,7 +156,10 @@ export default function TemplateVersionDialog({ Cancel diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx index f087f835f1..8269595685 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/AddNewProvider/TemplateVersionDialog.tsx @@ -96,7 +96,13 @@ export default function TemplateVersionDialog({ }, [open, templateId, currentOrganization?.uuid]); return ( - + Select a version of {templateName} @@ -124,6 +130,7 @@ export default function TemplateVersionDialog({ key={ver} selected={ver === selected} onClick={() => setSelected(ver)} + data-cyid={`template-version-option-${ver}`} sx={{ borderRadius: 1, mb: 0.5, @@ -152,7 +159,12 @@ export default function TemplateVersionDialog({ )} - From bf3b63201b0263fdef62145f13f138460016294e Mon Sep 17 00:00:00 2001 From: Tharanidk Date: Thu, 25 Jun 2026 01:47:12 +0530 Subject: [PATCH 13/20] Update LLM provider templates: rename 'provider' to 'managedBy', add 'groupVersionId', and fix test issues --- .../anthropic-template.yaml | 2 +- .../awsbedrock-template.yaml | 2 +- .../azureaifoundry-template.yaml | 2 +- .../azureopenai-template.yaml | 2 +- .../gemini-template.yaml | 2 +- .../mistral-template.yaml | 2 +- .../openai-template.yaml | 2 +- .../handlers/llm_provider_template_handler.go | 2 +- .../pkg/api/management/generated.go | 10 +- .../pkg/models/llm_provider_template.go | 14 +- .../pkg/models/llm_provider_template_test.go | 20 +- .../gateway-controller-db.sqlserver.sql | 3 +- .../pkg/utils/llm_deployment.go | 8 +- .../pkg/utils/llm_deployment_test.go | 28 +- platform-api/src/api/compat.go | 97 ------ platform-api/src/api/manual_types.go | 15 + platform-api/src/internal/constants/error.go | 3 +- .../src/internal/database/schema.postgres.sql | 4 +- platform-api/src/internal/database/schema.sql | 4 +- .../src/internal/database/schema.sqlite.sql | 2 +- .../internal/database/schema.sqlserver.sql | 10 +- platform-api/src/internal/handler/llm.go | 15 +- platform-api/src/internal/repository/llm.go | 38 ++- platform-api/src/internal/service/llm.go | 19 +- .../service/llm_provider_template_test.go | 92 ++++++ .../001-provider-and-proxy.cy.js | 22 +- .../005-custom-provider-template.cy.js | 281 ++++++++++++++++++ .../ai-workspace/cypress/support/commands.js | 60 ++++ .../CreateProviderTemplate.tsx | 4 + .../CreateProviderTemplateVersion.tsx | 9 +- .../ProviderTemplateOverview.tsx | 44 ++- .../providerTemplate/TemplateTokenMapping.tsx | 37 ++- .../src/utils/providerTemplateManifest.ts | 32 +- portals/ai-workspace/src/utils/types.ts | 2 + 34 files changed, 654 insertions(+), 235 deletions(-) delete mode 100644 platform-api/src/api/compat.go create mode 100644 portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js 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 d3a8be5b03..63b9672905 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/anthropic-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: Anthropic groupVersionId: anthropic - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 161a428e30..7bd0da662f 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/awsbedrock-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: AWS Bedrock groupVersionId: awsbedrock - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 a918ed44aa..57ace560bb 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/azureaifoundry-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: Azure AI Foundry groupVersionId: azureai-foundry - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 23214e87bc..9f7cfbc3d5 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/azureopenai-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: Azure OpenAI groupVersionId: azure-openai - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 d4767af03e..82ff835e5d 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/gemini-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: Gemini groupVersionId: gemini - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 33acf36d70..d4c9c8e876 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/mistral-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: MistralAI groupVersionId: mistralai - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 a679fdbc76..647654a246 100644 --- a/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml +++ b/gateway/gateway-controller/default-llm-provider-templates/openai-template.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: OpenAI groupVersionId: openai - provider: wso2 + managedBy: wso2 version: v1.0 promptTokens: location: payload 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 8f6dd71849..fa9083736d 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 @@ -215,6 +215,6 @@ func (s *APIServer) DeleteLLMProviderTemplate(c *gin.Context, id string) { c.JSON(http.StatusOK, gin.H{ "status": "success", "message": "LLM provider template deleted successfully", - "id": deleted.GetGroupVersionID(), + "id": deleted.GetID(), }) } diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index d12805a7c6..d47708ee2c 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -896,12 +896,12 @@ type LLMProviderTemplateData struct { // GroupVersionId Stable family-grouping identifier shared by every version of this // template. Defaults to metadata.name when omitted. - GroupVersionId *string `json:"groupVersionId,omitempty" yaml:"groupVersionId,omitempty"` - PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" yaml:"promptTokens,omitempty"` + GroupVersionId *string `json:"groupVersionId,omitempty" yaml:"groupVersionId,omitempty"` - // Provider Origin of the template. Built-in templates use 'wso2'; custom - // templates default to 'other' and may be set to any value. - Provider *string `json:"provider,omitempty" yaml:"provider,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"` diff --git a/gateway/gateway-controller/pkg/models/llm_provider_template.go b/gateway/gateway-controller/pkg/models/llm_provider_template.go index 535dec203b..8acb33ede6 100644 --- a/gateway/gateway-controller/pkg/models/llm_provider_template.go +++ b/gateway/gateway-controller/pkg/models/llm_provider_template.go @@ -28,8 +28,8 @@ import ( // DefaultTemplateVersion is used when a template does not declare a version. const DefaultTemplateVersion = "v1.0" -// DefaultTemplateProvider is used when a template does not declare a provider. -const DefaultTemplateProvider = "other" +// 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 { @@ -76,12 +76,12 @@ func (t *StoredLLMProviderTemplate) GetVersion() string { return DefaultTemplateVersion } -// GetProvider returns the template provider, defaulting to "other" when unset. -func (t *StoredLLMProviderTemplate) GetProvider() string { - if t.Configuration.Spec.Provider != nil { - if p := strings.TrimSpace(*t.Configuration.Spec.Provider); p != "" { +// 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 DefaultTemplateProvider + 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 044277329a..14daac886e 100644 --- a/gateway/gateway-controller/pkg/models/llm_provider_template_test.go +++ b/gateway/gateway-controller/pkg/models/llm_provider_template_test.go @@ -107,26 +107,26 @@ func TestStoredLLMProviderTemplate_GetVersion(t *testing.T) { } } -func TestStoredLLMProviderTemplate_GetProvider(t *testing.T) { +func TestStoredLLMProviderTemplate_GetManagedBy(t *testing.T) { tests := []struct { - name string - provider *string - expected string + name string + managedBy *string + expected string }{ - {name: "explicit provider", provider: strPtr("wso2"), expected: "wso2"}, - {name: "provider with surrounding whitespace", provider: strPtr(" custom "), expected: "custom"}, - {name: "nil provider defaults to other", provider: nil, expected: DefaultTemplateProvider}, - {name: "blank provider defaults to other", provider: strPtr(" "), expected: DefaultTemplateProvider}, + {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{Provider: tt.provider}, + Spec: api.LLMProviderTemplateData{ManagedBy: tt.managedBy}, }, } - assert.Equal(t, tt.expected, template.GetProvider()) + assert.Equal(t, tt.expected, template.GetManagedBy()) }) } } 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 1fd536431e..3465b41c11 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql @@ -120,11 +120,12 @@ CREATE TABLE dbo.llm_provider_templates ( uuid NVARCHAR(64) NOT NULL, gateway_id NVARCHAR(64) NOT NULL, group_version_id NVARCHAR(255) NOT NULL, + 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_version_id) + UNIQUE(gateway_id, group_version_id, version) ); -- Table for API keys diff --git a/gateway/gateway-controller/pkg/utils/llm_deployment.go b/gateway/gateway-controller/pkg/utils/llm_deployment.go index dbea1b8dea..27c4117eca 100644 --- a/gateway/gateway-controller/pkg/utils/llm_deployment.go +++ b/gateway/gateway-controller/pkg/utils/llm_deployment.go @@ -611,15 +611,15 @@ func (s *LLMDeploymentService) parseAndValidateLLMTemplate(params LLMTemplatePar return nil, fmt.Errorf("%w: %d error(s): %s", ErrLLMTemplateValidation, len(validationErrors), strings.Join(errs, "; ")) } - // Normalize version/provider so the persisted config and API responses are + // 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.Provider == nil || strings.TrimSpace(*tmpl.Spec.Provider) == "" { - p := models.DefaultTemplateProvider - tmpl.Spec.Provider = &p + if tmpl.Spec.ManagedBy == nil || strings.TrimSpace(*tmpl.Spec.ManagedBy) == "" { + p := models.DefaultTemplateManagedBy + tmpl.Spec.ManagedBy = &p } return &tmpl, nil } diff --git a/gateway/gateway-controller/pkg/utils/llm_deployment_test.go b/gateway/gateway-controller/pkg/utils/llm_deployment_test.go index 8ce4c3a9b6..f00f119d3d 100644 --- a/gateway/gateway-controller/pkg/utils/llm_deployment_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_deployment_test.go @@ -653,9 +653,9 @@ func TestLLMDeploymentService_CreateLLMProviderTemplate_HandleConflict(t *testin assert.Contains(t, err.Error(), "template with handle 'openai' and version 'v1.0' already exists") } -// templateYAMLWithProviderVersion builds a template manifest carrying explicit -// provider and version fields (as produced by the AI workspace download). -func templateYAMLWithProviderVersion(handle, displayName, provider, version string) []byte { +// templateYAMLWithManagedByVersion builds a template manifest carrying explicit +// managedBy and version fields (as produced by the AI workspace download). +func templateYAMLWithManagedByVersion(handle, displayName, managedBy, version string) []byte { return []byte(fmt.Sprintf(` apiVersion: gateway.api-platform.wso2.com/v1alpha1 kind: LlmProviderTemplate @@ -663,9 +663,9 @@ metadata: name: %s spec: displayName: %s - provider: %s + managedBy: %s version: %s -`, handle, displayName, provider, version)) +`, handle, displayName, managedBy, version)) } func TestLLMDeploymentService_CreateLLMProviderTemplate_VersionWise(t *testing.T) { @@ -676,21 +676,21 @@ func TestLLMDeploymentService_CreateLLMProviderTemplate_VersionWise(t *testing.T 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 provider/version round-trip. + // Deploy v1.0 of a custom template and confirm managedBy/version round-trip. v1, err := service.CreateLLMProviderTemplate(LLMTemplateParams{ - Spec: templateYAMLWithProviderVersion("deep-seek", "deep seek", "other", "v1.0"), + Spec: templateYAMLWithManagedByVersion("deep-seek", "deep seek", "other", "v1.0"), ContentType: "application/yaml", Logger: logger, }) require.NoError(t, err) - require.NotNil(t, v1.Configuration.Spec.Provider) - assert.Equal(t, "other", *v1.Configuration.Spec.Provider) + 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 handle — versions coexist, no conflict. v2, err := service.CreateLLMProviderTemplate(LLMTemplateParams{ - Spec: templateYAMLWithProviderVersion("deep-seek", "deep seek", "deepseek", "v2.0"), + Spec: templateYAMLWithManagedByVersion("deep-seek", "deep seek", "deepseek", "v2.0"), ContentType: "application/yaml", Logger: logger, }) @@ -698,17 +698,17 @@ func TestLLMDeploymentService_CreateLLMProviderTemplate_VersionWise(t *testing.T assert.NotEqual(t, v1.UUID, v2.UUID) // Handle-based lookup resolves to the latest (most recently created) version, - // and provider/version are persisted (read back from the DB, not in-memory). + // 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.Provider) - assert.Equal(t, "deepseek", *latest.Configuration.Spec.Provider) + 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: templateYAMLWithProviderVersion("deep-seek", "deep seek", "deepseek", "v2.0"), + Spec: templateYAMLWithManagedByVersion("deep-seek", "deep seek", "deepseek", "v2.0"), ContentType: "application/yaml", Logger: logger, }) diff --git a/platform-api/src/api/compat.go b/platform-api/src/api/compat.go deleted file mode 100644 index e2642d5a4b..0000000000 --- a/platform-api/src/api/compat.go +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 api - -import ( - openapi_types "github.com/oapi-codegen/runtime/types" -) - -// This file holds component types that are valid schemas in openapi.yaml but are -// dropped by oapi-codegen v2.5.1 on this OpenAPI 3.1 spec (it prints a 3.1 -// warning during `make generate`). Keeping them here — outside the generated -// file — keeps `make generate` reproducible: regenerating drops them from -// generated.go, and these hand-maintained definitions keep the package -// compiling. Remove this file once the codegen fully supports OpenAPI 3.1. - -// ApiIdentifierQ API Identifier query parameter. -type ApiIdentifierQ = string - -// GatewayStatusResponse defines model for GatewayStatusResponse. -type GatewayStatusResponse struct { - // Id Unique identifier for the gateway - Id *openapi_types.UUID `json:"id,omitempty" yaml:"id,omitempty"` - - // IsActive Indicates if the gateway is currently connected to the platform via WebSocket - IsActive *bool `json:"isActive,omitempty" yaml:"isActive,omitempty"` - - // IsCritical Whether the gateway is critical for production - IsCritical *bool `json:"isCritical,omitempty" yaml:"isCritical,omitempty"` - - // Name URL-friendly gateway identifier - Name *string `json:"name,omitempty" yaml:"name,omitempty"` -} - -// GatewayStatusListResponse defines model for GatewayStatusListResponse. -type GatewayStatusListResponse struct { - // Count Number of items in current response - Count int `binding:"required" json:"count" yaml:"count"` - List []GatewayStatusResponse `binding:"required" json:"list" yaml:"list"` - Pagination Pagination `json:"pagination" yaml:"pagination"` -} - -// RESTAPIValidationResponse defines model for RESTAPIValidationResponse. -type RESTAPIValidationResponse struct { - // Error Error details if validation fails - Error *struct { - // Code Error code indicating the type of validation failure - Code string `json:"code" yaml:"code"` - - // Message Human-readable error message - Message string `json:"message" yaml:"message"` - } `binding:"required" json:"error" yaml:"error"` - - // Valid Whether the API identifier or name-version combination is valid (not already in use) in the organization - Valid bool `binding:"required" json:"valid" yaml:"valid"` -} - -// 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 - -// ValidateRESTAPIParams defines parameters for ValidateRESTAPI. -type ValidateRESTAPIParams struct { - // Identifier **API Identifier** to check for existence within the organization. - Identifier *ApiIdentifierQ `form:"identifier,omitempty" json:"identifier,omitempty" yaml:"identifier,omitempty"` - - // Name **API Name** to check for existence within the organization. - Name *ApiNameQ `form:"name,omitempty" json:"name,omitempty" yaml:"name,omitempty"` - - // Version **API Version** to check for existence within the organization. - Version *ApiVersionQ `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` -} 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 d433a75612..85e5b68f41 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -152,7 +152,8 @@ var ( 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") + 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/schema.postgres.sql b/platform-api/src/internal/database/schema.postgres.sql index ba9ca928c1..808cd8dd3b 100644 --- a/platform-api/src/internal/database/schema.postgres.sql +++ b/platform-api/src/internal/database/schema.postgres.sql @@ -312,7 +312,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( configuration TEXT NOT NULL, openapi_spec TEXT, version VARCHAR(40) NOT NULL DEFAULT 'v1.0', - is_latest BOOLEAN NOT NULL DEFAULT TRUE, + is_latest SMALLINT NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -469,7 +469,7 @@ 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 UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_single_latest ON llm_provider_templates(organization_uuid, group_version_id) WHERE is_latest = TRUE; +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_single_latest ON llm_provider_templates(organization_uuid, group_version_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 6e02a0edf1..9c49cd8600 100644 --- a/platform-api/src/internal/database/schema.sql +++ b/platform-api/src/internal/database/schema.sql @@ -307,7 +307,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( configuration TEXT NOT NULL, openapi_spec TEXT, version VARCHAR(40) NOT NULL DEFAULT 'v1.0', - is_latest BOOLEAN NOT NULL DEFAULT TRUE, + is_latest TINYINT NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -467,7 +467,7 @@ 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 UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_single_latest ON llm_provider_templates(organization_uuid, group_version_id) WHERE is_latest = TRUE; +CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_provider_templates_single_latest ON llm_provider_templates(organization_uuid, group_version_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 15b20d3830..b2d7930f1c 100644 --- a/platform-api/src/internal/database/schema.sqlite.sql +++ b/platform-api/src/internal/database/schema.sqlite.sql @@ -310,7 +310,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_templates ( configuration TEXT NOT NULL, openapi_spec TEXT, version VARCHAR(40) NOT NULL DEFAULT 'v1.0', - is_latest BOOLEAN NOT NULL DEFAULT 1, + is_latest INTEGER NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, diff --git a/platform-api/src/internal/database/schema.sqlserver.sql b/platform-api/src/internal/database/schema.sqlserver.sql index b9073ab295..c562721850 100644 --- a/platform-api/src/internal/database/schema.sqlserver.sql +++ b/platform-api/src/internal/database/schema.sqlserver.sql @@ -350,15 +350,23 @@ CREATE TABLE dbo.llm_provider_templates ( uuid VARCHAR(40) PRIMARY KEY, organization_uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, + group_version_id VARCHAR(255) NOT NULL, name VARCHAR(253) NOT NULL, + managed_by VARCHAR(255) NOT NULL DEFAULT 'customer', description VARCHAR(1023), created_by VARCHAR(255), configuration NVARCHAR(MAX) NOT NULL, + openapi_spec NVARCHAR(MAX), + version VARCHAR(40) NOT NULL DEFAULT 'v1.0', + is_latest BIT NOT NULL DEFAULT 1, + enabled BIT NOT NULL DEFAULT 1, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, - UNIQUE(organization_uuid, handle) + UNIQUE(organization_uuid, group_version_id, version) ); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_provider_templates_single_latest' AND object_id = OBJECT_ID(N'dbo.llm_provider_templates')) +CREATE UNIQUE INDEX idx_llm_provider_templates_single_latest ON dbo.llm_provider_templates(organization_uuid, group_version_id) WHERE is_latest = 1; -- LLM Providers table IF OBJECT_ID(N'dbo.llm_providers', N'U') IS NULL diff --git a/platform-api/src/internal/handler/llm.go b/platform-api/src/internal/handler/llm.go index f8aa51f13d..51eef84ee7 100644 --- a/platform-api/src/internal/handler/llm.go +++ b/platform-api/src/internal/handler/llm.go @@ -104,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 @@ -240,6 +243,9 @@ func (h *LLMHandler) CreateLLMProviderTemplateVersion(c *gin.Context) { case errors.Is(err, constants.ErrLLMProviderTemplateVersionExists): c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", "A version with this number 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 @@ -279,8 +285,7 @@ func (h *LLMHandler) GetLLMProviderTemplateVersion(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// SetLLMProviderTemplateVersionEnabled enables/disables a specific template -// version. Body: {"enabled": true|false}. +// SetLLMProviderTemplateVersionEnabled enables or disables a specific version of a template. func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(c *gin.Context) { orgID, ok := middleware.GetOrganizationFromContext(c) if !ok { @@ -307,6 +312,9 @@ func (h *LLMHandler) SetLLMProviderTemplateVersionEnabled(c *gin.Context) { 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")) @@ -339,6 +347,9 @@ func (h *LLMHandler) UpdateLLMProviderTemplate(c *gin.Context) { 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 diff --git a/platform-api/src/internal/repository/llm.go b/platform-api/src/internal/repository/llm.go index 1ef77105c2..a5f249699a 100644 --- a/platform-api/src/internal/repository/llm.go +++ b/platform-api/src/internal/repository/llm.go @@ -52,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 { @@ -97,7 +104,7 @@ func (r *LLMProviderTemplateRepo) Create(t *model.LLMProviderTemplate) error { ` _, err = r.db.Exec(r.db.Rebind(query), t.UUID, t.OrganizationUUID, t.ID, t.GroupVersionID, t.Name, t.ManagedBy, t.Description, t.CreatedBy, - string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, t.Enabled, + string(configJSON), t.OpenAPISpec, t.Version, boolToInt(t.IsLatest), boolToInt(t.Enabled), t.CreatedAt, t.UpdatedAt, ) return err @@ -125,15 +132,12 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) } defer func() { _ = tx.Rollback() }() - // Lock the current latest row to serialize concurrent CreateNewVersion calls for - // the same template family. FOR UPDATE is Postgres-only; SQLite serializes writes - // at the connection level so no explicit lock is needed there. lockSQL := `SELECT created_by FROM llm_provider_templates WHERE group_version_id = ? AND organization_uuid = ? AND is_latest = ?` if r.db.Driver() == database.DriverPostgres || r.db.Driver() == database.DriverPostgreSQL { lockSQL += " FOR UPDATE" } var createdBy sql.NullString - err = tx.QueryRow(r.db.Rebind(lockSQL), t.GroupVersionID, t.OrganizationUUID, true).Scan(&createdBy) + err = tx.QueryRow(r.db.Rebind(lockSQL), t.GroupVersionID, t.OrganizationUUID, 1).Scan(&createdBy) if errors.Is(err, sql.ErrNoRows) { return sql.ErrNoRows } @@ -154,7 +158,7 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) // Demote the current latest within this family (same group_version_id). if _, err = tx.Exec(r.db.Rebind(` UPDATE llm_provider_templates SET is_latest = ? WHERE group_version_id = ? AND organization_uuid = ? AND is_latest = ? - `), false, t.GroupVersionID, t.OrganizationUUID, true); err != nil { + `), 0, t.GroupVersionID, t.OrganizationUUID, 1); err != nil { return err } @@ -177,7 +181,7 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), t.UUID, t.OrganizationUUID, t.ID, t.GroupVersionID, t.Name, t.ManagedBy, t.Description, t.CreatedBy, - string(configJSON), t.OpenAPISpec, t.Version, t.IsLatest, t.Enabled, + string(configJSON), t.OpenAPISpec, t.Version, boolToInt(t.IsLatest), boolToInt(t.Enabled), t.CreatedAt, t.UpdatedAt, ); err != nil { return err @@ -186,11 +190,6 @@ func (r *LLMProviderTemplateRepo) CreateNewVersion(t *model.LLMProviderTemplate) return tx.Commit() } -// familyGroupVersionID resolves the template family (grouping) key — its group_version_id -// — from any version handle in that family. Handles are version-specific, but all -// versions of a template share one immutable group_version_id, so it is the stable key -// used to list, count, promote, and delete across a template's versions. Returns -// "" if no template with the given handle exists in the organization. func (r *LLMProviderTemplateRepo) familyGroupVersionID(handle, orgUUID string) (string, error) { var base string err := r.db.QueryRow(r.db.Rebind(` @@ -205,8 +204,6 @@ func (r *LLMProviderTemplateRepo) familyGroupVersionID(handle, orgUUID string) ( return base, nil } -// GetGroupVersionID exposes the family group_version_id for a given (possibly non-latest) -// version handle, so callers can derive sibling-version handles and group a family. func (r *LLMProviderTemplateRepo) GetGroupVersionID(handle, orgUUID string) (string, error) { return r.familyGroupVersionID(handle, orgUUID) } @@ -346,7 +343,6 @@ func scanTemplateRow(row *sql.Row) (*model.LLMProviderTemplate, error) { } func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) { - // Catalog lists one entry per template — the latest version only. pageClause, pageArgs := r.db.PaginationClause(limit, offset) query := ` SELECT uuid, organization_uuid, handle, group_version_id, name, managed_by, description, created_by, configuration, openapi_spec, version, is_latest, enabled, created_at, updated_at @@ -355,7 +351,7 @@ func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*mo 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, true}, pageArgs...)...) + rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID, 1}, pageArgs...)...) if err != nil { return nil, err } @@ -484,7 +480,7 @@ func (r *LLMProviderTemplateRepo) Delete(templateID, orgUUID string) error { var remaining, latestCount int if err := tx.QueryRow(r.db.Rebind(` - SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest THEN 1 ELSE 0 END), 0) + SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest = 1 THEN 1 ELSE 0 END), 0) FROM llm_provider_templates WHERE group_version_id = ? AND organization_uuid = ? `), base, orgUUID).Scan(&remaining, &latestCount); err != nil { return err @@ -497,7 +493,7 @@ func (r *LLMProviderTemplateRepo) Delete(templateID, orgUUID string) error { WHERE group_version_id = ? AND organization_uuid = ? ORDER BY created_at DESC LIMIT 1 ) - `), true, base, orgUUID); err != nil { + `), 1, base, orgUUID); err != nil { return err } } @@ -561,7 +557,7 @@ func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version str var remaining, latestCount int if err := tx.QueryRow(r.db.Rebind(` - SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest THEN 1 ELSE 0 END), 0) + SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_latest = 1 THEN 1 ELSE 0 END), 0) FROM llm_provider_templates WHERE group_version_id = ? AND organization_uuid = ? `), base, orgUUID).Scan(&remaining, &latestCount); err != nil { return err @@ -574,7 +570,7 @@ func (r *LLMProviderTemplateRepo) DeleteVersion(templateID, orgUUID, version str WHERE group_version_id = ? AND organization_uuid = ? ORDER BY created_at DESC LIMIT 1 ) - `), true, base, orgUUID); err != nil { + `), 1, base, orgUUID); err != nil { return err } } @@ -596,7 +592,7 @@ func (r *LLMProviderTemplateRepo) Count(orgUUID string) (int, error) { SELECT COUNT(*) FROM llm_provider_templates WHERE organization_uuid = ? AND (managed_by = 'wso2' OR (managed_by != 'wso2' AND is_latest = ?)) - `), orgUUID, true).Scan(&count); err != nil { + `), orgUUID, 1).Scan(&count); err != nil { return 0, err } return count, nil diff --git a/platform-api/src/internal/service/llm.go b/platform-api/src/internal/service/llm.go index 65e8f51d19..d731eb3834 100644 --- a/platform-api/src/internal/service/llm.go +++ b/platform-api/src/internal/service/llm.go @@ -131,6 +131,10 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api. } handle := makeTemplateHandle(baseHandle, version) + if req.Provider != nil && strings.TrimSpace(*req.Provider) == 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) @@ -227,6 +231,9 @@ func (s *LLMProviderTemplateService) Update(orgUUID, handle string, req *api.LLM if req.Name == "" { return nil, constants.ErrInvalidInput } + if req.Provider != nil && strings.TrimSpace(*req.Provider) == constants.PolicyManagedByWSO2 { + return nil, constants.ErrLLMProviderTemplateManagedByReserved + } existing, err := s.repo.GetByID(handle, orgUUID) if err != nil { @@ -331,7 +338,7 @@ func (s *LLMProviderTemplateService) CreateVersion(orgUUID, handle, createdBy st GroupVersionID: baseHandle, Name: req.Name, Description: utils.ValueOrEmpty(req.Description), - ManagedBy: defaultTemplateManagedBy(req.Provider), + ManagedBy: constants.PolicyManagedByCustomer, CreatedBy: createdBy, Version: version, OpenAPISpec: utils.ValueOrEmpty(req.Openapi), @@ -412,6 +419,7 @@ func (s *LLMProviderTemplateService) GetVersion(orgUUID, handle, version string) } // 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 == "" { @@ -420,6 +428,15 @@ func (s *LLMProviderTemplateService) SetVersionEnabled(orgUUID, handle, version 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 diff --git a/platform-api/src/internal/service/llm_provider_template_test.go b/platform-api/src/internal/service/llm_provider_template_test.go index be67a7665b..52a25b60e5 100644 --- a/platform-api/src/internal/service/llm_provider_template_test.go +++ b/platform-api/src/internal/service/llm_provider_template_test.go @@ -230,6 +230,22 @@ func TestLLMProviderTemplateServiceCreate_RejectsInvalidVersion(t *testing.T) { } } +func TestLLMProviderTemplateServiceCreate_RejectsReservedManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := validTemplateRequest("My Provider") + req.Provider = &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) @@ -245,6 +261,22 @@ func TestLLMProviderTemplateServiceCreate_ReturnsConflictForDuplicateHandle(t *t // ---- Update ---- +func TestLLMProviderTemplateServiceUpdate_RejectsReservedManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := validTemplateRequest("My Provider") + req.Provider = &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) { @@ -356,6 +388,27 @@ func TestLLMProviderTemplateServiceCreateVersion_Success(t *testing.T) { } } +func TestLLMProviderTemplateServiceCreateVersion_ForkFromBuiltinSetsCustomerManagedBy(t *testing.T) { + repo := &mockLLMProviderTemplateCRUDRepo{getGroupVersionIDResult: "mistralai"} + svc := NewLLMProviderTemplateService(repo) + + wso2 := "wso2" + req := &api.CreateLLMProviderTemplateVersionRequest{Name: "Mistral", Version: "v2.0", Provider: &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{getGroupVersionIDResult: ""} svc := NewLLMProviderTemplateService(repo) @@ -494,6 +547,45 @@ func TestLLMProviderTemplateServiceSetVersionEnabled_NotFound(t *testing.T) { } } +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) { 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 5f947e4937..07f26f55bf 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', () => { @@ -106,7 +110,7 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { timeout: 30000, }).should('be.visible').click(); - cy.get('body', { timeout: 45000 }).should(($body) => { + 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 @@ -305,10 +309,10 @@ function toSlug(value) { 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: 45000 }) + cy.get('[data-cyid^="template-version-option-"]', { timeout: 30000 }) .first() .click(); - cy.get('[data-cyid="template-version-continue-button"]', { timeout: 45000 }) + 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..28af2f8e62 --- /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/v1/organizations', + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + }) + .then((response) => { + expect(response.status).to.eq(200); + organizationId = response.body?.id ?? ''; + expect(organizationId).to.not.equal(''); + }); + }); + + afterEach(() => { + const targeted = (authToken && organizationId) + ? deleteProvider(authToken, organizationId, createdProviderId || providerId) + .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.get('[data-cyid="provider-template-delete-button"]', { timeout: 30000 }).scrollIntoView(); + // v1.0 remains, so the template stays — now showing the new latest version. + cy.contains('button', 'v1.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(); + }); + + // 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/v1/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/v1/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/v1/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..98603ed751 100644 --- a/portals/ai-workspace/cypress/support/commands.js +++ b/portals/ai-workspace/cypress/support/commands.js @@ -43,3 +43,63 @@ 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 doSweep = (token, orgId) => { + return cy + .request({ + method: 'GET', + url: `/api-proxy/api/v1/llm-providers?organizationId=${encodeURIComponent(orgId)}&limit=100`, + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }) + .then((response) => { + if (response.status !== 200) return; + const e2eProviders = (response.body?.list ?? []).filter( + (p) => typeof p.name === 'string' && p.name.startsWith('E2E ') + ); + if (!e2eProviders.length) return; + return cy.wrap(e2eProviders).each((provider) => + cy.request({ + method: 'DELETE', + url: `/api-proxy/api/v1/llm-providers/${encodeURIComponent(provider.id)}?organizationId=${encodeURIComponent(orgId)}`, + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }) + ); + }); + }; + + 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/v1/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/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx index 8d61207b07..f0919c1fe6 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplate.tsx @@ -137,6 +137,10 @@ export default function CreateProviderTemplate() { 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); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx index 54eba76cd0..581a355108 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/providerTemplate/CreateProviderTemplateVersion.tsx @@ -170,6 +170,10 @@ function CreateProviderTemplateVersionForm({ 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); @@ -249,7 +253,6 @@ function CreateProviderTemplateVersionForm({ const payload: ProviderTemplate = { id: toTemplateId(`${template.name ?? ''} ${version.trim()}`), name: template.name, - provider: template.provider, version: version.trim(), description: description.trim() || undefined, resourceMappings: template.resourceMappings, @@ -349,7 +352,7 @@ function CreateProviderTemplateVersionForm({ error={version.trim().length > 0 && !isVersionValid} helperText={ version.trim().length > 0 && !isVersionValid - ? 'Use the format v., e.g. v2.0' + ? 'Expected: v.' : '' } /> @@ -411,7 +414,7 @@ function CreateProviderTemplateVersionForm({