From f315620bf7b52551a15184ec11889d1ba415d79b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 17:43:11 +0000 Subject: [PATCH] Add request validation API --- docs/roadmap/implementation.md | 2 + engine.go | 25 +++++++++- validate_request_contract_test.go | 83 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 validate_request_contract_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 55bd543..093c58b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -95,6 +95,8 @@ Completion: external consumers can call the new error-only method, all facade preparation plumbing is shared, and the public snapshot conversion remains only on `Prepare`'s success path. +**Completed: 2026-10-12.** + ## Stage 2: Verify Concrete-Request Parity And Resource Boundaries Scope: focused tests of the finalized roadmap's clarifications. diff --git a/engine.go b/engine.go index 91620a1..274765d 100644 --- a/engine.go +++ b/engine.go @@ -346,6 +346,29 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile // relationship to ctx.Err is promised. Prepare returns no partial result on // error. It validates RunRequest.TransientRetries but makes no model attempts. func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) { + prepared, err := e.prepareRequest(ctx, req) + if err != nil { + return nil, err + } + return fromDomainPreparedRun(prepared), nil +} + +// ValidateRequest reports whether req passes the same preparation path used by +// [Engine.Prepare] at the time of this call. A nil error means this concrete +// request passed preparation. The method returns no prepared snapshot or +// executable handle. It does not call an LLM, but preparation may still perform +// resource I/O, including loading supplied inputs and schemas. +// +// Its errors and context handling follow [Engine.Prepare], including an error +// matching ErrInvalidConfig when the Engine is nil. Success does not guarantee +// that a provider will accept a later request. Use [Engine.PrepareExecution] +// when execution must use the snapshot produced by preparation. +func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error { + _, err := e.prepareRequest(ctx, req) + return err +} + +func (e *Engine) prepareRequest(ctx context.Context, req RunRequest) (*domain.PreparedRun, error) { ctx = observation.WithoutExecutionState(ctx) if e == nil || e.internal == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) @@ -360,7 +383,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err if err != nil { return nil, mapPublicError(err) } - return fromDomainPreparedRun(prepared), nil + return prepared, nil } // PrepareExecution completely prepares a prompt request without calling the diff --git a/validate_request_contract_test.go b/validate_request_contract_test.go new file mode 100644 index 0000000..431ab4f --- /dev/null +++ b/validate_request_contract_test.go @@ -0,0 +1,83 @@ +package promptkit_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestValidateRequestMatchesPrepareContract(t *testing.T) { + var modelCalls atomic.Int64 + profiles := map[string]any{ + "offline": map[string]any{"endpoint": "http://example.test/v1", "model": "offline-model"}, + } + newEngine := func(t *testing.T, definition map[string]any) *promptkit.Engine { + t.Helper() + return requirementsEngine(t, oneVersionPrompt("synthetic", "v1", definition), profiles, + promptkit.WithLLMClient(countingLLMClient{calls: &modelCalls})) + } + testParity := func(t *testing.T, engine *promptkit.Engine, request promptkit.RunRequest, want error) { + t.Helper() + prepared, prepareErr := engine.Prepare(context.Background(), request) + validateErr := engine.ValidateRequest(context.Background(), request) + if want == nil { + if prepareErr != nil || prepared == nil { + t.Fatalf("Prepare() = (%+v, %v), want a prepared result", prepared, prepareErr) + } + if validateErr != nil { + t.Fatalf("ValidateRequest() error = %v, want nil", validateErr) + } + return + } + if prepared != nil || !errors.Is(prepareErr, want) { + t.Fatalf("Prepare() = (%+v, %v), want error matching %v", prepared, prepareErr, want) + } + if !errors.Is(validateErr, want) { + t.Fatalf("ValidateRequest() error = %v, want error matching %v", validateErr, want) + } + } + + t.Run("successful request", func(t *testing.T) { + engine := newEngine(t, contractPrompt("offline", "hello", nil, "", noValidationOutput)) + testParity(t, engine, promptkit.RunRequest{PromptID: "synthetic"}, nil) + }) + + t.Run("nil engine", func(t *testing.T) { + var engine *promptkit.Engine + request := promptkit.RunRequest{PromptID: "synthetic"} + prepared, prepareErr := engine.Prepare(context.Background(), request) + validateErr := engine.ValidateRequest(context.Background(), request) + if prepared != nil || !errors.Is(prepareErr, promptkit.ErrInvalidConfig) { + t.Fatalf("Prepare() = (%+v, %v), want ErrInvalidConfig", prepared, prepareErr) + } + if !errors.Is(validateErr, promptkit.ErrInvalidConfig) { + t.Fatalf("ValidateRequest() error = %v, want ErrInvalidConfig", validateErr) + } + }) + + t.Run("invalid appended message", func(t *testing.T) { + engine := newEngine(t, contractPrompt("offline", "hello", nil, "", noValidationOutput)) + request := promptkit.RunRequest{ + PromptID: "synthetic", + AppendedMessages: []promptkit.RenderedMessage{{ + Role: "unsupported-role", Content: "not sent to a model", + }}, + } + testParity(t, engine, request, promptkit.ErrInvalidRequest) + }) + + t.Run("missing required input", func(t *testing.T) { + definition := contractPrompt("offline", `{{input "required"}}`, []any{ + map[string]any{"name": "required", "required": true}, + }, "", noValidationOutput) + engine := newEngine(t, definition) + testParity(t, engine, promptkit.RunRequest{PromptID: "synthetic"}, promptkit.ErrPromptRender) + }) + + if got := modelCalls.Load(); got != 0 { + t.Fatalf("preparation operations made %d model calls, want none", got) + } +}