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