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
1 change: 1 addition & 0 deletions gateway/gateway-controller/api/management-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5910,6 +5910,7 @@ components:
description: >
Major-only translator policy version (for example v1). The Gateway
Controller resolves it to the installed full version.
pattern: '^v\d+$'
minLength: 1
example: v1
params:
Expand Down
5 changes: 5 additions & 0 deletions gateway/gateway-controller/pkg/config/llm_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,11 @@ func (v *LLMValidator) validateLLMProxyTransformer(fieldPrefix string, transform
Field: fieldPrefix + ".version",
Message: "Transformer version is required",
})
} else if !majorVersionPattern.MatchString(transformer.Version) {
errors = append(errors, ValidationError{
Field: fieldPrefix + ".version",
Message: "Transformer version must be major-only (e.g. v1)",
})
}
return errors
}
Expand Down
29 changes: 29 additions & 0 deletions kubernetes/gateway-operator/api/v1alpha1/llmproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)

// LLMProxyProvider references a deployed LlmProvider that this proxy fronts.
Expand Down Expand Up @@ -47,6 +48,34 @@ type LLMProxyAdditionalProvider struct {
// calls when the referenced provider is protected by an auth policy.
// +optional
Auth *LLMUpstreamAuth `json:"auth,omitempty"`

// Transformer optionally applies a request/response translator when this
// provider is the selected upstream. The proxy injects it as a conditional
// policy that runs only when this provider is selected.
// +optional
Transformer *LLMProxyTransformer `json:"transformer,omitempty"`
}

// LLMProxyTransformer is a request/response translator applied when its owning
// provider is the selected upstream; mirrors the management-API
// LLMProxyTransformer payload.
type LLMProxyTransformer struct {
// Type is the translator policy name (for example openai-to-anthropic).
// +kubebuilder:validation:Required
Type string `json:"type"`

// Version is the major-only translator policy version (for example v1).
// The Gateway Controller resolves it to the installed full version.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^v\d+$`
Version string `json:"version"`

// Params carries translator-specific parameters (for example model,
// apiVersion).
// +optional
// +kubebuilder:pruning:PreserveUnknownFields
// +kubebuilder:validation:Schemaless
Params *runtime.RawExtension `json:"params,omitempty"`
}

// LLMProxyConfigData mirrors the management-API LLMProxyConfigData payload.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ spec:
id:
description: Id is the LlmProvider handle (metadata.name).
type: string
transformer:
description: |-
Transformer optionally applies a request/response translator when this
provider is the selected upstream. The proxy injects it as a conditional
policy that runs only when this provider is selected.
properties:
params:
description: |-
Params carries translator-specific parameters (for example model,
apiVersion).
x-kubernetes-preserve-unknown-fields: true
type:
description: Type is the translator policy name (for example
openai-to-anthropic).
type: string
version:
description: |-
Version is the major-only translator policy version (for example v1).
The Gateway Controller resolves it to the installed full version.
pattern: ^v\d+$
type: string
required:
- type
- version
type: object
required:
- id
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ spec:
id:
description: Id is the LlmProvider handle (metadata.name).
type: string
transformer:
description: |-
Transformer optionally applies a request/response translator when this
provider is the selected upstream. The proxy injects it as a conditional
policy that runs only when this provider is selected.
properties:
params:
description: |-
Params carries translator-specific parameters (for example model,
apiVersion).
x-kubernetes-preserve-unknown-fields: true
type:
description: Type is the translator policy name (for example
openai-to-anthropic).
type: string
version:
description: |-
Version is the major-only translator policy version (for example v1).
The Gateway Controller resolves it to the installed full version.
pattern: ^v\d+$
type: string
required:
- type
- version
type: object
required:
- id
type: object
Expand Down
42 changes: 42 additions & 0 deletions platform-api/internal/service/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,38 @@ func (s *LLMProviderService) Delete(orgUUID, handle, deletedBy string) error {
return nil
}

