Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: anthropic
spec:
displayName: Anthropic
groupId: anthropic
groupId: wso2-anthropic
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: awsbedrock
spec:
displayName: AWS Bedrock
groupId: awsbedrock
groupId: wso2-awsbedrock
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: azureai-foundry
spec:
displayName: Azure AI Foundry
groupId: azureai-foundry
groupId: wso2-azureai-foundry
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: azure-openai
spec:
displayName: Azure OpenAI
groupId: azure-openai
groupId: wso2-azure-openai
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: gemini
spec:
displayName: Gemini
groupId: gemini
groupId: wso2-gemini
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: mistralai
spec:
displayName: MistralAI
groupId: mistralai
groupId: wso2-mistralai
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ metadata:
name: openai
spec:
displayName: OpenAI
groupId: openai
groupId: wso2-openai
managedBy: wso2
version: v1.0
promptTokens:
Expand Down
1 change: 1 addition & 0 deletions platform-api/internal/apperror/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ var (
LLMProviderTemplateDisabled = def(CodeLLMProviderTemplateDisabled, http.StatusBadRequest, "The referenced LLM provider template is disabled.")
LLMProviderTemplateReadOnly = def(CodeLLMProviderTemplateReadOnly, http.StatusForbidden, "Built-in templates are read-only and cannot be modified.")
LLMProviderTemplateNotToggleable = def(CodeLLMProviderTemplateNotToggleable, http.StatusForbidden, "Only built-in templates can be enabled or disabled.")
LLMProviderTemplateBuiltInImmutable = def(CodeLLMProviderTemplateBuiltInImmutable, http.StatusForbidden, "Built-in templates cannot have new versions. Create a copy to make a custom template.")
)

// Gateway entries.
Expand Down
1 change: 1 addition & 0 deletions platform-api/internal/apperror/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const (
CodeLLMProviderTemplateDisabled = "LLM_PROVIDER_TEMPLATE_DISABLED"
CodeLLMProviderTemplateReadOnly = "LLM_PROVIDER_TEMPLATE_READ_ONLY"
CodeLLMProviderTemplateNotToggleable = "LLM_PROVIDER_TEMPLATE_NOT_TOGGLEABLE"
CodeLLMProviderTemplateBuiltInImmutable = "LLM_PROVIDER_TEMPLATE_BUILT_IN_IMMUTABLE"
)

