Add profile inheritance definition support
This commit is contained in:
47
internal/profile/definition.go
Normal file
47
internal/profile/definition.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// NormalizeAndValidateDefinition normalizes and validates one source-local
|
||||
// profile definition without resolving a base profile.
|
||||
func NormalizeAndValidateDefinition(profile *domain.ExecutionProfile) error {
|
||||
if profile == nil {
|
||||
return errors.New("profile is required")
|
||||
}
|
||||
|
||||
profile.ID = strings.TrimSpace(profile.ID)
|
||||
profile.BaseProfileID = strings.TrimSpace(profile.BaseProfileID)
|
||||
profile.BackendID = strings.TrimSpace(profile.BackendID)
|
||||
profile.Endpoint = strings.TrimSpace(profile.Endpoint)
|
||||
|
||||
if profile.ID == "" {
|
||||
return errors.New("id is required")
|
||||
}
|
||||
if profile.Endpoint != "" {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(profile.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile.Endpoint = endpoint
|
||||
}
|
||||
if profile.BaseProfileID == "" {
|
||||
if profile.BackendID == "" && profile.Endpoint == "" {
|
||||
return errors.New("backend or endpoint is required")
|
||||
}
|
||||
if strings.TrimSpace(profile.Model) == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
}
|
||||
|
||||
return domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{
|
||||
Temperature: profile.Temperature,
|
||||
MaxTokens: profile.MaxTokens,
|
||||
TopP: profile.TopP,
|
||||
TimeoutSeconds: profile.TimeoutSeconds,
|
||||
})
|
||||
}
|
||||
@@ -127,12 +127,11 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
|
||||
if prof.ID != id {
|
||||
continue
|
||||
}
|
||||
prof.BackendID = strings.TrimSpace(prof.BackendID)
|
||||
prof.ExtraParams, err = jsonvalue.CopyMap(prof.ExtraParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
|
||||
}
|
||||
if err := normalizeAndValidateProfile(prof); err != nil {
|
||||
if err := NormalizeAndValidateDefinition(prof); err != nil {
|
||||
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||
return nil, fmt.Errorf("%w: %s", err, relPath)
|
||||
}
|
||||
@@ -255,30 +254,3 @@ func requireYAMLStreamEnd(decoder *yaml.Decoder) error {
|
||||
}
|
||||
return errors.New("profile file must contain exactly one YAML document")
|
||||
}
|
||||
|
||||
func normalizeAndValidateProfile(p *domain.ExecutionProfile) error {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
return errors.New("id is required")
|
||||
}
|
||||
p.Endpoint = strings.TrimSpace(p.Endpoint)
|
||||
if strings.TrimSpace(p.BackendID) == "" && p.Endpoint == "" {
|
||||
return errors.New("backend or endpoint is required")
|
||||
}
|
||||
if p.Endpoint != "" {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(p.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Endpoint = endpoint
|
||||
}
|
||||
if strings.TrimSpace(p.Model) == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
|
||||
return domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{
|
||||
Temperature: p.Temperature,
|
||||
MaxTokens: p.MaxTokens,
|
||||
TopP: p.TopP,
|
||||
TimeoutSeconds: p.TimeoutSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -858,6 +858,107 @@ top_p: .inf
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileRepositoriesValidateDerivedDefinitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
wantError error
|
||||
wantBaseID string
|
||||
wantProfile bool
|
||||
}{
|
||||
{
|
||||
name: "alias is locally valid and normalizes base id",
|
||||
files: map[string]string{"alias.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: " base-profile "
|
||||
`},
|
||||
wantBaseID: "base-profile",
|
||||
wantProfile: true,
|
||||
},
|
||||
{
|
||||
name: "derived endpoint remains valid",
|
||||
files: map[string]string{"invalid.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: base-profile
|
||||
endpoint: /v1
|
||||
`},
|
||||
wantError: ErrInvalidProfile,
|
||||
},
|
||||
{
|
||||
name: "derived settings remain valid",
|
||||
files: map[string]string{"invalid.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: base-profile
|
||||
top_p: 1.1
|
||||
`},
|
||||
wantError: ErrInvalidProfile,
|
||||
},
|
||||
{
|
||||
name: "derived extra params remain valid",
|
||||
files: map[string]string{"invalid.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: base-profile
|
||||
extra_params:
|
||||
timestamp: 2026-08-11T12:34:56Z
|
||||
`},
|
||||
wantError: ErrInvalidProfile,
|
||||
},
|
||||
{
|
||||
name: "derived raw key remains prohibited",
|
||||
files: map[string]string{"invalid.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: base-profile
|
||||
api_key: secret
|
||||
`},
|
||||
wantError: ErrRawAPIKeyNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "derived duplicate id remains invalid",
|
||||
files: map[string]string{
|
||||
"first.yaml": "id: selected-profile\nbase_profile: first-base\n",
|
||||
"second.yaml": "id: selected-profile\nbase_profile: second-base\n",
|
||||
},
|
||||
wantError: ErrInvalidProfile,
|
||||
},
|
||||
{
|
||||
name: "derived extra document remains invalid",
|
||||
files: map[string]string{"invalid.yaml": `
|
||||
id: selected-profile
|
||||
base_profile: base-profile
|
||||
---
|
||||
id: other
|
||||
`},
|
||||
wantError: ErrInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "standalone profile remains complete",
|
||||
files: map[string]string{"invalid.yaml": "id: selected-profile\n"},
|
||||
wantError: ErrInvalidProfile,
|
||||
},
|
||||
}
|
||||
|
||||
for _, source := range profileRepositorySources() {
|
||||
for _, tc := range tests {
|
||||
t.Run(source.name+"/"+tc.name, func(t *testing.T) {
|
||||
repo := source.newRepository(t, tc.files)
|
||||
got, err := repo.GetProfile(context.Background(), "selected-profile")
|
||||
if tc.wantError != nil {
|
||||
if !errors.Is(err, tc.wantError) {
|
||||
t.Fatalf("error = %v, want %v", err, tc.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || !tc.wantProfile {
|
||||
t.Fatalf("profile = %+v, error = %v, want valid derived definition", got, err)
|
||||
}
|
||||
if got.BaseProfileID != tc.wantBaseID {
|
||||
t.Fatalf("BaseProfileID = %q, want %q", got.BaseProfileID, tc.wantBaseID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayRepository(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primaryProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://primary", Model: "primary"}
|
||||
|
||||
Reference in New Issue
Block a user