// validateAdditionalProviders eagerly validates a proxy's additional providers
// so Create/Update surface an immediate, actionable API error instead of a
// confusing deployment-time failure. It mirrors the checks the gateway performs
// at transform time (see llm_transformer.go): every referenced provider must
// exist, and each upstream name (the `as` alias, or the provider id when no
// alias is set) must be unique within the proxy and must not collide with the
// primary provider id.
func (s *LLMProxyService) validateAdditionalProviders(orgUUID, primaryProviderID string, additionalProviders *[]api.LLMProxyAdditionalProvider) error {
if additionalProviders == nil {
return nil
}
seen := map[string]bool{primaryProviderID: true}
for _, ap := range *additionalProviders {
prov, err := s.providerRepo.GetByID(ap.Id, orgUUID)
if err != nil {
return fmt.Errorf("failed to validate additional provider %q: %w", ap.Id, err)
}
if prov == nil {
return constants.ErrLLMProviderNotFound
}
name := ap.Id
if ap.As != nil && *ap.As != "" {
name = *ap.As
}
if seen[name] {
return constants.ErrInvalidInput
}
seen[name] = true
}
return nil
}

func (s *LLMProxyService) Create(orgUUID, createdBy string, req *api.LLMProxy) (*api.LLMProxy, error) {
if req == nil {
return nil, constants.ErrInvalidInput
Expand Down Expand Up @@ -1287,6 +1319,11 @@ func (s *LLMProxyService) Create(orgUUID, createdBy string, req *api.LLMProxy) (
return nil, constants.ErrLLMProviderNotFound
}

// Validate additional providers exist and have unique upstream names
if err := s.validateAdditionalProviders(orgUUID, req.Provider.Id, req.AdditionalProviders); err != nil {
return nil, err
}

// Determine handle: use provided id or auto-generate from displayName
var handle string
if req.Id != nil && *req.Id != "" {
Expand Down Expand Up @@ -1590,6 +1627,11 @@ func (s *LLMProxyService) Update(orgUUID, handle, updatedBy string, req *api.LLM
}
}

// Validate additional providers exist and have unique upstream names
if err := s.validateAdditionalProviders(orgUUID, req.Provider.Id, req.AdditionalProviders); err != nil {
return nil, err
}

contextValue := utils.DefaultStringPtr(req.Context, "/")
m := &model.LLMProxy{
OrganizationUUID: orgUUID,
Expand Down
132 changes: 124 additions & 8 deletions platform-api/internal/service/llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1746,10 +1746,10 @@ func TestLLMProxyServiceUpdate_CleansUpRotatedSecret(t *testing.T) {

func validProviderRequest(template string) *api.LLMProvider {
return &api.LLMProvider{
Id: strPointer("provider-1"),
DisplayName: "Test Provider",
Version: "v1.0",
Template: template,
Id: strPointer("provider-1"),
DisplayName: "Test Provider",
Version: "v1.0",
Template: template,
Upstream: api.Upstream{
Main: api.UpstreamDefinition{Url: stringPtr("https://example.com/openai/v1")},
},
Expand All @@ -1759,10 +1759,10 @@ func validProviderRequest(template string) *api.LLMProvider {

func validProxyRequest(providerID, projectID string) *api.LLMProxy {
return &api.LLMProxy{
Id: strPointer("proxy-1"),
DisplayName: "Test Proxy",
Version: "v1.0",
ProjectId: projectID,
Id: strPointer("proxy-1"),
DisplayName: "Test Proxy",
Version: "v1.0",
ProjectId: projectID,
Provider: api.LLMProxyProvider{
Id: providerID,
},
Expand All @@ -1777,3 +1777,119 @@ func upstreamAuthTypePtr(v string) *api.UpstreamAuthType {
t := api.UpstreamAuthType(v)
return &t
}

func TestLLMProxyServiceCreateFailsWhenAdditionalProviderNotFound(t *testing.T) {
proxyRepo := &mockLLMProxyRepo{}
providerRepo := &mockLLMProviderRepo{
getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
if providerID == "provider-1" {
return &model.LLMProvider{UUID: "provider-uuid", ID: providerID}, nil
}
return nil, nil
},
}
service := NewLLMProxyService(proxyRepo, providerRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

req := validProxyRequest("provider-1", "project-1")
req.AdditionalProviders = &[]api.LLMProxyAdditionalProvider{{Id: "missing-provider"}}

if _, err := service.Create("org-1", "alice", req); err != constants.ErrLLMProviderNotFound {
t.Fatalf("expected ErrLLMProviderNotFound, got: %v", err)
}
}

func TestLLMProxyServiceCreateFailsWhenAdditionalProviderNameCollides(t *testing.T) {
proxyRepo := &mockLLMProxyRepo{}
providerRepo := &mockLLMProviderRepo{
getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
return &model.LLMProvider{UUID: "provider-uuid", ID: providerID}, nil
},
}
service := NewLLMProxyService(proxyRepo, providerRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

req := validProxyRequest("provider-1", "project-1")
// The additional provider exists, but its upstream `as` name collides with
// the primary provider id, which the gateway rejects at transform time.
req.AdditionalProviders = &[]api.LLMProxyAdditionalProvider{
{Id: "provider-2", As: stringPtr("provider-1")},
}

if _, err := service.Create("org-1", "alice", req); err != constants.ErrInvalidInput {
t.Fatalf("expected ErrInvalidInput, got: %v", err)
}
}

func TestLLMProxyServiceUpdateFailsWhenAdditionalProviderNotFound(t *testing.T) {
now := time.Now()
proxyRepo := &mockLLMProxyRepo{}
proxyRepo.getByIDFunc = func(proxyID, orgUUID string) (*model.LLMProxy, error) {
return &model.LLMProxy{
UUID: "proxy-uuid",
ID: proxyID,
Name: "Old Proxy",
Version: "v1.0",
ProjectUUID: "project-1",
ProviderUUID: "provider-uuid",
CreatedAt: now,
UpdatedAt: now,
Configuration: model.LLMProxyConfig{Provider: "provider-1"},
}, nil
}
providerRepo := &mockLLMProviderRepo{
getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
if providerID == "provider-1" {
return &model.LLMProvider{UUID: "provider-uuid", ID: providerID}, nil
}
return nil, nil
},
}
service := NewLLMProxyService(proxyRepo, providerRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

req := validProxyRequest("provider-1", "project-1")
req.AdditionalProviders = &[]api.LLMProxyAdditionalProvider{{Id: "missing-provider"}}

if _, err := service.Update("org-1", "proxy-1", "test-user", req); err != constants.ErrLLMProviderNotFound {
t.Fatalf("expected ErrLLMProviderNotFound, got: %v", err)
}
if proxyRepo.updated != nil {
t.Fatalf("expected update to be rejected before persisting, but proxy was updated")
}
}

func TestLLMProxyServiceUpdateFailsWhenAdditionalProviderNameCollides(t *testing.T) {
now := time.Now()
proxyRepo := &mockLLMProxyRepo{}
proxyRepo.getByIDFunc = func(proxyID, orgUUID string) (*model.LLMProxy, error) {
return &model.LLMProxy{
UUID: "proxy-uuid",
ID: proxyID,
Name: "Old Proxy",
Version: "v1.0",
ProjectUUID: "project-1",
ProviderUUID: "provider-uuid",
CreatedAt: now,
UpdatedAt: now,
Configuration: model.LLMProxyConfig{Provider: "provider-1"},
}, nil
}
providerRepo := &mockLLMProviderRepo{
getByIDFunc: func(providerID, orgUUID string) (*model.LLMProvider, error) {
return &model.LLMProvider{UUID: "provider-uuid", ID: providerID}, nil
},
}
service := NewLLMProxyService(proxyRepo, providerRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

req := validProxyRequest("provider-1", "project-1")
// Two additional providers resolve to the same upstream `as` name.
req.AdditionalProviders = &[]api.LLMProxyAdditionalProvider{
{Id: "provider-2", As: stringPtr("shared")},
{Id: "provider-3", As: stringPtr("shared")},
}

if _, err := service.Update("org-1", "proxy-1", "test-user", req); err != constants.ErrInvalidInput {
t.Fatalf("expected ErrInvalidInput, got: %v", err)
}
if proxyRepo.updated != nil {
t.Fatalf("expected update to be rejected before persisting, but proxy was updated")
}
}