// Gateway domain codes, matching the examples documented in resources/openapi.yaml.
Expand Down
6 changes: 6 additions & 0 deletions platform-api/internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ const (

const TemplateManagedByOrganization = "organization"

// ReservedTemplateGroupIDPrefix is the group_id namespace reserved for WSO2-shipped
// built-in templates. Custom templates created via the REST API must not use it; a
// generated group_id that falls in this namespace is rewritten with the "x" prefix
// (e.g. "wso2-openai" -> "xwso2-openai").
const ReservedTemplateGroupIDPrefix = "wso2-"

// ValidPolicyManagedBy holds accepted values for the managed_by field on gateway custom policies
var ValidPolicyManagedBy = map[string]bool{
PolicyManagedByOrganization: true,
Expand Down
1 change: 1 addition & 0 deletions platform-api/internal/repository/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ type LLMProviderTemplateRepository interface {
Exists(templateID, orgUUID string) (bool, error)
GetGroupID(handle, orgUUID string) (string, error)
ManagedByForHandle(handle, orgUUID string) (string, error)
ManagedByForGroupID(groupID, orgUUID string) (string, error)
CountProvidersUsingTemplate(templateID, orgUUID, version string) (int, error)
}

Expand Down
16 changes: 16 additions & 0 deletions platform-api/internal/repository/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@ func (r *LLMProviderTemplateRepo) ManagedByForHandle(handle, orgUUID string) (st
return managedBy, nil
}

// ManagedByForGroupID returns the managed_by value for a template family (group_id).
// Returns an empty string when the family does not exist.
func (r *LLMProviderTemplateRepo) ManagedByForGroupID(groupID, orgUUID string) (string, error) {
var managedBy string
err := r.db.QueryRow(r.db.Rebind(`
SELECT managed_by FROM llm_provider_templates WHERE group_id = ? AND organization_uuid = ?
ORDER BY (SELECT NULL) `+r.db.FetchFirstClause(1)), groupID, 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, group_id, display_name, managed_by, description, created_by, updated_by,
Expand Down
23 changes: 23 additions & 0 deletions platform-api/internal/service/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ func (s *LLMProviderTemplateService) Create(orgUUID, createdBy string, req *api.
if err != nil || baseHandle == "" {
return nil, apperror.ValidationFailed.New("The displayName must contain at least one alphanumeric character.")
}
baseHandle = applyReservedGroupIDGuard(baseHandle)
version := "v1.0"
if v := req.Version; v != "" {
normalized, ok := normalizeTemplateVersion(v)
Expand Down Expand Up @@ -471,6 +472,16 @@ func makeTemplateHandle(baseHandle, version string) string {
return baseHandle + "-" + strings.ReplaceAll(strings.ToLower(strings.TrimSpace(version)), ".", "-")
}

// applyReservedGroupIDGuard rewrites a generated custom-template group_id that falls in
// the reserved WSO2 namespace ("wso2" exactly, or a "wso2-" prefix) to the "xwso2-"
// namespace, so custom templates can never collide with WSO2 built-ins.
func applyReservedGroupIDGuard(baseHandle string) string {
if baseHandle == constants.PolicyManagedByWSO2 || strings.HasPrefix(baseHandle, constants.ReservedTemplateGroupIDPrefix) {
return "x" + baseHandle
}
return baseHandle
}

func templateVersionCreatable(v string) bool {
major, _, ok := strings.Cut(strings.TrimPrefix(v, "v"), ".")
if !ok {
Expand Down Expand Up @@ -504,6 +515,18 @@ func (s *LLMProviderTemplateService) CreateVersion(orgUUID, groupID, createdBy s
if count == 0 {
return nil, apperror.LLMProviderTemplateNotFound.New()
}

// Built-in (WSO2-managed) families are immutable via the REST API: new versions are
// only ever added by WSO2 through the seed data. Users must instead copy a built-in,
// which creates a new custom family through Create.
familyManagedBy, err := s.repo.ManagedByForGroupID(groupID, orgUUID)
if err != nil {
return nil, fmt.Errorf("failed to resolve template family owner: %w", err)
}
if familyManagedBy == constants.PolicyManagedByWSO2 {
return nil, apperror.LLMProviderTemplateBuiltInImmutable.New()
}

baseHandle := groupID

m := &model.LLMProviderTemplate{
Expand Down
55 changes: 39 additions & 16 deletions platform-api/internal/service/llm_provider_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type mockLLMProviderTemplateCRUDRepo struct {
getGroupIDResult string
getGroupIDErr error

managedByForGroupIDResult string
managedByForGroupIDErr error

renameFamilyCalled bool
renameFamilyBase string
renameFamilyOrg string
Expand Down Expand Up @@ -105,6 +108,10 @@ func (m *mockLLMProviderTemplateCRUDRepo) GetGroupID(handle, orgUUID string) (st
return m.getGroupIDResult, m.getGroupIDErr
}

func (m *mockLLMProviderTemplateCRUDRepo) ManagedByForGroupID(groupID, orgUUID string) (string, error) {
return m.managedByForGroupIDResult, m.managedByForGroupIDErr
}

func (m *mockLLMProviderTemplateCRUDRepo) RenameFamily(baseHandle, orgUUID, name string) error {
m.renameFamilyCalled = true
m.renameFamilyBase = baseHandle
Expand Down Expand Up @@ -193,6 +200,29 @@ func TestLLMProviderTemplateServiceCreate_Success(t *testing.T) {
}
}

func TestLLMProviderTemplateServiceCreate_RewritesReservedWSO2Prefix(t *testing.T) {
cases := map[string]string{
"wso2 openai": "xwso2-openai", // slugifies to "wso2-openai"
"WSO2 Template": "xwso2-template",
"wso2": "xwso2",
}
for displayName, wantGroupID := range cases {
repo := &mockLLMProviderTemplateCRUDRepo{}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())

resp, err := svc.Create("org-1", "alice", validTemplateRequest(displayName))
if err != nil {
t.Fatalf("[%s] expected no error, got: %v", displayName, err)
}
if repo.created == nil || repo.created.GroupID != wantGroupID {
t.Fatalf("[%s] expected reserved prefix rewrite to group_id %q, got: %#v", displayName, wantGroupID, repo.created)
}
if resp == nil || resp.Id == nil || !strings.HasPrefix(*resp.Id, wantGroupID) {
t.Fatalf("[%s] expected handle derived from %q, got: %#v", displayName, wantGroupID, resp)
}
}
}

func TestLLMProviderTemplateServiceCreate_RejectsMissingEndpoint(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())
Expand Down Expand Up @@ -373,7 +403,7 @@ func TestLLMProviderTemplateServiceUpdate_PropagatesNameToFamily(t *testing.T) {
// ---- CreateVersion ----

func TestLLMProviderTemplateServiceCreateVersion_Success(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{countVersionsResult: 1}
repo := &mockLLMProviderTemplateCRUDRepo{countVersionsResult: 1, managedByForGroupIDResult: constants.TemplateManagedByOrganization}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())

req := &api.CreateLLMProviderTemplateVersionRequest{DisplayName: stringPtr("Mistral"), Version: "v2.0"}
Expand All @@ -392,24 +422,17 @@ func TestLLMProviderTemplateServiceCreateVersion_Success(t *testing.T) {
}
}

func TestLLMProviderTemplateServiceCreateVersion_ForkFromBuiltinSetsOrganizationManagedBy(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{countVersionsResult: 1}
func TestLLMProviderTemplateServiceCreateVersion_RejectsNewVersionOnBuiltinFamily(t *testing.T) {
repo := &mockLLMProviderTemplateCRUDRepo{countVersionsResult: 1, managedByForGroupIDResult: constants.PolicyManagedByWSO2}
svc := NewLLMProviderTemplateService(repo, &noopAuditRepo{}, newTestIdentityService())

wso2 := "wso2"
req := &api.CreateLLMProviderTemplateVersionRequest{DisplayName: stringPtr("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")
req := &api.CreateLLMProviderTemplateVersionRequest{DisplayName: stringPtr("Mistral"), Version: "v2.0"}
_, err := svc.CreateVersion("org-1", "wso2-mistralai", "test-user", req)
if !apperror.LLMProviderTemplateBuiltInImmutable.Is(err) {
t.Fatalf("expected LLMProviderTemplateBuiltInImmutable when adding a version to a built-in family, got: %v", err)
}
if repo.createdVersion.ManagedBy != constants.TemplateManagedByOrganization {
t.Fatalf("expected forked version to have managedBy='organization', got: %q", repo.createdVersion.ManagedBy)
if repo.createdVersion != nil {
t.Fatalf("expected no version to be created for a built-in family, got: %#v", repo.createdVersion)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: anthropic
spec:
groupId: anthropic
groupId: wso2-anthropic
displayName: Anthropic
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: awsbedrock
spec:
groupId: awsbedrock
groupId: wso2-awsbedrock
displayName: AWS Bedrock
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: azureai-foundry
spec:
groupId: azureai-foundry
groupId: wso2-azureai-foundry
displayName: Azure AI Foundry
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: azure-openai
spec:
groupId: azure-openai
groupId: wso2-azure-openai
displayName: Azure OpenAI
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: gemini
spec:
groupId: gemini
groupId: wso2-gemini
displayName: Gemini
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: mistralai
spec:
groupId: mistralai
groupId: wso2-mistralai
displayName: Mistral
managedBy: wso2
version: v1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
metadata:
name: openai
spec:
groupId: openai
groupId: wso2-openai
displayName: OpenAI
managedBy: wso2
version: v1.0
Expand Down
11 changes: 8 additions & 3 deletions platform-api/resources/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,7 @@ paths:
- `query=groupId:<id>&version:<ver>` -> retrieve a specific template version

All filters are provided via the URL-encoded `query` parameter (e.g.
`?query=groupId%3Aopenai%26version%3Av2.0`).
`?query=groupId%3Awso2-openai%26version%3Av2.0`).

Returns a single `LLMProviderTemplate` only when both `groupId` and `version` are specified;
otherwise returns an `LLMProviderTemplateListResponse`.
Expand All @@ -1076,10 +1076,10 @@ paths:
versions; adding `&version:<ver>` returns the single full template
for that version. Terms are `&`-separated `key:value` pairs and the
whole value is percent-encoded (e.g.
groupId%3Aopenai%26version%3Av2.0).
groupId%3Awso2-openai%26version%3Av2.0).
schema:
type: string
example: groupId:openai&version:v2.0
example: groupId:wso2-openai&version:v2.0
- $ref: '#/components/parameters/limit-Q'
- $ref: '#/components/parameters/offset-Q'
responses:
Expand Down Expand Up @@ -1119,6 +1119,11 @@ paths:
is rejected. `toTemplateId`, when supplied, must equal the handle
derived from the family and `toVersion`. Organization is identified via
the JWT token.

Built-in (WSO2-managed) template families are immutable: adding a version
to one is rejected with `403`. To base a custom template on a built-in,
create a new template (`POST /llm-provider-templates`) instead — it gets
its own `group_id` and starts at v1.0.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
operationId: copyLLMProviderTemplateVersion
security:
- OAuth2Security:
Expand Down
Loading
Loading