Skip to content
Open
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
3 changes: 3 additions & 0 deletions gateway/examples/llm-provider-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: my-llm-provider-template
spec:
displayName: OpenAI
groupId: anthropic

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we change this to openai for consistency?

managedBy: wso2
version: v1.0
promptTokens:
location: payload
identifier: $.usage.inputTokens
Expand Down
23 changes: 23 additions & 0 deletions gateway/gateway-controller/api/management-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5566,6 +5566,29 @@ components:
minLength: 1
maxLength: 253
example: OpenAI
groupId:
type: string
Comment thread
Tharanidk marked this conversation as resolved.
description: |
Stable family-grouping identifier shared by every version of this
template. Multiple versions with the same groupId but different
handles are surfaced as versions of one template. Defaults to
metadata.name when omitted.
maxLength: 40
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: 255
example: wso2
version:
type: string
description: |
Template content version (e.g. v1.0). Multiple versions of the same
groupId can coexist; defaults to v1.0 when omitted.
maxLength: 30
example: v1.0
promptTokens:
$ref: '#/components/schemas/ExtractionIdentifier'
completionTokens:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: anthropic
spec:
displayName: Anthropic
groupId: anthropic
managedBy: wso2
version: v1.0
Comment thread
Tharanidk marked this conversation as resolved.
promptTokens:
location: payload
identifier: $.usage.input_tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: awsbedrock
spec:
displayName: AWS Bedrock
groupId: awsbedrock
managedBy: wso2
version: v1.0
promptTokens:
location: payload
identifier: $.usage.inputTokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: gemini
spec:
displayName: Gemini
groupId: gemini
managedBy: wso2
version: v1.0
promptTokens:
location: payload
identifier: $.usageMetadata.promptTokenCount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: mistralai
spec:
displayName: MistralAI
groupId: mistralai
managedBy: wso2
version: v1.0
promptTokens:
location: payload
identifier: $.usage.prompt_tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ metadata:
name: openai
spec:
displayName: OpenAI
groupId: openai
managedBy: wso2
version: v1.0
promptTokens:
location: payload
identifier: $.usage.prompt_tokens
Expand Down
562 changes: 290 additions & 272 deletions gateway/gateway-controller/pkg/api/management/generated.go

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions gateway/gateway-controller/pkg/models/llm_provider_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @malinthaprasan

For Custom Policies, we are using.

managedBy: customer

Should we use the same for the LLM Provider template?
Or something like other?

managedBy: other

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use one approach ideally. Do we see any issue in managedBy: wso2/customer pair?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm, yeah +1 for a consistent approach.
Ok. In the last code review meeting, we discussed changing this.


// StoredLLMProviderTemplate represents the LLM provider template stored in the database and in-memory
type StoredLLMProviderTemplate struct {
UUID string `json:"uuid"`
Expand All @@ -36,3 +43,30 @@ type StoredLLMProviderTemplate struct {
func (t *StoredLLMProviderTemplate) GetHandle() string {
return t.Configuration.Metadata.Name
}

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) GetVersion() string {
if t.Configuration.Spec.Version != nil {
if v := strings.TrimSpace(*t.Configuration.Spec.Version); v != "" {
return v
}
}
return DefaultTemplateVersion
}

func (t *StoredLLMProviderTemplate) GetManagedBy() string {
if t.Configuration.Spec.ManagedBy != nil {
if p := strings.TrimSpace(*t.Configuration.Spec.ManagedBy); p != "" {
return p
}
}
return DefaultTemplateManagedBy
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,53 @@ func TestStoredLLMProviderTemplate_GetHandle(t *testing.T) {
})
}
}

func newTemplate(name string, groupID, version, managedBy *string) *StoredLLMProviderTemplate {
return &StoredLLMProviderTemplate{
Configuration: api.LLMProviderTemplate{
Metadata: api.Metadata{Name: name},
Spec: api.LLMProviderTemplateData{
GroupId: groupID,
Version: version,
ManagedBy: managedBy,
},
},
}
}

