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
17 changes: 15 additions & 2 deletions platform-api/internal/service/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,10 +955,10 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi
migrateLegacyProviderPoliciesInPlace(&m.Configuration)

if m.Configuration.Upstream != nil && m.Configuration.Upstream.Main != nil {
m.Configuration.Upstream.Main.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Main.Auth)
m.Configuration.Upstream.Main.Auth = defaultProviderCreationAuth(m.Configuration.Upstream.Main.Auth)
}
if m.Configuration.Upstream != nil && m.Configuration.Upstream.Sandbox != nil {
m.Configuration.Upstream.Sandbox.Auth = defaultUpstreamAuthToNone(m.Configuration.Upstream.Sandbox.Auth)
m.Configuration.Upstream.Sandbox.Auth = defaultProviderCreationAuth(m.Configuration.Upstream.Sandbox.Auth)
}

policyUUIDs, err := s.resolveCustomPolicyUUIDs(orgUUID, &m.Configuration)
Expand Down Expand Up @@ -3389,6 +3389,19 @@ func defaultUpstreamAuthToNone(auth *model.UpstreamAuth) *model.UpstreamAuth {
return auth
}

// defaultProviderCreationAuth treats an omitted API-key credential as disabled
// upstream authentication. This normalization is intentionally limited to provider
// creation so stored configurations are valid before deployment.
func defaultProviderCreationAuth(auth *model.UpstreamAuth) *model.UpstreamAuth {
auth = defaultUpstreamAuthToNone(auth)
if normalizeUpstreamAuthType(auth.Type) == string(api.ApiKey) && strings.TrimSpace(auth.Value) == "" {
auth.Type = string(api.None)
auth.Header = ""
auth.Value = ""
}
return auth
}

// isCredentialLessUpstreamAuthType reports whether an upstream auth type carries no
// credentials ("none" or "other"), in which case header/value are irrelevant.
func isCredentialLessUpstreamAuthType(authType string) bool {
Expand Down
76 changes: 76 additions & 0 deletions platform-api/internal/service/llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,82 @@ func TestLLMProviderServiceCreateAllowsAggregatorTemplate(t *testing.T) {
}
}

func TestLLMProviderServiceCreateDefaultsEmptyAPIKeyAuthToNone(t *testing.T) {
now := time.Now()
providerRepo := &mockLLMProviderRepo{}
providerRepo.getByIDFunc = func(providerID, orgUUID string) (*model.LLMProvider, error) {
if providerRepo.created == nil {
return nil, nil
}
created := *providerRepo.created
created.UUID = "prov-uuid"
created.CreatedAt = now
created.UpdatedAt = now
return &created, nil
}
templateRepo := &mockLLMTemplateRepo{
getByIDFunc: func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) {
return &model.LLMProviderTemplate{UUID: "tpl-openai", ID: "openai", Enabled: true, CreatedAt: now, UpdatedAt: now}, nil
},
}
orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1"}}
service := NewLLMProviderService(providerRepo, templateRepo, orgRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

request := validProviderRequest("openai")
request.Upstream.Main.Auth = &api.UpstreamAuth{
Type: upstreamAuthTypePtr(string(api.ApiKey)),
Header: stringPtr("Authorization"),
}

if _, err := service.Create("org-1", "alice", request); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
auth := providerRepo.created.Configuration.Upstream.Main.Auth
if auth == nil || auth.Type != string(api.None) {
t.Fatalf("expected empty api-key auth to be stored as none, got: %+v", auth)
}
if auth.Header != "" || auth.Value != "" {
t.Fatalf("expected none auth to omit header/value, got: %+v", auth)
}
}

func TestLLMProviderServiceCreatePreservesAPIKeyAuthWithValue(t *testing.T) {
now := time.Now()
providerRepo := &mockLLMProviderRepo{}
providerRepo.getByIDFunc = func(providerID, orgUUID string) (*model.LLMProvider, error) {
if providerRepo.created == nil {
return nil, nil
}
created := *providerRepo.created
created.UUID = "prov-uuid"
created.CreatedAt = now
created.UpdatedAt = now
return &created, nil
}
templateRepo := &mockLLMTemplateRepo{
getByIDFunc: func(templateID, orgUUID string) (*model.LLMProviderTemplate, error) {
return &model.LLMProviderTemplate{UUID: "tpl-openai", ID: "openai", Enabled: true, CreatedAt: now, UpdatedAt: now}, nil
},
}
orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1"}}
service := NewLLMProviderService(providerRepo, templateRepo, orgRepo, nil, nil, nil, nil, slog.Default(), &noopAuditRepo{}, &config.Server{}, newTestIdentityService())

request := validProviderRequest("openai")
request.Upstream.Main.Auth = &api.UpstreamAuth{
Type: upstreamAuthTypePtr(string(api.ApiKey)),
Header: stringPtr("Authorization"),
Value: stringPtr("secret"),
}

if _, err := service.Create("org-1", "alice", request); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
auth := providerRepo.created.Configuration.Upstream.Main.Auth
if auth == nil || auth.Type != string(api.ApiKey) || auth.Value != "secret" {
t.Fatalf("expected configured api-key auth to be preserved, got: %+v", auth)
}
}

// TestLLMProviderServiceCreateMigratesLegacyPolicies verifies Phase 10 save-time
// migration: a provider created with the deprecated `policies` list has it split into
// globalPolicies (for path "/*" + methods ["*"]) and operationPolicies (all other paths).
Expand Down