Add internal profile inspection resolution
This commit is contained in:
@@ -236,6 +236,13 @@ type ExecutionTarget struct {
|
|||||||
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileInspection is the resolved result of exact profile inspection.
|
||||||
|
type ProfileInspection struct {
|
||||||
|
ProfileID string
|
||||||
|
EffectiveModelParams ExecutionTarget
|
||||||
|
APIKeyRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
// OutputContract defines the requirements for the output artifact.
|
// OutputContract defines the requirements for the output artifact.
|
||||||
type OutputContract struct {
|
type OutputContract struct {
|
||||||
Format OutputFormat `yaml:"format"`
|
Format OutputFormat `yaml:"format"`
|
||||||
|
|||||||
103
internal/usecase/profile_inspection.go
Normal file
103
internal/usecase/profile_inspection.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolvedProfileSelection struct {
|
||||||
|
id string
|
||||||
|
profile *domain.ExecutionProfile
|
||||||
|
backend *domain.Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) resolveProfileSelection(
|
||||||
|
ctx context.Context,
|
||||||
|
profileID string,
|
||||||
|
) (*resolvedProfileSelection, error) {
|
||||||
|
normalizedID := strings.TrimSpace(profileID)
|
||||||
|
if normalizedID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if r == nil || r.profiles == nil {
|
||||||
|
return nil, fmt.Errorf("%w: profile repository is not configured", ErrProfileLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedProfile, err := r.profiles.GetProfile(ctx, normalizedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||||
|
}
|
||||||
|
if selectedProfile == nil {
|
||||||
|
return nil, fmt.Errorf("%w: profile repository returned nil profile", ErrProfileLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileValue := *selectedProfile
|
||||||
|
profileValue.BackendID = strings.TrimSpace(profileValue.BackendID)
|
||||||
|
|
||||||
|
var selectedBackend *domain.Backend
|
||||||
|
if profileValue.BackendID != "" {
|
||||||
|
if r.backends == nil {
|
||||||
|
return nil, fmt.Errorf("%w: backend %q cannot be resolved", ErrProfileLoad, profileValue.BackendID)
|
||||||
|
}
|
||||||
|
backendValue, err := r.backends.GetBackend(profileValue.BackendID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: backend %q: %w", ErrProfileLoad, profileValue.BackendID, err)
|
||||||
|
}
|
||||||
|
selectedBackend = &backendValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedProfileSelection{
|
||||||
|
id: normalizedID,
|
||||||
|
profile: &profileValue,
|
||||||
|
backend: selectedBackend,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResolvedExecutionTarget(target domain.ExecutionTarget) error {
|
||||||
|
if strings.TrimSpace(target.Endpoint) == "" {
|
||||||
|
return errors.New("execution endpoint is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.Model) == "" {
|
||||||
|
return errors.New("execution model is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectProfile resolves one explicit profile without prompt or execution work.
|
||||||
|
func (r *Runner) InspectProfile(
|
||||||
|
ctx context.Context,
|
||||||
|
profileID string,
|
||||||
|
) (*domain.ProfileInspection, error) {
|
||||||
|
normalizedID := strings.TrimSpace(profileID)
|
||||||
|
if normalizedID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, ctx.Err())
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
selection, err := r.resolveProfileSelection(ctx, normalizedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
target, _, err := resolveExecutionTarget(selection.backend, selection.profile, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||||
|
}
|
||||||
|
if err := validateResolvedExecutionTarget(target); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||||
|
}
|
||||||
|
target.APIKey = ""
|
||||||
|
|
||||||
|
return &domain.ProfileInspection{
|
||||||
|
ProfileID: selection.id,
|
||||||
|
EffectiveModelParams: target,
|
||||||
|
APIKeyRequired: target.APIKeyRequired,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
213
internal/usecase/profile_inspection_test.go
Normal file
213
internal/usecase/profile_inspection_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inspectionProfileRepository struct {
|
||||||
|
profile *domain.ExecutionProfile
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *inspectionProfileRepository) GetProfile(
|
||||||
|
_ context.Context,
|
||||||
|
id string,
|
||||||
|
) (*domain.ExecutionProfile, error) {
|
||||||
|
r.calls++
|
||||||
|
r.id = id
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
return r.profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionBackendResolver struct {
|
||||||
|
backend domain.Backend
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *inspectionBackendResolver) GetBackend(id string) (domain.Backend, error) {
|
||||||
|
r.calls++
|
||||||
|
r.id = id
|
||||||
|
if r.err != nil {
|
||||||
|
return domain.Backend{}, r.err
|
||||||
|
}
|
||||||
|
return r.backend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerInspectProfileResolvesProfileAndBackendOnce(t *testing.T) {
|
||||||
|
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "profile",
|
||||||
|
BackendID: " backend ",
|
||||||
|
Model: "profile-model",
|
||||||
|
Temperature: 0.4,
|
||||||
|
MaxTokens: 32,
|
||||||
|
TimeoutSeconds: 45,
|
||||||
|
ServiceTier: "priority",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"profile": "value",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
backends := &inspectionBackendResolver{backend: domain.Backend{
|
||||||
|
ID: "backend",
|
||||||
|
Endpoint: "https://backend.example/v1",
|
||||||
|
APIKeyEnv: "BACKEND_KEY",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"backend": "value",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
runner := &Runner{profiles: profiles, backends: backends}
|
||||||
|
|
||||||
|
inspection, err := runner.InspectProfile(context.Background(), " profile ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect profile: %v", err)
|
||||||
|
}
|
||||||
|
if profiles.calls != 1 || profiles.id != "profile" {
|
||||||
|
t.Fatalf("profile lookup=(calls=%d id=%q), want one exact lookup", profiles.calls, profiles.id)
|
||||||
|
}
|
||||||
|
if backends.calls != 1 || backends.id != "backend" {
|
||||||
|
t.Fatalf("backend lookup=(calls=%d id=%q), want one exact lookup", backends.calls, backends.id)
|
||||||
|
}
|
||||||
|
if profiles.profile.BackendID != " backend " {
|
||||||
|
t.Fatalf("inspection mutated repository profile backend: %q", profiles.profile.BackendID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTarget := domain.ExecutionTarget{
|
||||||
|
BackendID: "backend",
|
||||||
|
Endpoint: "https://backend.example/v1",
|
||||||
|
Model: "profile-model",
|
||||||
|
Temperature: 0.4,
|
||||||
|
MaxTokens: 32,
|
||||||
|
TopP: defaults.ExecutionTargetDefault().TopP,
|
||||||
|
TimeoutSeconds: 45,
|
||||||
|
ServiceTier: "priority",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
APIKeyEnv: "BACKEND_KEY",
|
||||||
|
ExtraParams: map[string]any{
|
||||||
|
"profile": "value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if inspection.ProfileID != "profile" || inspection.APIKeyRequired ||
|
||||||
|
!reflect.DeepEqual(inspection.EffectiveModelParams, wantTarget) {
|
||||||
|
t.Fatalf("inspection=%#v, want profile=%q target=%#v", inspection, "profile", wantTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerInspectProfileDoesNotNeedExecutionCollaboratorsOrCredentials(t *testing.T) {
|
||||||
|
t.Setenv("PROMPTKIT_INSPECTION_TEST_KEY", "")
|
||||||
|
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "endpoint-only",
|
||||||
|
Endpoint: "https://profile.example/v1",
|
||||||
|
Model: "profile-model",
|
||||||
|
APIKeyEnv: "PROMPTKIT_INSPECTION_TEST_KEY",
|
||||||
|
}}
|
||||||
|
runner := &Runner{profiles: profiles}
|
||||||
|
|
||||||
|
inspection, err := runner.InspectProfile(context.Background(), "endpoint-only")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect endpoint-only profile: %v", err)
|
||||||
|
}
|
||||||
|
if inspection.EffectiveModelParams.BackendID != "" ||
|
||||||
|
inspection.EffectiveModelParams.APIKeyEnv != "PROMPTKIT_INSPECTION_TEST_KEY" ||
|
||||||
|
inspection.APIKeyRequired {
|
||||||
|
t.Fatalf("unexpected endpoint-only inspection: %#v", inspection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerInspectProfileDirectCredentialRequirementClearsBackendEnvironment(t *testing.T) {
|
||||||
|
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "direct-key",
|
||||||
|
BackendID: "backend",
|
||||||
|
Model: "profile-model",
|
||||||
|
APIKeyRequired: true,
|
||||||
|
}}
|
||||||
|
backends := &inspectionBackendResolver{backend: domain.Backend{
|
||||||
|
ID: "backend",
|
||||||
|
Endpoint: "https://backend.example/v1",
|
||||||
|
APIKeyEnv: "BACKEND_KEY",
|
||||||
|
}}
|
||||||
|
|
||||||
|
inspection, err := (&Runner{profiles: profiles, backends: backends}).InspectProfile(
|
||||||
|
context.Background(),
|
||||||
|
"direct-key",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect direct-key profile: %v", err)
|
||||||
|
}
|
||||||
|
if !inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "" {
|
||||||
|
t.Fatalf("credential requirement was not resolved exclusively: %#v", inspection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerInspectProfileClassifiesFailuresWithoutRepositoryWorkAfterCancellation(t *testing.T) {
|
||||||
|
t.Run("blank ID", func(t *testing.T) {
|
||||||
|
profiles := &inspectionProfileRepository{}
|
||||||
|
_, err := (&Runner{profiles: profiles}).InspectProfile(context.Background(), " \t ")
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) || profiles.calls != 0 {
|
||||||
|
t.Fatalf("blank inspection=(%v, calls=%d), want invalid request without lookup", err, profiles.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("canceled context", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
profiles := &inspectionProfileRepository{}
|
||||||
|
_, err := (&Runner{profiles: profiles}).InspectProfile(ctx, "profile")
|
||||||
|
if !errors.Is(err, ErrProfileLoad) || !errors.Is(err, context.Canceled) || profiles.calls != 0 {
|
||||||
|
t.Fatalf("canceled inspection=(%v, calls=%d), want profile load and context identities without lookup", err, profiles.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing profile", func(t *testing.T) {
|
||||||
|
profiles := &inspectionProfileRepository{err: profile.ErrProfileNotFound}
|
||||||
|
_, err := (&Runner{profiles: profiles}).InspectProfile(context.Background(), "missing")
|
||||||
|
if !errors.Is(err, ErrProfileLoad) || !errors.Is(err, profile.ErrProfileNotFound) {
|
||||||
|
t.Fatalf("missing profile error=%v, want profile load and not-found identities", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown backend", func(t *testing.T) {
|
||||||
|
backendErr := errors.New("unknown backend")
|
||||||
|
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
|
||||||
|
ID: "profile", BackendID: "backend", Model: "profile-model",
|
||||||
|
}}
|
||||||
|
backends := &inspectionBackendResolver{err: backendErr}
|
||||||
|
_, err := (&Runner{profiles: profiles, backends: backends}).InspectProfile(context.Background(), "profile")
|
||||||
|
if !errors.Is(err, ErrProfileLoad) || !errors.Is(err, backendErr) {
|
||||||
|
t.Fatalf("unknown backend error=%v, want profile load and backend identities", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("defensive invalid dependencies", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
runner *Runner
|
||||||
|
}{
|
||||||
|
{name: "nil repository", runner: &Runner{}},
|
||||||
|
{name: "nil profile", runner: &Runner{profiles: &inspectionProfileRepository{}}},
|
||||||
|
{name: "invalid target", runner: &Runner{profiles: &inspectionProfileRepository{
|
||||||
|
profile: &domain.ExecutionProfile{ID: "profile", Endpoint: "https://profile.example/v1"},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := tc.runner.InspectProfile(context.Background(), "profile")
|
||||||
|
if !errors.Is(err, ErrProfileLoad) {
|
||||||
|
t.Fatalf("inspection error=%v, want profile load", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -283,34 +283,18 @@ func (r *Runner) resolvePreparation(
|
|||||||
return nil, fmt.Errorf("%w: %w: profile id is required either in request or prompt default_profile", ErrInvalidRequest, ErrProfileRequired)
|
return nil, fmt.Errorf("%w: %w: profile id is required either in request or prompt default_profile", ErrInvalidRequest, ErrProfileRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
execProfile, err := r.profiles.GetProfile(ctx, selectedProfileID)
|
selection, err := r.resolveProfileSelection(ctx, selectedProfileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedBackend *domain.Backend
|
effectiveModel, targetPresence, err := resolveExecutionTarget(selection.backend, selection.profile, req.Execution)
|
||||||
if backendID := strings.TrimSpace(execProfile.BackendID); backendID != "" {
|
|
||||||
execProfile.BackendID = backendID
|
|
||||||
if r.backends == nil {
|
|
||||||
return nil, fmt.Errorf("%w: backend %q cannot be resolved", ErrProfileLoad, backendID)
|
|
||||||
}
|
|
||||||
resolvedBackend, resolveErr := r.backends.GetBackend(backendID)
|
|
||||||
if resolveErr != nil {
|
|
||||||
return nil, fmt.Errorf("%w: backend %q: %w", ErrProfileLoad, backendID, resolveErr)
|
|
||||||
}
|
|
||||||
selectedBackend = &resolvedBackend
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveModel, targetPresence, err := resolveExecutionTarget(selectedBackend, execProfile, req.Execution)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
effectiveModel.APIKey = req.APIKey
|
effectiveModel.APIKey = req.APIKey
|
||||||
if strings.TrimSpace(effectiveModel.Endpoint) == "" {
|
if err := validateResolvedExecutionTarget(effectiveModel); err != nil {
|
||||||
return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
}
|
|
||||||
if strings.TrimSpace(effectiveModel.Model) == "" {
|
|
||||||
return nil, fmt.Errorf("%w: execution model is required", ErrInvalidRequest)
|
|
||||||
}
|
}
|
||||||
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
|
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
@@ -321,7 +305,7 @@ func (r *Runner) resolvePreparation(
|
|||||||
definition: def,
|
definition: def,
|
||||||
directSessionID: directSessionID,
|
directSessionID: directSessionID,
|
||||||
promptDefinitionHash: promptDefinitionHash,
|
promptDefinitionHash: promptDefinitionHash,
|
||||||
selectedProfileID: selectedProfileID,
|
selectedProfileID: selection.id,
|
||||||
effectiveModel: effectiveModel,
|
effectiveModel: effectiveModel,
|
||||||
targetPresence: targetPresence,
|
targetPresence: targetPresence,
|
||||||
effectiveContract: effectiveContract,
|
effectiveContract: effectiveContract,
|
||||||
|
|||||||
Reference in New Issue
Block a user