func ptr(s string) *string { return &s }

func TestStoredLLMProviderTemplate_GetGroupID(t *testing.T) {
// Explicit groupId is returned verbatim.
tmpl := newTemplate("openai-v2", ptr("openai"), nil, nil)
assert.Equal(t, "openai", tmpl.GetGroupID())

// Falls back to metadata.name (the handle) when groupId is unset or blank.
assert.Equal(t, "openai-v2", newTemplate("openai-v2", nil, nil, nil).GetGroupID())
assert.Equal(t, "openai-v2", newTemplate("openai-v2", ptr(" "), nil, nil).GetGroupID())
}

func TestStoredLLMProviderTemplate_GetVersion(t *testing.T) {
assert.Equal(t, "v2.0", newTemplate("openai", nil, ptr("v2.0"), nil).GetVersion())
// Defaults to v1.0 when unset or blank.
assert.Equal(t, DefaultTemplateVersion, newTemplate("openai", nil, nil, nil).GetVersion())
assert.Equal(t, DefaultTemplateVersion, newTemplate("openai", nil, ptr(" "), nil).GetVersion())
}

func TestStoredLLMProviderTemplate_GetManagedBy(t *testing.T) {
assert.Equal(t, "wso2", newTemplate("openai", nil, nil, ptr("wso2")).GetManagedBy())
// Defaults to customer when unset or blank.
assert.Equal(t, DefaultTemplateManagedBy, newTemplate("openai", nil, nil, nil).GetManagedBy())
assert.Equal(t, DefaultTemplateManagedBy, newTemplate("openai", nil, nil, ptr("")).GetManagedBy())
}

// Two deployed versions of the same template carry distinct handles but share a
// groupId, so the AI workspace can present them as versions of one template.
func TestStoredLLMProviderTemplate_VersionsShareGroupID(t *testing.T) {
v1 := newTemplate("openai-v1", ptr("openai"), ptr("v1.0"), ptr("customer"))
v2 := newTemplate("openai-v2", ptr("openai"), ptr("v2.0"), ptr("customer"))

assert.NotEqual(t, v1.GetHandle(), v2.GetHandle())
assert.Equal(t, v1.GetGroupID(), v2.GetGroupID())
assert.NotEqual(t, v1.GetVersion(), v2.GetVersion())
}
18 changes: 17 additions & 1 deletion gateway/gateway-controller/pkg/utils/llm_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,12 +592,28 @@ type LLMTemplateParams struct {
Logger *slog.Logger
}

// parseAndValidateLLMTemplate parses the raw spec and validates it, returning the typed template.
// normalizeTemplateDefaults fills in default values for GroupId, Version, and ManagedBy if they are missing or empty.
func normalizeTemplateDefaults(tmpl *api.LLMProviderTemplate) {
if tmpl.Spec.GroupId == nil || strings.TrimSpace(*tmpl.Spec.GroupId) == "" {
g := tmpl.Metadata.Name
tmpl.Spec.GroupId = &g
}
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
}
}

