diff --git a/engine_test.go b/engine_test.go index d5b43f1..9b005bd 100644 --- a/engine_test.go +++ b/engine_test.go @@ -203,26 +203,44 @@ func TestPreparedRunJSONDoesNotExposeSecretOrTargetPresence(t *testing.T) { } func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) { - const secret = "run-request-secret" + const ( + secret = "run-request-api-key-sentinel" + inputURI = "memory://run-request-uri-sentinel" + inputBody = "run-request-body-sentinel" + variableValue = "run-request-variable-sentinel" + ) req := promptkit.RunRequest{ PromptID: frameworkMarkdownSummaryPromptID, ProfileID: frameworkFastProfileID, APIKey: secret, Inputs: map[string]promptkit.ArtifactRef{ - "transcript": promptkit.Inline("Rin opens the gate."), + "transcript": promptkit.InlineWithURI(inputURI, inputBody), }, + Vars: map[string]string{"audience": variableValue}, } for _, formatted := range []string{ - fmt.Sprint(req), + req.String(), + req.GoString(), + fmt.Sprintf("%v", req), fmt.Sprintf("%+v", req), fmt.Sprintf("%#v", req), } { - if strings.Contains(formatted, secret) { - t.Fatalf("formatted RunRequest leaked API key: %s", formatted) + for _, privateValue := range []string{secret, inputURI, inputBody, variableValue} { + if strings.Contains(formatted, privateValue) { + t.Fatalf("formatted RunRequest leaked private value %q: %s", privateValue, formatted) + } } - if !strings.Contains(formatted, "APIKeySet:true") { - t.Fatalf("formatted RunRequest should indicate an API key is set, got %s", formatted) + for _, summary := range []string{ + `PromptID:"` + frameworkMarkdownSummaryPromptID + `"`, + `ProfileID:"` + frameworkFastProfileID + `"`, + "APIKeySet:true", + "Inputs:1", + "Vars:1", + } { + if !strings.Contains(formatted, summary) { + t.Fatalf("formatted RunRequest omitted structural summary %q: %s", summary, formatted) + } } } @@ -1868,6 +1886,46 @@ func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) { } } +func TestOpenAICompatibleProfileMapsEveryField(t *testing.T) { + extraParams := map[string]any{"provider_option": "distinct-extra-params"} + got := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{ + ID: "distinct-id", + BackendID: "distinct-backend", + Endpoint: "https://distinct.example/v1", + Model: "distinct-model", + APIKeyRequired: true, + Temperature: 0.25, + MaxTokens: 321, + TopP: 0.75, + TimeoutSeconds: 43, + ServiceTier: "distinct-service-tier", + ReasoningEffort: "distinct-reasoning-effort", + ExtraParams: extraParams, + }) + want := promptkit.Profile{ + ID: "distinct-id", + BackendID: "distinct-backend", + Endpoint: "https://distinct.example/v1", + Model: "distinct-model", + Temperature: 0.25, + MaxTokens: 321, + TopP: 0.75, + TimeoutSeconds: 43, + ServiceTier: "distinct-service-tier", + ReasoningEffort: "distinct-reasoning-effort", + APIKeyRequired: true, + ExtraParams: map[string]any{"provider_option": "distinct-extra-params"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("profile mapping:\ngot %#v\nwant %#v", got, want) + } + + extraParams["added_after_construction"] = true + if _, ok := got.ExtraParams["added_after_construction"]; ok { + t.Fatal("profile retained the configuration map") + } +} + func TestEngineRunLayersTransportAndGenerationTimeouts(t *testing.T) { intPointer := func(value int) *int { return &value diff --git a/llm_adapter_internal_test.go b/llm_adapter_internal_test.go new file mode 100644 index 0000000..c81b8fb --- /dev/null +++ b/llm_adapter_internal_test.go @@ -0,0 +1,96 @@ +package promptkit + +import ( + "context" + "reflect" + "strconv" + "sync" + "testing" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +func TestPublicLLMClientAdapterGivesClientOwnedNestedValues(t *testing.T) { + source := adapterOwnershipRequest() + want := adapterOwnershipRequest() + client := &retainingMutatingLLMClient{} + adapter := publicLLMClientAdapter{client: client} + + if _, err := adapter.Generate(context.Background(), source); err != nil { + t.Fatalf("generate: %v", err) + } + if !reflect.DeepEqual(source, want) { + t.Fatalf("client mutation changed prepared source:\ngot %#v\nwant %#v", source, want) + } + + var mutations sync.WaitGroup + mutations.Add(1) + go func() { + defer mutations.Done() + for i := 0; i < 10_000; i++ { + mutateGenerateRequest(&client.retained, strconv.Itoa(i)) + } + }() + for i := 0; i < 10_000; i++ { + laterRequest := fromDomainGenerateRequest(source) + if laterRequest.Prompt.Messages[0].Content != "source-message" || + laterRequest.Prompt.Messages[0].CacheControl.TTL != "source-ttl" || + laterRequest.Target.ExtraParams["nested"].([]any)[0] != "source-extra" || + laterRequest.StructuredOutput.JSONSchema.Schema.(map[string]any)["enum"].([]any)[0] != "source-schema" { + t.Fatal("retained client mutation reached a later execution request") + } + } + mutations.Wait() + + if !reflect.DeepEqual(source, want) { + t.Fatalf("retained client mutation changed prepared source:\ngot %#v\nwant %#v", source, want) + } +} + +type retainingMutatingLLMClient struct { + retained GenerateRequest +} + +func (c *retainingMutatingLLMClient) Generate(_ context.Context, request GenerateRequest) (*GenerateResponse, error) { + c.retained = request + mutateGenerateRequest(&c.retained, "client-mutation") + return &GenerateResponse{Content: "generated"}, nil +} + +func adapterOwnershipRequest() domain.GenerateRequest { + return domain.GenerateRequest{ + Prompt: domain.RenderedPrompt{ + SessionID: "source-session", + Messages: []domain.RenderedMessage{{ + Role: "user", + Content: "source-message", + CacheControl: &domain.CacheControl{ + Type: domain.CacheControlEphemeral, + TTL: "source-ttl", + }, + }}, + }, + Target: domain.ExecutionTarget{ + Model: "source-model", + APIKey: "source-api-key", + ExtraParams: map[string]any{ + "nested": []any{"source-extra"}, + }, + }, + StructuredOutput: &domain.StructuredOutputSpec{ + Type: domain.StructuredOutputJSONSchema, + JSONSchema: &domain.StructuredOutputJSONSpec{ + Name: "source-schema-name", + Strict: true, + Schema: map[string]any{"enum": []any{"source-schema"}}, + }, + }, + } +} + +func mutateGenerateRequest(request *GenerateRequest, value string) { + request.Prompt.Messages[0].Content = value + request.Prompt.Messages[0].CacheControl.TTL = value + request.Target.ExtraParams["nested"].([]any)[0] = value + request.StructuredOutput.JSONSchema.Schema.(map[string]any)["enum"].([]any)[0] = value +} diff --git a/prepared_execution.go b/prepared_execution.go index 06b584d..f0f8a9d 100644 --- a/prepared_execution.go +++ b/prepared_execution.go @@ -44,12 +44,12 @@ func (p *PreparedExecution) Discard() { // String returns a constant representation that exposes no retained request, // rendered content, or credential data. -func (p *PreparedExecution) String() string { +func (p PreparedExecution) String() string { return preparedExecutionString } // GoString returns a constant Go-syntax representation that exposes no // retained request, rendered content, or credential data. -func (p *PreparedExecution) GoString() string { +func (p PreparedExecution) GoString() string { return preparedExecutionString } diff --git a/prepared_execution_contract_test.go b/prepared_execution_contract_test.go index cbd3955..a4dfb69 100644 --- a/prepared_execution_contract_test.go +++ b/prepared_execution_contract_test.go @@ -409,14 +409,35 @@ func TestPreparedExecutionDiscardAndFormattingDoNotExposePrivateState(t *testing t.Fatalf("prepare execution: %v", err) } - formattedValues := []string{ - fmt.Sprint(prepared), - fmt.Sprintf("%+v", prepared), - fmt.Sprintf("%#v", prepared), + copied := *prepared + zeroValue := promptkit.PreparedExecution{} + var nilHandle *promptkit.PreparedExecution + for name, value := range map[string]any{ + "original pointer": prepared, + "copied value": copied, + "zero value": zeroValue, + "zero pointer": &zeroValue, + } { + for format, formatted := range map[string]string{ + "String": fmt.Sprintf("%s", value), + "GoString": fmt.Sprintf("%#v", value), + "v": fmt.Sprintf("%v", value), + "+v": fmt.Sprintf("%+v", value), + } { + if formatted != "promptkit.PreparedExecution{opaque}" { + t.Fatalf("%s %s formatting = %q, want opaque representation", name, format, formatted) + } + assertPreparedPrivateValuesAbsent(t, formatted, directCredential, renderedContent) + } } - for _, formatted := range formattedValues { - if formatted != "promptkit.PreparedExecution{opaque}" { - t.Fatalf("unexpected opaque formatting: %q", formatted) + for format, formatted := range map[string]string{ + "String": fmt.Sprintf("%s", nilHandle), + "GoString": fmt.Sprintf("%#v", nilHandle), + "v": fmt.Sprintf("%v", nilHandle), + "+v": fmt.Sprintf("%+v", nilHandle), + } { + if formatted != "" { + t.Fatalf("nil pointer %s formatting = %q, want ", format, formatted) } assertPreparedPrivateValuesAbsent(t, formatted, directCredential, renderedContent) } @@ -433,27 +454,9 @@ func TestPreparedExecutionDiscardAndFormattingDoNotExposePrivateState(t *testing } assertPreparedPrivateValuesAbsent(t, string(detailsJSON), directCredential) - prepared.Discard() - prepared.Discard() - result, lifecycleErr := engine.RunPrepared(context.Background(), prepared) - if result != nil || !errors.Is(lifecycleErr, promptkit.ErrInvalidRequest) { - t.Fatalf("discarded execution result=(%+v, %v), want ErrInvalidRequest", result, lifecycleErr) - } - assertPreparedPrivateValuesAbsent(t, lifecycleErr.Error(), directCredential, renderedContent) - if !reflect.DeepEqual(prepared.Details(), detailsBefore) { - t.Fatal("details changed after discard") - } - - executed, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{ - PromptID: "prepared", - APIKey: directCredential, - }) + executionResult, err := engine.RunPrepared(context.Background(), &copied) if err != nil { - t.Fatalf("prepare execution for request inspection: %v", err) - } - executionResult, err := engine.RunPrepared(context.Background(), executed) - if err != nil { - t.Fatalf("run execution for request inspection: %v", err) + t.Fatalf("run copied execution after formatting: %v", err) } requests := client.snapshot() if len(requests) != 1 || requests[0].APIKey != directCredential { @@ -478,16 +481,34 @@ func TestPreparedExecutionDiscardAndFormattingDoNotExposePrivateState(t *testing } assertPreparedPrivateValuesAbsent(t, string(resultJSON), directCredential) - var nilHandle *promptkit.PreparedExecution nilHandle.Discard() if !reflect.DeepEqual(nilHandle.Details(), promptkit.PreparedRun{}) { t.Fatalf("nil handle details=%+v, want zero value", nilHandle.Details()) } - zeroHandle := &promptkit.PreparedExecution{} + zeroHandle := &zeroValue zeroHandle.Discard() if !reflect.DeepEqual(zeroHandle.Details(), promptkit.PreparedRun{}) { t.Fatalf("zero handle details=%+v, want zero value", zeroHandle.Details()) } + + discarded, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{ + PromptID: "prepared", + APIKey: directCredential, + }) + if err != nil { + t.Fatalf("prepare execution for discard: %v", err) + } + discardedDetails := discarded.Details() + discarded.Discard() + discarded.Discard() + result, lifecycleErr := engine.RunPrepared(context.Background(), discarded) + if result != nil || !errors.Is(lifecycleErr, promptkit.ErrInvalidRequest) { + t.Fatalf("discarded execution result=(%+v, %v), want ErrInvalidRequest", result, lifecycleErr) + } + assertPreparedPrivateValuesAbsent(t, lifecycleErr.Error(), directCredential, renderedContent) + if !reflect.DeepEqual(discarded.Details(), discardedDetails) { + t.Fatal("details changed after discard") + } } func TestPreparedExecutionCredentialCapacityAndTimingBoundaries(t *testing.T) {