Add Promptkit execution adapter
This commit is contained in:
321
internal/adapters/promptkit/adapter.go
Normal file
321
internal/adapters/promptkit/adapter.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Package promptkitadapter implements promptexec with Promptkit.
|
||||
package promptkitadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
// Config selects the Promptkit sources and optional local backend for one engine.
|
||||
type Config struct {
|
||||
ProfileDirectory string
|
||||
ProfileFile string
|
||||
LocalEndpoint string
|
||||
LocalConcurrencyLimit int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||
type Adapter struct {
|
||||
engine *promptkit.Engine
|
||||
}
|
||||
|
||||
var _ promptexec.Executor = (*Adapter)(nil)
|
||||
|
||||
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
|
||||
func New(config Config) (*Adapter, error) {
|
||||
return newAdapter(config)
|
||||
}
|
||||
|
||||
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
|
||||
if config.ProfileDirectory != "" && config.ProfileFile != "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
|
||||
}
|
||||
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
|
||||
}
|
||||
|
||||
options := []promptkit.Option{
|
||||
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||
}
|
||||
if config.ProfileFile != "" {
|
||||
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
||||
}
|
||||
if config.LocalEndpoint != "" {
|
||||
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
|
||||
}
|
||||
options = append(options, additionalOptions...)
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
ProfileDir: config.ProfileDirectory,
|
||||
Timeout: config.Timeout,
|
||||
}, options...)
|
||||
if err != nil {
|
||||
return nil, classifyConfigurationError(err)
|
||||
}
|
||||
return &Adapter{engine: engine}, nil
|
||||
}
|
||||
|
||||
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
|
||||
return newAdapter(config, promptkit.WithLLMClient(client))
|
||||
}
|
||||
|
||||
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
|
||||
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
|
||||
if err != nil {
|
||||
return promptexec.PromptInspection{}, classifyError(err)
|
||||
}
|
||||
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
|
||||
for index, input := range inspection.Inputs {
|
||||
inputs[index] = promptexec.InputDefinition{
|
||||
Name: input.Name,
|
||||
Required: input.Required,
|
||||
ContentType: input.ContentType,
|
||||
Description: input.Description,
|
||||
}
|
||||
}
|
||||
return promptexec.PromptInspection{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
DefaultProfileID: inspection.DefaultProfileID,
|
||||
Inputs: inputs,
|
||||
Output: outputContract(inspection.OutputContract),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
|
||||
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return promptexec.ProfileInspection{}, classifyError(err)
|
||||
}
|
||||
return promptexec.ProfileInspection{
|
||||
ProfileID: inspection.ProfileID,
|
||||
BackendID: inspection.EffectiveModelParams.BackendID,
|
||||
ModelName: inspection.EffectiveModelParams.Model,
|
||||
CredentialRequired: inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute prepares one exact inline data package, invokes prepared after a
|
||||
// successful preparation, and then runs the same opaque prepared handle.
|
||||
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||
PromptID: request.PromptID,
|
||||
PromptVersion: request.PromptVersion,
|
||||
ProfileID: request.ProfileID,
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"data_package": promptkit.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, classifyError(err)
|
||||
}
|
||||
defer prepared.Discard()
|
||||
|
||||
details := prepared.Details()
|
||||
preparation, debug := preparationValues(details, request.DataPackagePath, request.CaptureDebug)
|
||||
if preparedCallback != nil {
|
||||
if err := preparedCallback(preparation, debug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := adapter.engine.RunPrepared(ctx, prepared)
|
||||
if err != nil {
|
||||
return nil, classifyError(err)
|
||||
}
|
||||
return executionValue(result, request.DataPackagePath, request.CaptureDebug), nil
|
||||
}
|
||||
|
||||
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
||||
return promptexec.OutputContract{
|
||||
Format: string(value.Format),
|
||||
ValidationMode: string(value.ValidationMode),
|
||||
SchemaPath: value.SchemaPath,
|
||||
}
|
||||
}
|
||||
|
||||
func preparationValues(value promptkit.PreparedRun, dataPackagePath string, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
||||
preparation := promptexec.Preparation{
|
||||
PromptID: value.PromptID,
|
||||
PromptVersion: value.PromptVersion,
|
||||
PromptHash: value.PromptHash,
|
||||
RenderedPromptHash: value.RenderedPromptHash,
|
||||
InputHashes: copyInputHashes(value.InputHashes),
|
||||
ProfileID: value.SelectedProfileID,
|
||||
BackendID: value.SelectedBackendID,
|
||||
ModelName: value.EffectiveModelParams.Model,
|
||||
Output: outputContract(value.OutputContract),
|
||||
StartedAt: value.StartTime,
|
||||
EndedAt: value.EndTime,
|
||||
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
||||
DataPackagePath: dataPackagePath,
|
||||
}
|
||||
if !captureDebug {
|
||||
return preparation, nil
|
||||
}
|
||||
debug := &promptexec.PreparationDebug{
|
||||
RenderedMessages: renderedMessages(value.Messages),
|
||||
Endpoint: value.EffectiveModelParams.Endpoint,
|
||||
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
|
||||
}
|
||||
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
|
||||
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
|
||||
}
|
||||
return preparation, debug
|
||||
}
|
||||
|
||||
func executionValue(value *promptkit.RunResult, dataPackagePath string, captureDebug bool) *promptexec.Execution {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
validation := promptexec.NewValidation(
|
||||
promptexec.ValidationStatus(value.Validation.Status),
|
||||
string(value.Validation.Mode),
|
||||
value.Validation.SchemaPath,
|
||||
value.Validation.Errors,
|
||||
)
|
||||
execution := &promptexec.Execution{
|
||||
RunID: value.RunID,
|
||||
PromptID: value.PromptID,
|
||||
PromptVersion: value.PromptVersion,
|
||||
PromptHash: value.PromptHash,
|
||||
RenderedPromptHash: value.RenderedPromptHash,
|
||||
InputHashes: copyInputHashes(value.InputHashes),
|
||||
ProfileID: value.SelectedProfileID,
|
||||
BackendID: value.SelectedBackendID,
|
||||
ModelName: value.ModelName,
|
||||
GeneratedHash: value.Artifact.Hash,
|
||||
Usage: promptexec.TokenUsage{
|
||||
PromptTokens: value.Usage.PromptTokens,
|
||||
CompletionTokens: value.Usage.CompletionTokens,
|
||||
TotalTokens: value.Usage.TotalTokens,
|
||||
CachedTokens: value.Usage.CachedTokens,
|
||||
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
||||
},
|
||||
StartedAt: value.StartTime,
|
||||
EndedAt: value.EndTime,
|
||||
Duration: value.Duration,
|
||||
Validation: validation,
|
||||
DataPackagePath: dataPackagePath,
|
||||
RawOutput: []byte(value.RawOutput),
|
||||
}
|
||||
if captureDebug {
|
||||
execution.Debug = &promptexec.ExecutionDebug{
|
||||
RawOutput: append([]byte(nil), value.RawOutput...),
|
||||
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||
}
|
||||
}
|
||||
return execution
|
||||
}
|
||||
|
||||
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
|
||||
messages := make([]promptexec.RenderedMessage, len(values))
|
||||
for index, value := range values {
|
||||
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func copyInputHashes(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||
parameters := struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ExtraParams map[string]any `json:"extra_params"`
|
||||
}{
|
||||
Temperature: value.Temperature,
|
||||
MaxTokens: value.MaxTokens,
|
||||
TopP: value.TopP,
|
||||
TimeoutSeconds: value.TimeoutSeconds,
|
||||
ServiceTier: value.ServiceTier,
|
||||
ReasoningEffort: value.ReasoningEffort,
|
||||
ExtraParams: value.ExtraParams,
|
||||
}
|
||||
data, _ := json.Marshal(parameters)
|
||||
return data
|
||||
}
|
||||
|
||||
func classifyConfigurationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||
}
|
||||
|
||||
func classifyError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
|
||||
}
|
||||
var capacityError *promptkit.CapacityError
|
||||
if errors.As(err, &capacityError) {
|
||||
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, promptkit.ErrInvalidConfig):
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||
case errors.Is(err, promptkit.ErrPromptNotFound):
|
||||
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
|
||||
case errors.Is(err, promptkit.ErrPromptLoad):
|
||||
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrProfileNotFound):
|
||||
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
|
||||
case errors.Is(err, promptkit.ErrProfileLoad):
|
||||
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
|
||||
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
|
||||
case errors.Is(err, promptkit.ErrArtifactLoad):
|
||||
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrPromptRender):
|
||||
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
|
||||
case errors.Is(err, promptkit.ErrCapacityExceeded):
|
||||
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
|
||||
case errors.Is(err, promptkit.ErrLLMGenerate):
|
||||
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
|
||||
case errors.Is(err, promptkit.ErrValidation):
|
||||
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
|
||||
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
|
||||
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
|
||||
default:
|
||||
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
|
||||
}
|
||||
}
|
||||
380
internal/adapters/promptkit/adapter_test.go
Normal file
380
internal/adapters/promptkit/adapter_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package promptkitadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
mu sync.Mutex
|
||||
response *promptkit.GenerateResponse
|
||||
err error
|
||||
calls int
|
||||
requests []promptkit.GenerateRequest
|
||||
block bool
|
||||
}
|
||||
|
||||
type recordingReader struct {
|
||||
ref promptkit.ArtifactRef
|
||||
}
|
||||
|
||||
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||
reader.ref = ref
|
||||
return &promptkit.Artifact{
|
||||
Name: "data_package",
|
||||
ContentType: "application/yaml",
|
||||
Body: []byte(ref.Body),
|
||||
URI: ref.URI,
|
||||
Hash: "input-hash",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||
client.mu.Lock()
|
||||
client.calls++
|
||||
client.requests = append(client.requests, request)
|
||||
block := client.block
|
||||
response := client.response
|
||||
err := client.err
|
||||
client.mu.Unlock()
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (client *fakeClient) callCount() int {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.calls
|
||||
}
|
||||
|
||||
func (client *fakeClient) request() promptkit.GenerateRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.requests[0]
|
||||
}
|
||||
|
||||
func TestInspectPromptAndProfile(t *testing.T) {
|
||||
adapter := newTestAdapter(t, &fakeClient{})
|
||||
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPrompt() error = %v", err)
|
||||
}
|
||||
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
|
||||
t.Fatalf("inputs = %#v", inspection.Inputs)
|
||||
}
|
||||
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
|
||||
t.Fatalf("output = %#v", inspection.Output)
|
||||
}
|
||||
|
||||
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile() error = %v", err)
|
||||
}
|
||||
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
|
||||
t.Fatalf("profile = %#v", profile)
|
||||
}
|
||||
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
|
||||
t.Fatalf("profile leaks endpoint: %#v", profile)
|
||||
}
|
||||
|
||||
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile(builtin) error = %v", err)
|
||||
}
|
||||
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
|
||||
t.Fatalf("builtin profile = %#v", builtin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
request := testExecuteRequest()
|
||||
callbackCalls := 0
|
||||
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
callbackCalls++
|
||||
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.DataPackagePath != request.DataPackagePath || preparation.ModelName != "test-model" {
|
||||
t.Fatalf("preparation = %#v", preparation)
|
||||
}
|
||||
if debug != nil {
|
||||
t.Fatalf("debug = %#v, want nil", debug)
|
||||
}
|
||||
if client.callCount() != 0 {
|
||||
t.Fatal("provider called before preparation callback")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if callbackCalls != 1 || client.callCount() != 1 {
|
||||
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
|
||||
}
|
||||
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content || result.DataPackagePath != request.DataPackagePath {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if result.Debug != nil {
|
||||
t.Fatalf("debug = %#v, want nil", result.Debug)
|
||||
}
|
||||
providerRequest := client.request()
|
||||
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
|
||||
t.Fatalf("provider target = %#v", providerRequest.Target)
|
||||
}
|
||||
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
|
||||
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
reader := &recordingReader{}
|
||||
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
|
||||
request := testExecuteRequest()
|
||||
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != request.DataPackagePath || reader.ref.Body != string(request.DataPackage) {
|
||||
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
request := testExecuteRequest()
|
||||
request.CaptureDebug = true
|
||||
var preparationDebug *promptexec.PreparationDebug
|
||||
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
preparationDebug = debug
|
||||
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
|
||||
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
|
||||
t.Fatalf("preparation debug = %#v", preparationDebug)
|
||||
}
|
||||
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
|
||||
t.Fatalf("execution debug = %#v", result.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
callbackError := errors.New("save preparation")
|
||||
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
|
||||
return callbackError
|
||||
})
|
||||
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
|
||||
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
||||
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
|
||||
adapter := newTestAdapter(t, client)
|
||||
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
client *fakeClient
|
||||
context func() (context.Context, context.CancelFunc)
|
||||
category promptexec.ErrorCategory
|
||||
}{
|
||||
{
|
||||
name: "generation",
|
||||
client: &fakeClient{err: errors.New("provider response body")},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(context.Background())
|
||||
},
|
||||
category: promptexec.Generation,
|
||||
},
|
||||
{
|
||||
name: "canceled",
|
||||
client: &fakeClient{block: true},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx, func() {}
|
||||
},
|
||||
category: promptexec.Canceled,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
client: &fakeClient{block: true},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
},
|
||||
category: promptexec.DeadlineExceeded,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter := newTestAdapter(t, test.client)
|
||||
ctx, cancel := test.context()
|
||||
defer cancel()
|
||||
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
|
||||
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
|
||||
}
|
||||
if strings.Contains(err.Error(), "provider response body") {
|
||||
t.Fatalf("error leaks provider detail: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPromptkitErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
category promptexec.ErrorCategory
|
||||
}{
|
||||
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
|
||||
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
|
||||
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
|
||||
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
|
||||
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
|
||||
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
|
||||
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
|
||||
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
|
||||
{promptkit.ErrPromptRender, promptexec.PromptRender},
|
||||
{promptkit.ErrLLMGenerate, promptexec.Generation},
|
||||
{promptkit.ErrValidation, promptexec.OperationalValidation},
|
||||
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.category), func(t *testing.T) {
|
||||
got := classifyError(test.err)
|
||||
if promptexec.CategoryOf(got) != test.category {
|
||||
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesConfiguration(t *testing.T) {
|
||||
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("profile source error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("local concurrency error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("local endpoint error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||
profiles := testProfileDirectory(t, `id: local-profile
|
||||
backend: local
|
||||
model: local-model
|
||||
`)
|
||||
adapter, err := newAdapterForTest(Config{
|
||||
ProfileDirectory: profiles,
|
||||
LocalEndpoint: "https://local.example/v1",
|
||||
LocalConcurrencyLimit: 1,
|
||||
}, &fakeClient{})
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest(local) error = %v", err)
|
||||
}
|
||||
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
|
||||
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
|
||||
t.Fatalf("local profile/error = %#v/%v", profile, err)
|
||||
}
|
||||
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
|
||||
t.Fatalf("capacity classification = %v", got)
|
||||
}
|
||||
|
||||
credentialProfiles := testProfileDirectory(t, `id: credential-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||
`)
|
||||
client := &fakeClient{response: validResponse()}
|
||||
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest(credential) error = %v", err)
|
||||
}
|
||||
request := testExecuteRequest()
|
||||
request.ProfileID = "credential-profile"
|
||||
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
|
||||
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
|
||||
return newTestAdapterWithOptions(t, client)
|
||||
}
|
||||
|
||||
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
||||
t.Helper()
|
||||
profiles := testProfileDirectory(t, `id: test-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
temperature: 0.2
|
||||
max_tokens: 300
|
||||
top_p: 1
|
||||
timeout_seconds: 30
|
||||
`)
|
||||
options = append(options, promptkit.WithLLMClient(client))
|
||||
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapter() error = %v", err)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
func testProfileDirectory(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
profiles := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func testExecuteRequest() promptexec.ExecuteRequest {
|
||||
return promptexec.ExecuteRequest{
|
||||
PromptID: "weather.daily_generated_text",
|
||||
PromptVersion: "1.0.0",
|
||||
ProfileID: "test-profile",
|
||||
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||
DataPackagePath: "data-packages/daily/data_package.yaml",
|
||||
}
|
||||
}
|
||||
|
||||
func validResponse() *promptkit.GenerateResponse {
|
||||
return &promptkit.GenerateResponse{
|
||||
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`,
|
||||
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,16 @@ type Validation struct {
|
||||
Diagnostics []string
|
||||
}
|
||||
|
||||
// NewValidation returns a completed validation value with bounded diagnostics.
|
||||
func NewValidation(status ValidationStatus, mode string, schemaPath string, diagnostics []string) Validation {
|
||||
return Validation{
|
||||
Status: status,
|
||||
Mode: mode,
|
||||
SchemaPath: schemaPath,
|
||||
Diagnostics: boundDiagnostics(diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationStatus identifies the completed validation state.
|
||||
type ValidationStatus string
|
||||
|
||||
|
||||
@@ -172,6 +172,10 @@ func TestBoundDiagnosticAndErrorText(t *testing.T) {
|
||||
if bounded[0] != "a<>b" {
|
||||
t.Fatalf("invalid UTF-8 diagnostic = %q, want replacement", bounded[0])
|
||||
}
|
||||
validation := NewValidation(ValidationFailed, "json_schema", "daily.schema.json", values)
|
||||
if len(validation.Diagnostics) != maxValidationDiagnostics || validation.Diagnostics[0] != "a<>b" {
|
||||
t.Fatalf("validation = %#v, want bounded diagnostics", validation)
|
||||
}
|
||||
|
||||
err := NewError(Generation, strings.Repeat("é", maxErrorMessageBytes), nil)
|
||||
if len(err.Error()) > maxErrorMessageBytes || !utf8.ValidString(err.Error()) {
|
||||
|
||||
Reference in New Issue
Block a user