func (s *LLMDeploymentService) parseAndValidateLLMTemplate(params LLMTemplateParams) (*api.LLMProviderTemplate, error) {
var tmpl api.LLMProviderTemplate
if err := s.parser.Parse(params.Spec, params.ContentType, &tmpl); err != nil {
return nil, fmt.Errorf("%w: failed to parse template configuration: %v", ErrLLMTemplateValidation, err)
}
normalizeTemplateDefaults(&tmpl)

// Render template expressions into a separate copy for validation only; tmpl stays unrendered for persistence.
renderHolder := &models.StoredConfig{Configuration: tmpl}
Expand Down
98 changes: 98 additions & 0 deletions gateway/gateway-controller/pkg/utils/llm_deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1354,3 +1354,101 @@ func TestLLMDeploymentService_InitializeOOBTemplates_UpdateExisting(t *testing.T
assert.NoError(t, err)
assert.Equal(t, "Updated Template", found.Configuration.Spec.DisplayName)
}

func TestNormalizeTemplateDefaults(t *testing.T) {
strPtr := func(s string) *string { return &s }

t.Run("fills defaults when fields are absent", func(t *testing.T) {
tmpl := &api.LLMProviderTemplate{
Metadata: api.Metadata{Name: "mistralai-v2"},
Spec: api.LLMProviderTemplateData{DisplayName: "MistralAI"},
}
normalizeTemplateDefaults(tmpl)

require.NotNil(t, tmpl.Spec.GroupId)
// groupId falls back to the handle (metadata.name), never overwrites it.
assert.Equal(t, "mistralai-v2", *tmpl.Spec.GroupId)
require.NotNil(t, tmpl.Spec.Version)
assert.Equal(t, models.DefaultTemplateVersion, *tmpl.Spec.Version)
require.NotNil(t, tmpl.Spec.ManagedBy)
assert.Equal(t, models.DefaultTemplateManagedBy, *tmpl.Spec.ManagedBy)
// The handle itself is left untouched.
assert.Equal(t, "mistralai-v2", tmpl.Metadata.Name)
})

t.Run("preserves explicit values", func(t *testing.T) {
tmpl := &api.LLMProviderTemplate{
Metadata: api.Metadata{Name: "mistralai-v2"},
Spec: api.LLMProviderTemplateData{
DisplayName: "MistralAI",
GroupId: strPtr("mistralai"),
Version: strPtr("v2.0"),
ManagedBy: strPtr("wso2"),
},
}
normalizeTemplateDefaults(tmpl)

assert.Equal(t, "mistralai", *tmpl.Spec.GroupId)
assert.Equal(t, "v2.0", *tmpl.Spec.Version)
assert.Equal(t, "wso2", *tmpl.Spec.ManagedBy)
})
}

func TestTemplateVersionsShareGroupID(t *testing.T) {
const v1YAML = `apiVersion: gateway.api-platform.wso2.com/v1
kind: LlmProviderTemplate
metadata:
name: deep-seek-v1-0
spec:
displayName: deep seek
groupId: deep-seek
managedBy: customer
version: v1.0
promptTokens:
location: payload
identifier: $.usage.prompt_tokens
totalTokens:
location: payload
identifier: $.usage.total_tokens
`
const v2YAML = `apiVersion: gateway.api-platform.wso2.com/v1
kind: LlmProviderTemplate
metadata:
name: deep-seek-v2-0
spec:
displayName: deep seek
groupId: deep-seek
managedBy: customer
version: v2.0
promptTokens:
location: payload
identifier: $.usage.prompt_tokens
totalTokens:
location: payload
identifier: $.usage.total_tokens
`

parser := config.NewParser()
parse := func(raw string) *models.StoredLLMProviderTemplate {
var tmpl api.LLMProviderTemplate
require.NoError(t, parser.Parse([]byte(raw), "application/yaml", &tmpl))
normalizeTemplateDefaults(&tmpl)
return &models.StoredLLMProviderTemplate{Configuration: tmpl}
}

v1 := parse(v1YAML)
v2 := parse(v2YAML)

// Two separate templates: handles are distinct and taken verbatim from metadata.name.
assert.Equal(t, "deep-seek-v1-0", v1.GetHandle())
assert.Equal(t, "deep-seek-v2-0", v2.GetHandle())
assert.NotEqual(t, v1.GetHandle(), v2.GetHandle())

// Same group, different versions — this is what lets the AI workspace group them.
assert.Equal(t, "deep-seek", v1.GetGroupID())
assert.Equal(t, v1.GetGroupID(), v2.GetGroupID())
assert.Equal(t, "v1.0", v1.GetVersion())
assert.Equal(t, "v2.0", v2.GetVersion())
assert.NotEqual(t, v1.GetVersion(), v2.GetVersion())
assert.Equal(t, "customer", v1.GetManagedBy())
}
Loading