From 0bf5f8813631d1a7f19ce0373fff9784086263e3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 30 Jul 2026 19:48:09 +0000 Subject: [PATCH] Add internal profile inspection resolution --- internal/domain/domain.go | 7 + internal/usecase/profile_inspection.go | 103 ++++++++++ internal/usecase/profile_inspection_test.go | 213 ++++++++++++++++++++ internal/usecase/runner.go | 28 +-- 4 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 internal/usecase/profile_inspection.go create mode 100644 internal/usecase/profile_inspection_test.go diff --git a/internal/domain/domain.go b/internal/domain/domain.go index cbc2b33..d4ef634 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -236,6 +236,13 @@ type ExecutionTarget struct { 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. type OutputContract struct { Format OutputFormat `yaml:"format"` diff --git a/internal/usecase/profile_inspection.go b/internal/usecase/profile_inspection.go new file mode 100644 index 0000000..c46eafc --- /dev/null +++ b/internal/usecase/profile_inspection.go @@ -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 +} diff --git a/internal/usecase/profile_inspection_test.go b/internal/usecase/profile_inspection_test.go new file mode 100644 index 0000000..c179006 --- /dev/null +++ b/internal/usecase/profile_inspection_test.go @@ -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) + } + }) + } + }) +} diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 4d7d13e..72f7941 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -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) } - execProfile, err := r.profiles.GetProfile(ctx, selectedProfileID) + selection, err := r.resolveProfileSelection(ctx, selectedProfileID) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) + return nil, err } - var selectedBackend *domain.Backend - 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) + effectiveModel, targetPresence, err := resolveExecutionTarget(selection.backend, selection.profile, req.Execution) if err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err) } effectiveModel.APIKey = req.APIKey - if strings.TrimSpace(effectiveModel.Endpoint) == "" { - return nil, fmt.Errorf("%w: execution endpoint is required", ErrInvalidRequest) - } - if strings.TrimSpace(effectiveModel.Model) == "" { - return nil, fmt.Errorf("%w: execution model is required", ErrInvalidRequest) + if err := validateResolvedExecutionTarget(effectiveModel); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err) } if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err) @@ -321,7 +305,7 @@ func (r *Runner) resolvePreparation( definition: def, directSessionID: directSessionID, promptDefinitionHash: promptDefinitionHash, - selectedProfileID: selectedProfileID, + selectedProfileID: selection.id, effectiveModel: effectiveModel, targetPresence: targetPresence, effectiveContract: effectiveContract,